diff --git a/pkg/arvo/app/graph-push-hook.hoon b/pkg/arvo/app/graph-push-hook.hoon index d685e3dec..4501aa03f 100644 --- a/pkg/arvo/app/graph-push-hook.hoon +++ b/pkg/arvo/app/graph-push-hook.hoon @@ -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] +-- + diff --git a/pkg/arvo/app/group-store.hoon b/pkg/arvo/app/group-store.hoon index c85a0ad63..d1e0d9ebd 100644 --- a/pkg/arvo/app/group-store.hoon +++ b/pkg/arvo/app/group-store.hoon @@ -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)] diff --git a/pkg/arvo/app/invite-store.hoon b/pkg/arvo/app/invite-store.hoon index 47de74ba8..d7bbbf0c3 100644 --- a/pkg/arvo/app/invite-store.hoon +++ b/pkg/arvo/app/invite-store.hoon @@ -38,7 +38,9 @@ %_ this invites.state %- ~(gas by *invites:store) - [%graph *invitatory:store]~ + :~ [%graph *invitatory:store] + [%groups *invitatory:store] + == == :: ++ on-save !>(state) diff --git a/pkg/arvo/app/metadata-hook.hoon b/pkg/arvo/app/metadata-hook.hoon index 8d0d99e33..edd91bac2 100644 --- a/pkg/arvo/app/metadata-hook.hoon +++ b/pkg/arvo/app/metadata-hook.hoon @@ -39,6 +39,8 @@ ++ on-save !>(state) ++ on-load |= =vase + ?: =(1 1) + `this =+ !<(old=versioned-state vase) |^ ?: ?=(%2 -.old) diff --git a/pkg/arvo/app/metadata-push-hook.hoon b/pkg/arvo/app/metadata-push-hook.hoon index a2d5f3c13..aa096a857 100644 --- a/pkg/arvo/app/metadata-push-hook.hoon +++ b/pkg/arvo/app/metadata-push-hook.hoon @@ -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 diff --git a/pkg/arvo/lib/graph.hoon b/pkg/arvo/lib/graph.hoon index 3c71c20a7..4f8ebe37b 100644 --- a/pkg/arvo/lib/graph.hoon +++ b/pkg/arvo/lib/graph.hoon @@ -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]) -- diff --git a/pkg/arvo/lib/group-store.hoon b/pkg/arvo/lib/group-store.hoon index da60c2926..dedc9dd4d 100644 --- a/pkg/arvo/lib/group-store.hoon +++ b/pkg/arvo/lib/group-store.hoon @@ -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 == diff --git a/pkg/arvo/lib/group-view.hoon b/pkg/arvo/lib/group-view.hoon index 483b97a86..f3919891a 100644 --- a/pkg/arvo/lib/group-view.hoon +++ b/pkg/arvo/lib/group-view.hoon @@ -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 diff --git a/pkg/arvo/lib/group.hoon b/pkg/arvo/lib/group.hoon index dc4041805..d2ef6af01 100644 --- a/pkg/arvo/lib/group.hoon +++ b/pkg/arvo/lib/group.hoon @@ -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) diff --git a/pkg/arvo/mar/graph/permissions/add.hoon b/pkg/arvo/mar/graph/permissions/add.hoon new file mode 100644 index 000000000..614530dc1 --- /dev/null +++ b/pkg/arvo/mar/graph/permissions/add.hoon @@ -0,0 +1,12 @@ +/- *graph-store +|_ per=permissions +++ grad %noun +++ grow + |% + ++ noun per + -- +++ grab + |% + ++ noun permissions + -- +-- diff --git a/pkg/arvo/mar/graph/permissions/remove.hoon b/pkg/arvo/mar/graph/permissions/remove.hoon new file mode 100644 index 000000000..614530dc1 --- /dev/null +++ b/pkg/arvo/mar/graph/permissions/remove.hoon @@ -0,0 +1,12 @@ +/- *graph-store +|_ per=permissions +++ grad %noun +++ grow + |% + ++ noun per + -- +++ grab + |% + ++ noun permissions + -- +-- diff --git a/pkg/arvo/mar/graph/validator/chat.hoon b/pkg/arvo/mar/graph/validator/chat.hoon index 236c6ac45..344b3822d 100644 --- a/pkg/arvo/mar/graph/validator/chat.hoon +++ b/pkg/arvo/mar/graph/validator/chat.hoon @@ -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] == diff --git a/pkg/arvo/mar/graph/validator/link.hoon b/pkg/arvo/mar/graph/validator/link.hoon index 1efcf6b11..143a99258 100644 --- a/pkg/arvo/mar/graph/validator/link.hoon +++ b/pkg/arvo/mar/graph/validator/link.hoon @@ -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] diff --git a/pkg/arvo/mar/graph/validator/publish.hoon b/pkg/arvo/mar/graph/validator/publish.hoon index d55bbed2c..62fc8c466 100644 --- a/pkg/arvo/mar/graph/validator/publish.hoon +++ b/pkg/arvo/mar/graph/validator/publish.hoon @@ -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=* diff --git a/pkg/arvo/mar/metadata/update.hoon b/pkg/arvo/mar/metadata/update.hoon index d9aa1b9c3..9f894cf84 100644 --- a/pkg/arvo/mar/metadata/update.hoon +++ b/pkg/arvo/mar/metadata/update.hoon @@ -12,5 +12,4 @@ ++ noun update:store ++ json action:dejs:store -- -:: -- diff --git a/pkg/arvo/sur/graph-store.hoon b/pkg/arvo/sur/graph-store.hoon index 6780e4210..845885ed1 100644 --- a/pkg/arvo/sur/graph-store.hoon +++ b/pkg/arvo/sur/graph-store.hoon @@ -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] diff --git a/pkg/arvo/sur/group-store.hoon b/pkg/arvo/sur/group-store.hoon index 6905704a9..5a3931f95 100644 --- a/pkg/arvo/sur/group-store.hoon +++ b/pkg/arvo/sur/group-store.hoon @@ -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 diff --git a/pkg/arvo/sur/group-view.hoon b/pkg/arvo/sur/group-view.hoon index 4ffbcfc8a..549ae5430 100644 --- a/pkg/arvo/sur/group-view.hoon +++ b/pkg/arvo/sur/group-view.hoon @@ -9,6 +9,8 @@ :: client side [%join =resource =ship] [%leave =resource] + :: + [%invite =resource ships=(set ship) description=@t] == :: diff --git a/pkg/arvo/sur/group.hoon b/pkg/arvo/sur/group.hoon index 837276d6b..c5806493d 100644 --- a/pkg/arvo/sur/group.hoon +++ b/pkg/arvo/sur/group.hoon @@ -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 diff --git a/pkg/arvo/ted/graph/delete.hoon b/pkg/arvo/ted/graph/delete.hoon index 04603e546..488b44917 100644 --- a/pkg/arvo/ted/graph/delete.hoon +++ b/pkg/arvo/ted/graph/delete.hoon @@ -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 diff --git a/pkg/arvo/ted/group/invite.hoon b/pkg/arvo/ted/group/invite.hoon new file mode 100644 index 000000000..ad42479ef --- /dev/null +++ b/pkg/arvo/ted/group/invite.hoon @@ -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 !>(~)) diff --git a/pkg/interface/src/logic/api/groups.ts b/pkg/interface/src/logic/api/groups.ts index 4e5182abe..de2f4b488 100644 --- a/pkg/interface/src/logic/api/groups.ts +++ b/pkg/interface/src/logic/api/groups.ts @@ -67,6 +67,18 @@ export default class GroupsApi extends BaseApi { }); } + 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); } diff --git a/pkg/interface/src/logic/api/hark.ts b/pkg/interface/src/logic/api/hark.ts index 288ce6b2b..c049b4a84 100644 --- a/pkg/interface/src/logic/api/hark.ts +++ b/pkg/interface/src/logic/api/hark.ts @@ -17,10 +17,7 @@ export class HarkApi extends BaseApi { private groupHookAction(action: any) { return this.action("hark-group-hook", "hark-group-hook-action", action); } - - private chatHookAction(action: any) { - return this.action("hark-chat-hook", "hark-chat-hook-action", action); - } + private actOnNotification(frond: string, intTime: BigInteger, index: NotifIndex) { const time = decToUd(intTime.toString()); @@ -36,9 +33,6 @@ export class HarkApi extends BaseApi { await this.graphHookAction({ 'set-mentions': mentions }); - return this.chatHookAction({ - 'set-mentions': mentions - }); } setWatchOnSelf(watchSelf: boolean) { @@ -89,15 +83,15 @@ export class HarkApi extends BaseApi { markEachAsRead(association: Association, parent: string, child: string, description: GraphNotifDescription, mod: string) { return this.harkAction({ 'read-each': { - index: + index: { graph: - { graph: association.resource, + { graph: association.resource, group: association.group, - description, + description, module: mod, - index: parent + index: parent } - }, + }, target: child } }); @@ -129,9 +123,6 @@ export class HarkApi extends BaseApi { 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 { 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 { }) } - 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 { }) } - listenChat(chat: string) { - return this.chatHookAction({ - listen: chat - }); - } - async getMore(): Promise { const offset = this.store.state['notifications']?.size || 0; const count = 3; diff --git a/pkg/interface/src/logic/api/metadata.ts b/pkg/interface/src/logic/api/metadata.ts index b17e681d8..5a16e479d 100644 --- a/pkg/interface/src/logic/api/metadata.ts +++ b/pkg/interface/src/logic/api/metadata.ts @@ -23,9 +23,9 @@ export default class MetadataApi extends BaseApi { 'date-created': dateCreated, creator, 'module': moduleName, - preview: false, picture: '', - permissions: '' + preview: false, + vip: '' } } }); diff --git a/pkg/interface/src/logic/lib/group.ts b/pkg/interface/src/logic/lib/group.ts index 7d554cc1b..0bc3274f7 100644 --- a/pkg/interface/src/logic/lib/group.ts +++ b/pkg/interface/src/logic/lib/group.ts @@ -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 | 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; +} diff --git a/pkg/interface/src/logic/lib/omnibox.js b/pkg/interface/src/logic/lib/omnibox.js index 0c2e27c3e..d79ae08e8 100644 --- a/pkg/interface/src/logic/lib/omnibox.js +++ b/pkg/interface/src/logic/lib/omnibox.js @@ -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}`, diff --git a/pkg/interface/src/logic/lib/useModal.tsx b/pkg/interface/src/logic/lib/useModal.tsx index 3aa6f21d9..982ed5fbd 100644 --- a/pkg/interface/src/logic/lib/useModal.tsx +++ b/pkg/interface/src/logic/lib/useModal.tsx @@ -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(); 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 : ( - - - {inner} - - + {inner} + ), [inner, dismiss] ); diff --git a/pkg/interface/src/logic/lib/useOutsideClick.ts b/pkg/interface/src/logic/lib/useOutsideClick.ts index 412e3ee5c..f8b7c4138 100644 --- a/pkg/interface/src/logic/lib/useOutsideClick.ts +++ b/pkg/interface/src/logic/lib/useOutsideClick.ts @@ -1,7 +1,7 @@ import { useEffect, RefObject } from "react"; export function useOutsideClick( - ref: RefObject, + ref: RefObject, 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]); } diff --git a/pkg/interface/src/logic/lib/util.ts b/pkg/interface/src/logic/lib/util.ts index b22b89924..c39f9607e 100644 --- a/pkg/interface/src/logic/lib/util.ts +++ b/pkg/interface/src/logic/lib/util.ts @@ -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 +}; diff --git a/pkg/interface/src/logic/lib/workspace.ts b/pkg/interface/src/logic/lib/workspace.ts index 7532bac6e..e17b81e7b 100644 --- a/pkg/interface/src/logic/lib/workspace.ts +++ b/pkg/interface/src/logic/lib/workspace.ts @@ -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 || ""; diff --git a/pkg/interface/src/logic/reducers/group-update.ts b/pkg/interface/src/logic/reducers/group-update.ts index 0b0ad8ec0..8de063f89 100644 --- a/pkg/interface/src/logic/reducers/group-update.ts +++ b/pkg/interface/src/logic/reducers/group-update.ts @@ -41,22 +41,16 @@ function decodePolicy(policy: Enc): GroupPolicy { } function decodeTags(tags: Enc): 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 { 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 { 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) { diff --git a/pkg/interface/src/logic/reducers/hark-update.ts b/pkg/interface/src/logic/reducers/hark-update.ts index 8db766f53..e35f62f29 100644 --- a/pkg/interface/src/logic/reducers/hark-update.ts +++ b/pkg/interface/src/logic/reducers/hark-update.ts @@ -10,7 +10,7 @@ import _ from "lodash"; import {StoreState} from "../store/type"; import { BigIntOrderedMap } from '../lib/BigIntOrderedMap'; -type HarkState = Pick; +type HarkState = Pick; 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(), archivedNotifications: new BigIntOrderedMap(), 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; } diff --git a/pkg/interface/src/logic/store/store.ts b/pkg/interface/src/logic/store/store.ts index ad0ccbfb7..4a56d1a74 100644 --- a/pkg/interface/src/logic/store/store.ts +++ b/pkg/interface/src/logic/store/store.ts @@ -83,7 +83,6 @@ export default class GlobalStore extends BaseStore { notifications: new BigIntOrderedMap(), archivedNotifications: new BigIntOrderedMap(), notificationsGroupConfig: [], - notificationsChatConfig: [], notificationsGraphConfig: { watchOnSelf: false, mentions: false, diff --git a/pkg/interface/src/types/group-update.ts b/pkg/interface/src/types/group-update.ts index 8640f3e02..ad7c35004 100644 --- a/pkg/interface/src/types/group-update.ts +++ b/pkg/interface/src/types/group-update.ts @@ -8,6 +8,7 @@ interface RoleTag { interface AppTag { app: string; + resource: string; tag: string; } diff --git a/pkg/interface/src/types/hark-update.ts b/pkg/interface/src/types/hark-update.ts index 9191a1709..156f96b66 100644 --- a/pkg/interface/src/types/hark-update.ts +++ b/pkg/interface/src/types/hark-update.ts @@ -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; graph: Record>; group: Record; } diff --git a/pkg/interface/src/types/metadata-update.ts b/pkg/interface/src/types/metadata-update.ts index ae00a180f..640bd5a9b 100644 --- a/pkg/interface/src/types/metadata-update.ts +++ b/pkg/interface/src/types/metadata-update.ts @@ -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'; diff --git a/pkg/interface/src/types/util.ts b/pkg/interface/src/types/util.ts index c2e281d81..435410fec 100644 --- a/pkg/interface/src/types/util.ts +++ b/pkg/interface/src/types/util.ts @@ -1,2 +1,4 @@ - +import { Icon } from "@tlon/indigo-react"; export type PropFunc any> = Parameters[0]; + +export type IconRef = PropFunc['icon']; diff --git a/pkg/interface/src/types/workspace.ts b/pkg/interface/src/types/workspace.ts index 5b4e68877..69da82ed3 100644 --- a/pkg/interface/src/types/workspace.ts +++ b/pkg/interface/src/types/workspace.ts @@ -9,4 +9,8 @@ interface HomeWorkspace { type: 'home' } -export type Workspace = HomeWorkspace | GroupWorkspace; +interface Messages { + type: 'messages' +} + +export type Workspace = HomeWorkspace | GroupWorkspace | Messages; diff --git a/pkg/interface/src/views/apps/chat/ChatResource.tsx b/pkg/interface/src/views/apps/chat/ChatResource.tsx index df3db4fb4..e4d094da6 100644 --- a/pkg/interface/src/views/apps/chat/ChatResource.tsx +++ b/pkg/interface/src/views/apps/chat/ChatResource.tsx @@ -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(); + 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 && ( + /> )} ); } diff --git a/pkg/interface/src/views/apps/chat/components/ChatWindow.tsx b/pkg/interface/src/views/apps/chat/components/ChatWindow.tsx index 5073b8549..14c0bbf3f 100644 --- a/pkg/interface/src/views/apps/chat/components/ChatWindow.tsx +++ b/pkg/interface/src/views/apps/chat/components/ChatWindow.tsx @@ -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 - - {this.virtualList = list}} origin="bottom" diff --git a/pkg/interface/src/views/apps/chat/components/backlog-element.js b/pkg/interface/src/views/apps/chat/components/backlog-element.js deleted file mode 100644 index bef762ba5..000000000 --- a/pkg/interface/src/views/apps/chat/components/backlog-element.js +++ /dev/null @@ -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 ( - - - - Past messages are being restored - - - ); -}; diff --git a/pkg/interface/src/views/apps/chat/components/resubscribe-element.js b/pkg/interface/src/views/apps/chat/components/resubscribe-element.js deleted file mode 100644 index d12e39332..000000000 --- a/pkg/interface/src/views/apps/chat/components/resubscribe-element.js +++ /dev/null @@ -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 ( - - - 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. - - - - ); - } else { - return null; - } - } -} diff --git a/pkg/interface/src/views/apps/launch/app.js b/pkg/interface/src/views/apps/launch/app.js index 7861d905f..9b1929d70 100644 --- a/pkg/interface/src/views/apps/launch/app.js +++ b/pkg/interface/src/views/apps/launch/app.js @@ -76,9 +76,9 @@ export default function LaunchApp(props) { - DMs + Drafts + My Channels diff --git a/pkg/interface/src/views/apps/links/LinkResource.tsx b/pkg/interface/src/views/apps/links/LinkResource.tsx index f33990fd7..64a728cce 100644 --- a/pkg/interface/src/views/apps/links/LinkResource.tsx +++ b/pkg/interface/src/views/apps/links/LinkResource.tsx @@ -61,7 +61,6 @@ export function LinkResource(props: LinkResourceProps) { return
; } - return ( diff --git a/pkg/interface/src/views/apps/links/LinkWindow.tsx b/pkg/interface/src/views/apps/links/LinkWindow.tsx index 8e3fd3b49..37d00828e 100644 --- a/pkg/interface/src/views/apps/links/LinkWindow.tsx +++ b/pkg/interface/src/views/apps/links/LinkWindow.tsx @@ -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 ( <> diff --git a/pkg/interface/src/views/apps/notifications/graph.tsx b/pkg/interface/src/views/apps/notifications/graph.tsx index 36e3b3532..8d6cdc5b7 100644 --- a/pkg/interface/src/views/apps/notifications/graph.tsx +++ b/pkg/interface/src/views/apps/notifications/graph.tsx @@ -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) { diff --git a/pkg/interface/src/views/apps/notifications/header.tsx b/pkg/interface/src/views/apps/notifications/header.tsx index e27a4ef31..7d7644b1a 100644 --- a/pkg/interface/src/views/apps/notifications/header.tsx +++ b/pkg/interface/src/views/apps/notifications/header.tsx @@ -36,7 +36,6 @@ export function Header(props: { time: number; read: boolean; associations: Associations; - chat?: boolean; } & PropFunc ) { 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; diff --git a/pkg/interface/src/views/apps/notifications/notification.tsx b/pkg/interface/src/views/apps/notifications/notification.tsx index 0eb96642a..cc55d3814 100644 --- a/pkg/interface/src/views/apps/notifications/notification.tsx +++ b/pkg/interface/src/views/apps/notifications/notification.tsx @@ -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 () => { diff --git a/pkg/interface/src/views/apps/publish/components/Note.tsx b/pkg/interface/src/views/apps/publish/components/Note.tsx index 3c71ecd9e..abffefaba 100644 --- a/pkg/interface/src/views/apps/publish/components/Note.tsx +++ b/pkg/interface/src/views/apps/publish/components/Note.tsx @@ -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 = ( diff --git a/pkg/interface/src/views/apps/publish/components/Notebook.tsx b/pkg/interface/src/views/apps/publish/components/Notebook.tsx index de960f3d2..206349723 100644 --- a/pkg/interface/src/views/apps/publish/components/Notebook.tsx +++ b/pkg/interface/src/views/apps/publish/components/Notebook.tsx @@ -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 ( + + + {association.metadata?.title} + by + + {showNickname ? contact?.nickname : ship} + + + + `~${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 ( @@ -51,10 +52,14 @@ export class Writers extends Component { - {writers.length > 0 && <> + {writers.length > 0 ? <> Current writers: {writers} - } + : + + All group members can write to this channel + + } ); } diff --git a/pkg/interface/src/views/apps/publish/components/new-post.tsx b/pkg/interface/src/views/apps/publish/components/new-post.tsx index d44fa2885..2b0dd366a 100644 --- a/pkg/interface/src/views/apps/publish/components/new-post.tsx +++ b/pkg/interface/src/views/apps/publish/components/new-post.tsx @@ -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); diff --git a/pkg/interface/src/views/components/AsyncButton.tsx b/pkg/interface/src/views/components/AsyncButton.tsx index f62288ee5..a181ef784 100644 --- a/pkg/interface/src/views/components/AsyncButton.tsx +++ b/pkg/interface/src/views/components/AsyncButton.tsx @@ -4,11 +4,12 @@ import { Button, LoadingSpinner } from "@tlon/indigo-react"; import { useFormikContext } from "formik"; -export function AsyncButton({ +export function AsyncButton({ children, + onSuccess = () => {}, ...rest }: Parameters[0]) { - const { isSubmitting, status, isValid } = useFormikContext(); + const { isSubmitting, status, isValid, setStatus } = useFormikContext(); const [success, setSuccess] = useState(); 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]); diff --git a/pkg/interface/src/views/components/Comments.tsx b/pkg/interface/src/views/components/Comments.tsx index 717f84fec..9c37751e6 100644 --- a/pkg/interface/src/views/components/Comments.tsx +++ b/pkg/interface/src/views/components/Comments.tsx @@ -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 ( - {( !props.editCommentId ? : null )} + {( !props.editCommentId && canComment ? : null )} {( !!props.editCommentId ? ( { placeholder?: string; onChange?: (e: ChangeEvent) => void; onBlur?: (e: any) => void; + onFocus?: (e: FocusEvent) => void; } type DropdownSearchProps = PropFunc & @@ -53,6 +54,7 @@ export function DropdownSearch(props: DropdownSearchProps) { renderCandidate, disabled, placeholder, + onFocus = () => {}, onChange = () => {}, onBlur = () => {}, ...rest @@ -101,7 +103,7 @@ export function DropdownSearch(props: DropdownSearchProps) { return () => { mousetrap.unbind(["down", "tab"]); mousetrap.unbind(["up", "shift+tab"]); - mousetrap.unbind("enter", onEnter); + mousetrap.unbind("enter"); }; }, [textarea.current, next, back, onEnter]); diff --git a/pkg/interface/src/views/components/FormSubmit.tsx b/pkg/interface/src/views/components/FormSubmit.tsx new file mode 100644 index 000000000..ae481de85 --- /dev/null +++ b/pkg/interface/src/views/components/FormSubmit.tsx @@ -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(props: FormSubmitProps) { + const { children } = props; + const { initialValues, values, dirty, resetForm, isSubmitting } = useFormikContext(); + + const handleSuccess = useCallback(() => { + resetForm({ errors: {}, touched: {}, values, status: {} }); + }, [resetForm, values]); + + const handleRevert = useCallback(() => { + resetForm({ errors: {}, touched: {}, values: initialValues, status: {} }); + }, [resetForm, initialValues]); + + + return ( + + {dirty && !isSubmitting && ( + + )} + + {children} + + + ); +} diff --git a/pkg/interface/src/views/components/HoverBox.tsx b/pkg/interface/src/views/components/HoverBox.tsx index 4b6488b6a..5b2959eaf 100644 --- a/pkg/interface/src/views/components/HoverBox.tsx +++ b/pkg/interface/src/views/components/HoverBox.tsx @@ -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)` } `; -export const HoverBoxLink = ({ to, children, ...rest }) => ( +interface HoverBoxLinkProps { + to: string; +} + +export const HoverBoxLink = ({ + to, + children, + ...rest +}: HoverBoxLinkProps & PropFunc) => ( {children} diff --git a/pkg/interface/src/views/components/IconRadio.tsx b/pkg/interface/src/views/components/IconRadio.tsx new file mode 100644 index 000000000..912329e8f --- /dev/null +++ b/pkg/interface/src/views/components/IconRadio.tsx @@ -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[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 & { + 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 ( + + {children} + + ); +}; + +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 ( + + + + + + + + {caption ? ( + + ) : null} + + + + + ); +} diff --git a/pkg/interface/src/views/components/Invite.tsx b/pkg/interface/src/views/components/Invite.tsx index 34bbabfed..70c16ab4c 100644 --- a/pkg/interface/src/views/components/Invite.tsx +++ b/pkg/interface/src/views/components/Invite.tsx @@ -12,8 +12,16 @@ export class InviteItem extends Component<{invite: Invite, onAccept: (i: any) => <> - {cite(props.invite.resource.ship)} invited you to {props.invite.resource.name} + + {cite(props.invite.resource.ship)} + {" "}invited you to{" "} + {props.invite.resource.name} + {props.invite.text && ( + + {props.invite.text} + + )} ["m"]; +} +export const ModalOverlay = React.forwardRef( + (props: ModalOverlayProps & PropFunc, ref) => { + const { spacing, ...rest } = props; + return ( + + + + ); + } +); diff --git a/pkg/interface/src/views/components/ShipSearch.tsx b/pkg/interface/src/views/components/ShipSearch.tsx index 6c9db1760..b65beb529 100644 --- a/pkg/interface/src/views/components/ShipSearch.tsx +++ b/pkg/interface/src/views/components/ShipSearch.tsx @@ -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 { 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(); + const nicknames = new Map(); + _.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 }) => ( ( ); -export function ShipSearch(props: InviteSearchProps) { +type Value = { + [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>( + props: InviteSearchProps +) { const { id, label, caption } = props; - const [{}, meta, { setValue, setTouched, setError: _setError }] = useField({ - name: id, - multiple: true - }); + const { + values, + touched, + errors, + initialValues, + setFieldValue, + } = useFormikContext(); - 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(); - const contacts = new Map(); - _.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) => { + const newValue = + e.target.value?.length > 0 ? `~${deSig(e.target.value)}` : ""; + setFieldValue(name(), newValue); + }; + + const error = _.compact(errors[id] as string[]); + return ( - - - {caption && ( - - )} - - 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} - /> - - {selected.map((s) => ( - - {cite(s)} - onRemove(s)} - cursor="pointer" + { + const onAdd = () => { + inputIdx.current += 1; + arrayHelpers.push(""); + }; + + const onRemove = (idx: number) => { + inputIdx.current -= 1; + arrayHelpers.remove(idx); + }; + + return ( + + + {caption && ( + + )} + + + 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} /> - - ))} - - - {error} - - + + {pills.map((s, i) => ( + + {cite(s)} + onRemove(i)} + cursor="pointer" + /> + + ))} + + 0}> + {error.join(", ")} + + + ); + }} + /> ); } diff --git a/pkg/interface/src/views/components/StatusBar.js b/pkg/interface/src/views/components/StatusBar.js index ca6cd9da6..94fa9f949 100644 --- a/pkg/interface/src/views/components/StatusBar.js +++ b/pkg/interface/src/views/components/StatusBar.js @@ -27,7 +27,7 @@ const StatusBar = (props) => { const invites = [].concat(...Object.values(props.invites).map(obj => Object.values(obj))); const metaKey = (window.navigator.platform.includes('Mac')) ? '⌘' : 'Ctrl+'; const { toggleOmnibox, hideAvatars } = - useLocalState(({ toggleOmnibox, hideAvatars }) => + useLocalState(({ toggleOmnibox, hideAvatars }) => ({ toggleOmnibox, hideAvatars }) ); @@ -91,6 +91,9 @@ const StatusBar = (props) => { > Submit an issue + props.history.push('/~landscape/messages')}> + + ; } else if (icon === 'home') { - graphic = ; + graphic = ; } else if (icon === 'notifications') { graphic = ; } else { diff --git a/pkg/interface/src/views/landscape/components/ChannelMenu.tsx b/pkg/interface/src/views/landscape/components/ChannelMenu.tsx deleted file mode 100644 index fc1120656..000000000 --- a/pkg/interface/src/views/landscape/components/ChannelMenu.tsx +++ /dev/null @@ -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, -}) => ( - - - {children} - -); - -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 ( - - - - {isMuted ? "Unmute" : "Mute"} this channel - - - {isOurs ? ( - <> - - - Delete Channel - - - - - - Channel Settings - - - - - ) : ( - - - Unsubscribe from Channel - - - )} - - } - alignX="right" - alignY="top" - dropWidth="250px" - > - - - ); -} diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx new file mode 100644 index 000000000..c92cd0477 --- /dev/null +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx @@ -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 ( + + + {description} + {vipDescription} + + + ); +} + +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[] = []; + 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[] = []; + 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 ( + +
+ + + + Permissions + + + Add or remove read/write privileges to this channel. Group admins + can always write to a channel + + + + + + + + {association.metadata.module !== "chat" && ( + + )} + Update Permissions + + +
+ ); +} diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Details.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Details.tsx new file mode 100644 index 000000000..20d0d70da --- /dev/null +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Details.tsx @@ -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 ( + +
+ + + + Channel Details + + + + + + + + Update Details + + + + +
+ ); +} diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Notifications.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Notifications.tsx new file mode 100644 index 000000000..aa245cefe --- /dev/null +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Notifications.tsx @@ -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 ( + + + Channel Notifications + + + + + + + + + + ); +} diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Sidebar.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Sidebar.tsx new file mode 100644 index 000000000..dfe7825da --- /dev/null +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Sidebar.tsx @@ -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 ( + + + Channel Settings + + + Preferences + + + {!isOwner && ( + + )} + {isAdmin && ( + <> + + Administration + + + + { isOwner ? ( + + ) : ( + + )} + + )} + + ); +} diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx new file mode 100644 index 000000000..8b96980b8 --- /dev/null +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx @@ -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(); + 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 ( + + + + + + {!isOwner && ( + + + Unsubscribe from Channel + + + 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. + + + + Unsubscribe from {props.association.metadata.title} + + + + )} + {canAdmin && ( + <> + + + { isOwner ? ( + + + Archive channel + + + 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. + + + + Archive {props.association.metadata.title} + + + + + ) : ( + + + Remove channel from group + + + 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. + + + + Remove {props.association.metadata.title} + + + + + )} + + )} + + + + ); +} diff --git a/pkg/interface/src/views/landscape/components/ChannelSettings.tsx b/pkg/interface/src/views/landscape/components/ChannelSettings.tsx deleted file mode 100644 index 39a571f4a..000000000 --- a/pkg/interface/src/views/landscape/components/ChannelSettings.tsx +++ /dev/null @@ -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 - ) => { - 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 ( - - -
- - - Channel Settings - - - - - - - Save - - - - -
- - {(metadata?.module === 'publish') && (<> - - - )} - - - ); -} diff --git a/pkg/interface/src/views/landscape/components/ChannelWritePerms.tsx b/pkg/interface/src/views/landscape/components/ChannelWritePerms.tsx new file mode 100644 index 000000000..be12fcf60 --- /dev/null +++ b/pkg/interface/src/views/landscape/components/ChannelWritePerms.tsx @@ -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(); + + return ( + + + + + + {values.writePerms === "subset" && ( + + )} + + ); +} diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx index 86970fbad..34fc6f9ab 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx @@ -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 ) => { 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} /> + + `${props.baseUrl}${to}`; return ( @@ -117,9 +119,9 @@ export function GroupSwitcher(props: { mr={2} color="gray" display="block" - icon="Mail" + icon="Home" /> - DMs + Drafts + My Channels } { - 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) { Invite to - {title || "DM"} + {title} + {/* { 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]; diff --git a/pkg/interface/src/views/landscape/components/NewChannel.tsx b/pkg/interface/src/views/landscape/components/NewChannel.tsx index a7fc45bf6..95fb4cf15 100644 --- a/pkg/interface/src/views/landscape/components/NewChannel.tsx +++ b/pkg/interface/src/views/landscape/components/NewChannel.tsx @@ -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 ( history.push(props.baseUrl)}> {'<- Back'} - - New Channel + + {workspace?.type === 'messages' ? 'Direct Message' : 'New Channel'} - { ({ errors, values }) =>
+ - - Channel Type - - - + + Channel Type + + + - {(workspace?.type === 'home') && + {(workspace?.type === 'home' || workspace?.type === 'messages') ? ( } - {(workspace?.type !== 'home' && values.moduleType === 'publish') && - <> - ) : ( + - {errors.writers && - <> - - - - {Array.from(new Set([...errors.writers]))} - - - - } - } + )} - Create Channel + Create - } +
); diff --git a/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx b/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx index 4d217ce49..41aa4a8d2 100644 --- a/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx +++ b/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx @@ -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 ( - - - - {text} - - {children} - - ); -}; +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 ( - - - - Group Settings - - Group - - {groupSize} - - { admin && ( - <> - - Administration - - - - - )} - - + Group Settings + + Group + + {groupSize} + + { admin && ( + <> + + Administration + + + + + )} + - - - {"<- Back"} - - - - {view === "settings" && ( - - )} - {view === "participants" && ( - - )} - + + + + {"<- Back"} + + + + {view === "settings" && ( + + )} + {view === "participants" && ( + + )} - + ); }} /> diff --git a/pkg/interface/src/views/landscape/components/Resource.tsx b/pkg/interface/src/views/landscape/components/Resource.tsx index 8c3cd7d7c..9cafcf5e7 100644 --- a/pkg/interface/src/views/landscape/components/Resource.tsx +++ b/pkg/interface/src/views/landscape/components/Resource.tsx @@ -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) { {props.notificationsCount ? `(${String(props.notificationsCount)}) ` : ''}{ title } + + {app === "chat" ? ( + + ) : app === "publish" ? ( + + ) : ( + + )} + { return ( - - - + ); }} /> - ( - - {app === "chat" ? ( - - ) : app === "publish" ? ( - - ) : ( - - )} - - )} - /> ); diff --git a/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx b/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx index 36c668e39..63bc35f86 100644 --- a/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx +++ b/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx @@ -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 rid = association.resource; + 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 ? ( - + {"<- Back"} + + + + {title} + + + + - {"<- Back"} - - ) : ( - - - Go back to channel - - - )} - - {atRoot && ( - <> - - - {title} - - - - - {association?.metadata?.description} - - - - {isWriter && ( - - + New Post - - )} - - - )} + {(workspace === "/messages") ? recipient : association?.metadata?.description} + + + + {canWrite && ( + + + New Post + + )} + + + {children} diff --git a/pkg/interface/src/views/landscape/components/Sidebar/Apps.tsx b/pkg/interface/src/views/landscape/components/Sidebar/Apps.tsx index eacecdd3c..d56236ad0 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/Apps.tsx +++ b/pkg/interface/src/views/landscape/components/Sidebar/Apps.tsx @@ -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( diff --git a/pkg/interface/src/views/landscape/components/Sidebar/Sidebar.tsx b/pkg/interface/src/views/landscape/components/Sidebar/Sidebar.tsx index 61380aaee..8998d4b92 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/Sidebar.tsx +++ b/pkg/interface/src/views/landscape/components/Sidebar/Sidebar.tsx @@ -85,6 +85,7 @@ export function Sidebar(props: SidebarProps) { workspace={props.workspace} /> ); diff --git a/pkg/interface/src/views/landscape/components/Sidebar/SidebarItem.tsx b/pkg/interface/src/views/landscape/components/Sidebar/SidebarItem.tsx index 0e53ecc27..40cf6ff81 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/SidebarItem.tsx +++ b/pkg/interface/src/views/landscape/components/Sidebar/SidebarItem.tsx @@ -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 = ; + } else { + img = + } + if (props.contacts?.[title] && props.contacts[title].nickname) { + title = props.contacts[title].nickname; + } + } else { + img = + } + return ( - + {DM ? img : ( + + ) + } { 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} /> ); })} diff --git a/pkg/interface/src/views/landscape/components/Sidebar/SidebarListHeader.tsx b/pkg/interface/src/views/landscape/components/Sidebar/SidebarListHeader.tsx index 0a38fad42..66364ac66 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/SidebarListHeader.tsx +++ b/pkg/interface/src/views/landscape/components/Sidebar/SidebarListHeader.tsx @@ -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 ( - {props.initialValues.hideUnjoined ? "Joined Channels" : "All Channels"} + {props.initialValues.hideUnjoined ? `Joined ${noun}` : `All ${noun}`} - + + + } + > + +
+ ) + : ( + + to={!!groupPath + ? `/~landscape${groupPath}/new` + : `/~landscape/${props.workspace?.type}/new`}> - - - + DM - - + ) + } ) => { + return ( + + + + {text} + + {children} + + ); +}; diff --git a/pkg/interface/src/views/landscape/components/Skeleton.tsx b/pkg/interface/src/views/landscape/components/Skeleton.tsx index 921be109e..3027e41ec 100644 --- a/pkg/interface/src/views/landscape/components/Skeleton.tsx +++ b/pkg/interface/src/views/landscape/components/Skeleton.tsx @@ -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; graphs: Graphs; linkListening: Set; 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 ( - {!props.hideSidebar && ( - - )} + {props.children} ); diff --git a/pkg/interface/src/views/landscape/index.tsx b/pkg/interface/src/views/landscape/index.tsx index ec85986ee..b04e3892d 100644 --- a/pkg/interface/src/views/landscape/index.tsx +++ b/pkg/interface/src/views/landscape/index.tsx @@ -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 { render() { const { props } = this; - const { api } = props; return ( <> @@ -104,6 +103,14 @@ export default class Landscape extends Component { ); }} /> + { + const ws: Workspace = { type: 'messages' }; + return ( + + ); + }} + /> { return (