mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-09-20 23:18:00 +03:00
Merge branch 'release/next-userspace' into lf/graph-publish-fe
This commit is contained in:
commit
4551e16976
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,8 +1,11 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Landscape design issue
|
||||
url: https://github.com/urbit/landscape/issues/new?assignees=&labels=design+issue&template=report-a-design-issue.md&title=
|
||||
about: Submit non-functionality, design-specific issues to the Landscape team here.
|
||||
- name: Landscape feature request
|
||||
url: https://github.com/urbit/landscape/issues/new?assignees=&labels=feature+request&template=feature_request.md&title=
|
||||
about: Landscape is comprised of Tlon's user applications and client for Urbit. Submit Landscape feature requests here.
|
||||
- name: urbit-dev mailing list
|
||||
url: https://groups.google.com/a/urbit.org/g/dev
|
||||
about: Developer questions and discussions also take place on the urbit-dev mailing list.
|
||||
about: Developer questions and discussions also take place on the urbit-dev mailing list.
|
||||
|
@ -38,6 +38,17 @@ To resume a fake ship, just pass the name of the pier:
|
||||
$ urbit my-fake-zod
|
||||
```
|
||||
|
||||
Fake ships by default use the same pre-compiled kernelspace ('pills') as livenet
|
||||
ships do: boot pills, which are not always current with `master`. If you wish to
|
||||
develop using code off the master branch, run the following from the repo
|
||||
directory:
|
||||
|
||||
```
|
||||
git lfs install
|
||||
git lfs pull
|
||||
urbit -F zod -B "bin/solid.pill" -A "pkg/arvo"
|
||||
```
|
||||
|
||||
## Git practice
|
||||
|
||||
### Contributing
|
||||
|
@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eab360913b845f8775002cfe1830defcd252b490ac90e8dfa093297b56531392
|
||||
size 19090656
|
||||
oid sha256:bfdea906d8d0493e0989faf4ef9f70580e1a797a113b2f89f45fffc2bbbab061
|
||||
size 6254938
|
||||
|
@ -33,7 +33,7 @@ To boot a fake ship with a custom pill, use the `-B` flag:
|
||||
urbit -F zod -A /path/to/arvo -B /path/to.pill -c fakezod
|
||||
```
|
||||
|
||||
To run all tests in `/tests`, run `+test` in dojo. `+test /some/path` would only run all tests in `/tests/some/path`.
|
||||
To run all tests in `/tests`, run `-test %/tests` in dojo. To run only the tests in `/tests/some/path`, use `-test %/tests/some/path`.
|
||||
|
||||
## Maintainers
|
||||
|
||||
|
@ -23,8 +23,12 @@
|
||||
state-6
|
||||
state-7
|
||||
state-8
|
||||
state-9
|
||||
state-10
|
||||
==
|
||||
::
|
||||
+$ state-10 [%10 state-base]
|
||||
+$ state-9 [%9 state-base]
|
||||
+$ state-8 [%8 state-base]
|
||||
+$ state-7 [%7 state-base]
|
||||
+$ state-6 [%6 state-base]
|
||||
@ -56,7 +60,7 @@
|
||||
$% [%chat-update update:store]
|
||||
==
|
||||
--
|
||||
=| state-8
|
||||
=| state-10
|
||||
=* state -
|
||||
::
|
||||
%- agent:dbug
|
||||
@ -85,8 +89,47 @@
|
||||
=/ old !<(versioned-state old-vase)
|
||||
=| cards=(list card)
|
||||
|-
|
||||
?: ?=(%8 -.old)
|
||||
?: ?=(%10 -.old)
|
||||
[cards this(state old)]
|
||||
?: ?=(%9 -.old)
|
||||
=. cards
|
||||
%+ weld cards
|
||||
^- (list card)
|
||||
%+ roll ~(tap in ~(key by wex.bol))
|
||||
|= [[=wire =ship =term] out=(list card)]
|
||||
?> ?=([@ *] wire)
|
||||
?. ?&(=(ship our.bol) =(term %chat-hook))
|
||||
out
|
||||
:_ out
|
||||
=- [%pass / %agent [our.bol %chat-hook] %poke %chat-hook-action !>(-)]
|
||||
[%remove t.wire]
|
||||
=/ chat-keys=(set path) (scry-for (set path) %chat-store [%keys ~])
|
||||
=. cards
|
||||
%+ weld cards
|
||||
^- (list card)
|
||||
%+ turn ~(tap in chat-keys)
|
||||
|= =app=path
|
||||
^- card
|
||||
?> ?=([@ @ ~] app-path)
|
||||
=/ =ship (slav %p i.app-path)
|
||||
?: =(ship our.bol)
|
||||
(add-owned app-path %.y)
|
||||
(add-synced ship app-path)
|
||||
::
|
||||
=/ list-paths=(list path)
|
||||
%+ murn ~(tap in ~(key by synced.old))
|
||||
|= =app=path
|
||||
^- (unit path)
|
||||
?~ (groups-of-chat:cc app-path)
|
||||
`app-path
|
||||
~
|
||||
|-
|
||||
?~ list-paths
|
||||
^$(-.old %10)
|
||||
=. synced.old (~(del by synced.old) i.list-paths)
|
||||
$(list-paths t.list-paths)
|
||||
?: ?=(%8 -.old)
|
||||
$(-.old %9)
|
||||
?: ?=(%7 -.old)
|
||||
=/ subscribers=(jug path ship)
|
||||
%+ roll ~(val by sup.bol)
|
||||
@ -104,9 +147,10 @@
|
||||
|= =path
|
||||
^- (unit card)
|
||||
?> ?=([@ @ ~] path)
|
||||
=/ group-path (group-from-chat:cc path)
|
||||
=/ members (members-from-path:group group-path)
|
||||
?: (is-managed-path:group group-path) ~
|
||||
=/ group-paths (groups-of-chat:cc path)
|
||||
?~ group-paths ~
|
||||
=/ members (members-from-path:group i.group-paths)
|
||||
?: (is-managed-path:group i.group-paths) ~
|
||||
=/ ships=(set ship) (~(get ju subscribers) path)
|
||||
%- some
|
||||
=+ [%invite path (~(dif in members) ships)]
|
||||
@ -187,6 +231,17 @@
|
||||
^- (list (list card))
|
||||
(turn ~(tap in keys) generate-cards)
|
||||
==
|
||||
::
|
||||
++ scry-for
|
||||
|* [=mold app=term =path]
|
||||
.^ mold
|
||||
%gx
|
||||
(scot %p our.bol)
|
||||
app
|
||||
(scot %da now.bol)
|
||||
(snoc `^path`path %noun)
|
||||
==
|
||||
::
|
||||
++ kick-old-subs
|
||||
|= old-path=path
|
||||
^- (list card)
|
||||
@ -550,6 +605,7 @@
|
||||
::
|
||||
%add-synced
|
||||
?> (team:title our.bol src.bol)
|
||||
?< =(ship.act our.bol)
|
||||
?: (~(has by synced) path.act) [~ state]
|
||||
=. synced (~(put by synced) path.act ship.act)
|
||||
?. ask-history.act
|
||||
@ -816,13 +872,7 @@
|
||||
?: =(i.t.wir '~')
|
||||
?> ?=(^ chat)
|
||||
(migrate-listen t.chat)
|
||||
:_ state
|
||||
%. ~[(chat-view-poke %delete chat)]
|
||||
%- slog
|
||||
:* leaf+"chat-hook failed subscribe on {(spud chat)}"
|
||||
leaf+"stack trace:"
|
||||
u.saw
|
||||
==
|
||||
[~ state]
|
||||
==
|
||||
::
|
||||
++ chat-poke
|
||||
|
@ -5,7 +5,7 @@
|
||||
/- glob
|
||||
/+ default-agent, verb, dbug
|
||||
|%
|
||||
++ hash 0v5.6e3d0.3hm4q.iib09.rb2jb.9h4k4
|
||||
++ hash 0v4.9nedu.7t8gi.5n5f7.nofgk.c2dl1
|
||||
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
|
||||
+$ all-states
|
||||
$% state-0
|
||||
|
@ -65,7 +65,7 @@
|
||||
=^ d drum.state (on-load:drum-core -.old drum.tup)
|
||||
=^ h helm.state (on-load:helm-core -.old helm.tup)
|
||||
=^ k kiln.state (on-load:kiln-core -.old kiln.tup)
|
||||
[:(weld d h k) this]
|
||||
[:(welp d h k) this]
|
||||
::
|
||||
++ on-poke
|
||||
|= [=mark =vase]
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
Before Width: | Height: | Size: 693 B |
Binary file not shown.
Before Width: | Height: | Size: 582 B |
@ -21,8 +21,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="portal-root"></div>
|
||||
<script src="/~landscape/js/channel.js"></script>
|
||||
<script src="/~landscape/js/session.js"></script>
|
||||
<script src="/~landscape/js/bundle/index.9f00eb9b1c58d2b1bd3c.js"></script>
|
||||
<script src="/~landscape/js/bundle/index.8934ecdc7676ad101413.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -11,6 +11,7 @@
|
||||
[%2 *]
|
||||
[%3 *]
|
||||
[%4 state-zero]
|
||||
[%5 state-zero]
|
||||
==
|
||||
::
|
||||
+$ state-zero
|
||||
@ -20,7 +21,7 @@
|
||||
==
|
||||
--
|
||||
::
|
||||
=| [%4 state-zero]
|
||||
=| [%5 state-zero]
|
||||
=* state -
|
||||
%- agent:dbug
|
||||
^- agent:gall
|
||||
@ -35,48 +36,51 @@
|
||||
%_ new-state
|
||||
tiles
|
||||
%- ~(gas by *tiles:store)
|
||||
%+ turn `(list term)`[%chat %publish %links %weather %clock %dojo ~]
|
||||
%+ turn `(list term)`[%weather %clock %dojo ~]
|
||||
|= =term
|
||||
:- term
|
||||
^- tile:store
|
||||
?+ term [[%custom ~] %.y]
|
||||
%chat [[%basic 'Chat' '/~landscape/img/Chat.png' '/~chat'] %.y]
|
||||
%links [[%basic 'Links' '/~landscape/img/Links.png' '/~link'] %.y]
|
||||
%dojo [[%basic 'Dojo' '/~landscape/img/Dojo.png' '/~dojo'] %.y]
|
||||
%publish
|
||||
[[%basic 'Publish' '/~landscape/img/Publish.png' '/~publish'] %.y]
|
||||
==
|
||||
tile-ordering [%chat %publish %links %weather %clock %dojo ~]
|
||||
tile-ordering [%weather %clock %dojo ~]
|
||||
==
|
||||
[~ this(state [%4 new-state])]
|
||||
[~ this(state [%5 new-state])]
|
||||
::
|
||||
++ on-save !>(state)
|
||||
++ on-load
|
||||
|= old=vase
|
||||
^- (quip card _this)
|
||||
=/ old-state !<(versioned-state old)
|
||||
|-
|
||||
?: ?=(%5 -.old-state)
|
||||
`this(state old-state)
|
||||
?: ?=(%4 -.old-state)
|
||||
:- [%pass / %arvo %e %disconnect [~ /]]~
|
||||
this(state old-state)
|
||||
=. tiles.old-state
|
||||
(~(del by tiles.old-state) %chat)
|
||||
=. tiles.old-state
|
||||
(~(del by tiles.old-state) %publish)
|
||||
=. tiles.old-state
|
||||
(~(del by tiles.old-state) %links)
|
||||
=. tile-ordering.old-state
|
||||
(skip tile-ordering.old-state |=(=term ?=(?(%links %chat %publish) term)))
|
||||
this(state [%5 +.old-state])
|
||||
=/ new-state *state-zero
|
||||
=. new-state
|
||||
%_ new-state
|
||||
tiles
|
||||
%- ~(gas by *tiles:store)
|
||||
%+ turn `(list term)`[%chat %publish %links %weather %clock %dojo ~]
|
||||
%+ turn `(list term)`[%weather %clock %dojo ~]
|
||||
|= =term
|
||||
:- term
|
||||
^- tile:store
|
||||
?+ term [[%custom ~] %.y]
|
||||
%chat [[%basic 'Chat' '/~landscape/img/Chat.png' '/~chat'] %.y]
|
||||
%links [[%basic 'Links' '/~landscape/img/Links.png' '/~link'] %.y]
|
||||
%dojo [[%basic 'Dojo' '/~landscape/img/Dojo.png' '/~dojo'] %.y]
|
||||
%publish
|
||||
[[%basic 'Publish' '/~landscape/img/Publish.png' '/~publish'] %.y]
|
||||
==
|
||||
tile-ordering [%chat %publish %links %weather %clock %dojo ~]
|
||||
tile-ordering [%weather %clock %dojo ~]
|
||||
==
|
||||
:_ this(state [%4 new-state])
|
||||
:_ this(state [%5 new-state])
|
||||
%+ welp
|
||||
:~ [%pass / %arvo %e %disconnect [~ /]]
|
||||
:* %pass /srv %agent [our.bowl %file-server]
|
||||
|
@ -10,7 +10,7 @@
|
||||
:: encode group-path and app-path using (scot %t (spat group-path))
|
||||
::
|
||||
:: +watch paths:
|
||||
:: /all assocations + updates
|
||||
:: /all associations + updates
|
||||
:: /updates just updates
|
||||
:: /app-name/%app-name specific app's associations + updates
|
||||
::
|
||||
@ -74,9 +74,9 @@
|
||||
^- agent:gall
|
||||
=<
|
||||
|_ =bowl:gall
|
||||
+* this .
|
||||
mc ~(. +> bowl)
|
||||
def ~(. (default-agent this %|) bowl)
|
||||
+* this .
|
||||
mc ~(. +> bowl)
|
||||
def ~(. (default-agent this %|) bowl)
|
||||
::
|
||||
++ on-init on-init:def
|
||||
++ on-save !>(state)
|
||||
@ -152,6 +152,27 @@
|
||||
==
|
||||
$(old new-state-1)
|
||||
::
|
||||
++ rebuild-resource-indices
|
||||
|= =^associations
|
||||
%- ~(gas ju *(jug md-resource group-path))
|
||||
%+ turn ~(tap in ~(key by associations))
|
||||
|= [g=group-path r=md-resource]
|
||||
^- [md-resource group-path]
|
||||
[r g]
|
||||
::
|
||||
++ rebuild-group-indices
|
||||
|= =^associations
|
||||
%- ~(gas ju *(jug group-path md-resource))
|
||||
~(tap in ~(key by associations))
|
||||
::
|
||||
++ rebuild-app-indices
|
||||
|= =^associations
|
||||
%- ~(gas ju *(jug app-name [group-path app-path]))
|
||||
%+ turn ~(tap in ~(key by associations))
|
||||
|= [g=group-path r=md-resource]
|
||||
^- [app-name [group-path app-path]]
|
||||
[app-name.r [g app-path.r]]
|
||||
|
||||
::
|
||||
++ migrate-app-to-graph-store
|
||||
|= [app=@tas =^associations]
|
||||
@ -163,41 +184,6 @@
|
||||
?. =(app-name.md-resource app) ~
|
||||
`[[group-path [%graph app-path.md-resource]] m]
|
||||
::
|
||||
++ rebuild-app-indices
|
||||
=| app-indices=(jug app-name [group-path app-path])
|
||||
|= =^associations
|
||||
^- (jug app-name [group-path app-path])
|
||||
?~ associations app-indices
|
||||
=. app-indices
|
||||
%+ ~(put ju app-indices) app-name.p.n.associations
|
||||
[-.p.n.associations app-path.p.n.associations]
|
||||
%- ~(uni by $(associations l.associations))
|
||||
$(associations r.associations)
|
||||
::
|
||||
++ rebuild-group-indices
|
||||
=| group-indices=(jug group-path md-resource)
|
||||
|= =^associations
|
||||
^- (jug group-path md-resource)
|
||||
?~ associations group-indices
|
||||
=. group-indices
|
||||
%+ ~(put ju group-indices)
|
||||
-.p.n.associations
|
||||
+.p.n.associations
|
||||
%- ~(uni by $(associations l.associations))
|
||||
$(associations r.associations)
|
||||
::
|
||||
++ rebuild-resource-indices
|
||||
=| resource-indices=(jug md-resource group-path)
|
||||
|= =^associations
|
||||
^- (jug md-resource group-path)
|
||||
?~ associations resource-indices
|
||||
=. resource-indices
|
||||
%+ ~(put ju resource-indices)
|
||||
+.p.n.associations
|
||||
-.p.n.associations
|
||||
%- ~(uni by $(associations l.associations))
|
||||
$(associations r.associations)
|
||||
::
|
||||
++ poke-md-hook
|
||||
|= act=metadata-hook-action
|
||||
^- card
|
||||
|
@ -164,7 +164,7 @@
|
||||
~(tap in ~(key by starting.any))
|
||||
|- ^- (quip card _this)
|
||||
?~ yarns
|
||||
`this
|
||||
[~[bind-eyre:sc] this]
|
||||
=^ cards-1 state
|
||||
(handle-stop-thread:sc (yarn-to-tid i.yarns) |)
|
||||
=^ cards-2 this
|
||||
|
6
pkg/arvo/gen/cors-registry.hoon
Normal file
6
pkg/arvo/gen/cors-registry.hoon
Normal file
@ -0,0 +1,6 @@
|
||||
:: eyre: give cors configuration
|
||||
::
|
||||
:- %say
|
||||
|= [[now=@da eny=@uvJ =beak] ~ ~]
|
||||
:- %noun
|
||||
.^(cors-registry:eyre %ex /(scot %p p.beak)//(scot %da now)/cors)
|
5
pkg/arvo/gen/hood/cors-approve.hoon
Normal file
5
pkg/arvo/gen/hood/cors-approve.hoon
Normal file
@ -0,0 +1,5 @@
|
||||
:: eyre: allow cors requests from origin
|
||||
::
|
||||
:- %say
|
||||
|= [^ [=origin:eyre ~] ~]
|
||||
[%helm-cors-approve origin]
|
5
pkg/arvo/gen/hood/cors-reject.hoon
Normal file
5
pkg/arvo/gen/hood/cors-reject.hoon
Normal file
@ -0,0 +1,5 @@
|
||||
:: eyre: disallow cors requests from origin
|
||||
::
|
||||
:- %say
|
||||
|= [^ [=origin:eyre ~] ~]
|
||||
[%helm-cors-reject origin]
|
@ -204,6 +204,16 @@
|
||||
|= [=binding:eyre =generator:eyre] =< abet
|
||||
(emit %pass /helm/serv %arvo %e %serve binding generator)
|
||||
::
|
||||
++ poke-cors-approve
|
||||
|= =origin:eyre
|
||||
=< abet
|
||||
(emit %pass /helm/cors/approve %arvo %e %approve-origin origin)
|
||||
::
|
||||
++ poke-cors-reject
|
||||
|= =origin:eyre
|
||||
=< abet
|
||||
(emit %pass /helm/cors/reject %arvo %e %reject-origin origin)
|
||||
::
|
||||
++ poke
|
||||
|= [=mark =vase]
|
||||
?+ mark ~|([%poke-helm-bad-mark mark] !!)
|
||||
@ -213,6 +223,8 @@
|
||||
%helm-atom =;(f (f !<(_+<.f vase)) poke-atom)
|
||||
%helm-automass =;(f (f !<(_+<.f vase)) poke-automass)
|
||||
%helm-cancel-automass =;(f (f !<(_+<.f vase)) poke-cancel-automass)
|
||||
%helm-cors-approve =;(f (f !<(_+<.f vase)) poke-cors-approve)
|
||||
%helm-cors-reject =;(f (f !<(_+<.f vase)) poke-cors-reject)
|
||||
%helm-hi =;(f (f !<(_+<.f vase)) poke-hi)
|
||||
%helm-knob =;(f (f !<(_+<.f vase)) poke-knob)
|
||||
%helm-mass =;(f (f !<(_+<.f vase)) poke-mass)
|
||||
|
@ -69,7 +69,7 @@
|
||||
++ axle
|
||||
$: :: date: date at which http-server's state was updated to this data structure
|
||||
::
|
||||
date=%~2020.5.29
|
||||
date=%~2020.9.30
|
||||
:: server-state: state of inbound requests
|
||||
::
|
||||
=server-state
|
||||
@ -87,6 +87,9 @@
|
||||
:: the :binding into a (map (unit @t) (trie knot =action)).
|
||||
::
|
||||
bindings=(list [=binding =duct =action])
|
||||
:: cors-registry: state used and managed by the +cors core
|
||||
::
|
||||
=cors-registry
|
||||
:: connections: open http connections not fully complete
|
||||
::
|
||||
connections=(map duct outstanding-connection)
|
||||
@ -571,6 +574,29 @@
|
||||
[action [authenticated secure address request] ~ 0]
|
||||
=. connections.state
|
||||
(~(put by connections.state) duct connection)
|
||||
:: figure out whether this is a cors request,
|
||||
:: whether the origin is approved or not,
|
||||
:: and maybe add it to the "pending approval" set
|
||||
::
|
||||
=/ origin=(unit origin)
|
||||
(get-header:http 'origin' header-list.request)
|
||||
=^ cors-approved requests.cors-registry.state
|
||||
=, cors-registry.state
|
||||
?~ origin [| requests]
|
||||
?: (~(has in approved) u.origin) [& requests]
|
||||
?: (~(has in rejected) u.origin) [| requests]
|
||||
[| (~(put in requests) u.origin)]
|
||||
:: if this is a cors preflight request from an approved origin
|
||||
:: handle it synchronously
|
||||
::
|
||||
?: &(?=(^ origin) cors-approved ?=(%'OPTIONS' method.request))
|
||||
%- handle-response
|
||||
=; =header-list:http
|
||||
[%start [204 header-list] ~ &]
|
||||
::NOTE +handle-response will add the rest of the headers
|
||||
:~ 'Access-Control-Allow-Methods'^'*'
|
||||
'Access-Control-Allow-Headers'^'*'
|
||||
==
|
||||
::
|
||||
?- -.action
|
||||
%gen
|
||||
@ -1632,10 +1658,25 @@
|
||||
(session-cookie-string u.session-id &)
|
||||
headers.response-header.http-event
|
||||
::
|
||||
=/ connection=outstanding-connection
|
||||
(~(got by connections.state) duct)
|
||||
:: if the request was a simple cors request from an approved origin
|
||||
:: append the necessary cors headers to the response
|
||||
::
|
||||
=/ origin=(unit origin)
|
||||
%+ get-header:http 'origin'
|
||||
header-list.request.inbound-request.connection
|
||||
=? headers.response-header
|
||||
?& ?=(^ origin)
|
||||
(~(has in approved.cors-registry.state) u.origin)
|
||||
==
|
||||
%^ set-header:http 'Access-Control-Allow-Origin' u.origin
|
||||
%^ set-header:http 'Access-Control-Allow-Credentials' 'true'
|
||||
headers.response-header
|
||||
::
|
||||
=. response-header.http-event response-header
|
||||
=. connections.state
|
||||
%+ ~(jab by connections.state) duct
|
||||
|= connection=outstanding-connection
|
||||
%+ ~(put by connections.state) duct
|
||||
%_ connection
|
||||
response-header `response-header
|
||||
bytes-sent ?~(data.http-event 0 p.u.data.http-event)
|
||||
@ -2030,6 +2071,22 @@
|
||||
%disconnect
|
||||
=. server-state.ax (remove-binding:server binding.task)
|
||||
[~ http-server-gate]
|
||||
::
|
||||
%approve-origin
|
||||
=. cors-registry.server-state.ax
|
||||
=, cors-registry.server-state.ax
|
||||
:+ (~(del in requests) origin.task)
|
||||
(~(put in approved) origin.task)
|
||||
(~(del in rejected) origin.task)
|
||||
[~ http-server-gate]
|
||||
::
|
||||
%reject-origin
|
||||
=. cors-registry.server-state.ax
|
||||
=, cors-registry.server-state.ax
|
||||
:+ (~(del in requests) origin.task)
|
||||
(~(del in approved) origin.task)
|
||||
(~(put in rejected) origin.task)
|
||||
[~ http-server-gate]
|
||||
==
|
||||
::
|
||||
++ take
|
||||
@ -2210,6 +2267,19 @@
|
||||
::
|
||||
++ load
|
||||
=> |%
|
||||
+$ axle-2020-5-29
|
||||
[date=%~2020.5.29 server-state=server-state-2020-5-29]
|
||||
::
|
||||
+$ server-state-2020-5-29
|
||||
$: bindings=(list [=binding =duct =action])
|
||||
connections=(map duct outstanding-connection)
|
||||
=authentication-state
|
||||
=channel-state
|
||||
domains=(set turf)
|
||||
=http-config
|
||||
ports=[insecure=@ud secure=(unit @ud)]
|
||||
outgoing-duct=duct
|
||||
==
|
||||
+$ axle-2019-10-6
|
||||
[date=%~2019.10.6 server-state=server-state-2019-10-6]
|
||||
::
|
||||
@ -2224,12 +2294,18 @@
|
||||
outgoing-duct=duct
|
||||
==
|
||||
--
|
||||
|= old=$%(axle axle-2019-10-6)
|
||||
|= old=$%(axle axle-2019-10-6 axle-2020-5-29)
|
||||
^+ ..^$
|
||||
::
|
||||
~! %loading
|
||||
?- -.old
|
||||
%~2020.5.29 ..^$(ax old)
|
||||
%~2020.9.30 ..^$(ax old)
|
||||
::
|
||||
%~2020.5.29
|
||||
%_ $
|
||||
date.old %~2020.9.30
|
||||
server-state.old [-.server-state.old *cors-registry +.server-state.old]
|
||||
==
|
||||
::
|
||||
%~2019.10.6
|
||||
=^ success bindings.server-state.old
|
||||
@ -2258,8 +2334,6 @@
|
||||
?. ?=(%& -.why)
|
||||
~
|
||||
=* who p.why
|
||||
?. ?=(%$ ren)
|
||||
[~ ~]
|
||||
?: =(tyl /whey)
|
||||
=/ maz=(list mass)
|
||||
:~ bindings+&+bindings.server-state.ax
|
||||
@ -2276,6 +2350,25 @@
|
||||
[~ ~]
|
||||
~& [%r %scry-foreign-host who]
|
||||
~
|
||||
?: &(?=(%x ren) ?=(~ syd))
|
||||
=, server-state.ax
|
||||
?+ tyl [~ ~]
|
||||
[%cors ~] ``noun+!>(cors-registry)
|
||||
[%cors %requests ~] ``noun+!>(requests.cors-registry)
|
||||
[%cors %approved ~] ``noun+!>(approved.cors-registry)
|
||||
[%cors %rejected ~] ``noun+!>(rejected.cors-registry)
|
||||
::
|
||||
[%cors ?(%approved %rejected) @ ~]
|
||||
=* kind i.t.tyl
|
||||
=* orig i.t.t.tyl
|
||||
?~ origin=(slaw %t orig) [~ ~]
|
||||
?- kind
|
||||
%approved ``noun+!>((~(has in approved.cors-registry) u.origin))
|
||||
%rejected ``noun+!>((~(has in rejected.cors-registry) u.origin))
|
||||
==
|
||||
==
|
||||
?. ?=(%$ ren)
|
||||
[~ ~]
|
||||
?+ syd [~ ~]
|
||||
%bindings ``noun+!>(bindings.server-state.ax)
|
||||
%connections ``noun+!>(connections.server-state.ax)
|
||||
|
@ -1271,9 +1271,25 @@
|
||||
:: the first place.
|
||||
::
|
||||
[%disconnect =binding]
|
||||
:: start responding positively to cors requests from origin
|
||||
::
|
||||
[%approve-origin =origin]
|
||||
:: start responding negatively to cors requests from origin
|
||||
::
|
||||
[%reject-origin =origin]
|
||||
==
|
||||
::
|
||||
--
|
||||
:: +origin: request origin as specified in an Origin header
|
||||
::
|
||||
+$ origin @torigin
|
||||
:: +cors-registry: origins categorized by approval status
|
||||
::
|
||||
+$ cors-registry
|
||||
$: requests=(set origin)
|
||||
approved=(set origin)
|
||||
rejected=(set origin)
|
||||
==
|
||||
:: +outstanding-connection: open http connections not fully complete:
|
||||
::
|
||||
:: This refers to outstanding connections where the connection to
|
||||
|
@ -49,7 +49,7 @@
|
||||
|= arg=vase
|
||||
=/ m (strand ,vase)
|
||||
^- form:m
|
||||
=+ !<([=action:graph-view ~] arg)
|
||||
=+ !<(=action:graph-view arg)
|
||||
?> ?=(%leave -.action)
|
||||
;< =bowl:spider bind:m get-bowl:strandio
|
||||
?: =(our.bowl entity.rid.action)
|
||||
|
@ -10,11 +10,12 @@ applications. Landscape applications will usually make good use of Gall, but
|
||||
it's not strictly required if a Landscape application is not interacting with
|
||||
ships directly.
|
||||
|
||||
## Contributing to Landscape applications
|
||||
## Starting the dev environment
|
||||
|
||||
To begin developing on Landscape, find the `urbitrc-sample` file found
|
||||
at `urbit/pkg/interface/config/urbitrc-sample`. Copy it as `urbitrc`.
|
||||
Open it using your preferred code editor and you should see the following:
|
||||
From this directory, go to the config folder and copy `urbitrc-sample` to
|
||||
`urbitrc`.
|
||||
|
||||
You should see the following:
|
||||
|
||||
```
|
||||
module.exports = {
|
||||
@ -26,130 +27,17 @@ module.exports = {
|
||||
};
|
||||
```
|
||||
|
||||
This file is the configuration file for your front-end development environment.
|
||||
Let's walk through it.
|
||||
|
||||
The first line, listing piers, specifies which piers to copy the JS files into.
|
||||
By default, the development environment won't copy files into any pier, even if
|
||||
you've set the pier in `urbitrc`.
|
||||
|
||||
If you want to copy the JS files into your ship, as it would run in a regular
|
||||
user environment, uncomment these lines in
|
||||
`pkg/interface/config/webpack.dev.js`:
|
||||
|
||||
```javascript
|
||||
// uncomment to copy into all piers
|
||||
//
|
||||
// return Promise.all(this.piers.map(pier => {
|
||||
// const dst = path.resolve(pier, 'app/landscape/js/index.js');
|
||||
// copyFile(src, dst).then(() => {
|
||||
// if(!this.herb) {
|
||||
// return;
|
||||
// }
|
||||
// pier = pier.split('/');
|
||||
// const desk = pier.pop();
|
||||
// return exec(`herb -p hood -d '+hood/commit %${desk}' ${pier.join('/')}`);
|
||||
// });
|
||||
// }));
|
||||
```
|
||||
|
||||
And then set your pier in `urbitrc` (ensure it ends in `/home`). The `herb`
|
||||
option in your `urbitrc` will automatically commit the changes to your ship if
|
||||
you have herb installed (see `pkg/herb`).
|
||||
|
||||
For most developers, if you are making changes to Landscape without any back-end
|
||||
changes on the Urbit ship itself, and you have an Urbit ship running already,
|
||||
you don't have to boot a development ship. You can simply set up the dev server
|
||||
for the development environment and point it at your running ship.
|
||||
|
||||
To do this, set the `URL` property in your urbitrc and replace it with the URL
|
||||
of the urbit that you are testing on. For example, a development ship by default
|
||||
lives at `localhost:80` so our `urbitrc` would have:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
URL: 'http://localhost:80'
|
||||
}
|
||||
```
|
||||
|
||||
Then get everything installed:
|
||||
Change the URL to your livenet ship (if making front-end changes) or keep it the
|
||||
same (if [developing on a local development ship][local]). Then, from
|
||||
'pkg/interface':
|
||||
|
||||
```
|
||||
## go to urbit's interface directory and install the required tooling
|
||||
cd urbit/pkg/interface
|
||||
npm install
|
||||
|
||||
## Start your development server
|
||||
npm run start
|
||||
```
|
||||
|
||||
You can then access a hot reloaded version of the interface at
|
||||
`http://localhost:9000`.
|
||||
|
||||
If you set the URL to your running ship, like
|
||||
`http://sampel-palnet.arvo.network`, then you can use your actual ship while
|
||||
running local-only development changes.
|
||||
|
||||
As previously stated, if your changes require back-end development (front-end
|
||||
and Gall changes, for example), or you just want an empty development
|
||||
environment, you'll want to create a development ship.
|
||||
|
||||
### Creating a development ship
|
||||
|
||||
[nix](https://github.com/NixOS/nix) and `git-lfs` should be installed at this
|
||||
point, and have been used to `make build` the project.
|
||||
|
||||
First follow the
|
||||
[instructions](https://urbit.org/using/develop/#creating-a-development-ship) for
|
||||
fake `~zod` initialization.
|
||||
|
||||
Once your fake ship is running and you see
|
||||
```
|
||||
~zod:dojo>
|
||||
```
|
||||
in your console, be sure to 'mount' your ship's working state (what we call
|
||||
'desks') to your local machine via the `|mount %` command. This will ensure that
|
||||
code you modify locally can be committed to your ship and initialized.
|
||||
|
||||
To set up urbit's Javascript environment, you'll need node (ideally installed
|
||||
via [nvm](https://github.com/nvm-sh/nvm)) and webpack, which will be installed
|
||||
via node.
|
||||
|
||||
If you want to copy the code into your ship, perform the following steps:
|
||||
|
||||
```
|
||||
## go to urbit's interface directory and install the required tooling
|
||||
cd urbit/pkg/interface
|
||||
npm install
|
||||
|
||||
## Build the JS code
|
||||
npm run build:dev
|
||||
```
|
||||
|
||||
If you want to run the JavaScript code in a dev server, you can simply set the
|
||||
URL in your `urbitrc` to `localhost:80` and `npm run start` instead.
|
||||
|
||||
If you set your pier in `urbitrc`, and uncommented the code in the webpack
|
||||
config, then once the build process is running, commit on your ship to copy the
|
||||
changed JS code in:
|
||||
|
||||
```
|
||||
|commit %home
|
||||
```
|
||||
|
||||
Your urbit should take a moment to process the changes, and will emit a `>=`.
|
||||
Refreshing your browser will display the newly-rendered interface.
|
||||
|
||||
Once you are done editing code, and wish to commit changes to git, stop your
|
||||
process. Do not commit compiled code, but submit the source code
|
||||
for review.
|
||||
|
||||
Please also ensure your pull request fits our standards for [Git
|
||||
hygiene][contributing].
|
||||
|
||||
[contributing]: /CONTRIBUTING.md#git-practice
|
||||
[arvo]: /pkg/arvo
|
||||
[interface]:/pkg/interface
|
||||
The dev server will start at `http://localhost:9000`. Sign in as you would
|
||||
normally. Landscape will refresh automatically as you make changes.
|
||||
|
||||
## Linting
|
||||
|
||||
@ -170,12 +58,6 @@ $ npm run lint-file ./src/apps/chat/**/*.js # lints all .js files in `interface/
|
||||
$ npm run lint-file ./src/chat/app.js # lints a single chosen file
|
||||
```
|
||||
|
||||
### Gall
|
||||
|
||||
Presently, Gall documentation is still in [progress][gall], but a good
|
||||
reference. For examples of Landscape apps that use Gall, see the code for
|
||||
[Chat][chat] and [Publish][publish].
|
||||
|
||||
## Creating your own applications
|
||||
|
||||
If you'd like to create your own application for Landscape, the easiest way to
|
||||
@ -188,5 +70,4 @@ running.
|
||||
[cla]: https://github.com/urbit/create-landscape-app
|
||||
[template]: https://github.com/urbit/create-landscape-app/generate
|
||||
[gall]:https://urbit.org/docs/learn/arvo/gall/
|
||||
[chat]: /pkg/arvo/app/chat-view.hoon
|
||||
[publish]: /pkg/arvo/app/publish.hoon
|
||||
[local]: /CONTRIBUTING.md#fake-ships
|
BIN
pkg/interface/package-lock.json
generated
BIN
pkg/interface/package-lock.json
generated
Binary file not shown.
@ -9,7 +9,8 @@
|
||||
"@reach/menu-button": "^0.10.5",
|
||||
"@reach/tabs": "^0.10.5",
|
||||
"@tlon/indigo-light": "^1.0.3",
|
||||
"@tlon/indigo-react": "1.2.6",
|
||||
"@tlon/indigo-react": "urbit/indigo-react#lf/1.2.9",
|
||||
"@tlon/sigil-js": "^1.4.2",
|
||||
"aws-sdk": "^2.726.0",
|
||||
"classnames": "^2.2.6",
|
||||
"codemirror": "^5.55.0",
|
||||
@ -20,6 +21,7 @@
|
||||
"moment": "^2.20.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mousetrap-global-bind": "^1.1.0",
|
||||
"normalize-wheel": "1.0.1",
|
||||
"oembed-parser": "^1.4.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.5.2",
|
||||
@ -33,15 +35,14 @@
|
||||
"react-oembed-container": "^1.0.0",
|
||||
"react-router-dom": "^5.0.0",
|
||||
"react-virtuoso": "^0.20.0",
|
||||
"react-visibility-sensor": "^5.1.1",
|
||||
"remark-disable-tokenizers": "^1.0.24",
|
||||
"style-loader": "^1.2.1",
|
||||
"styled-components": "^5.1.0",
|
||||
"styled-system": "^5.1.5",
|
||||
"suncalc": "^1.8.0",
|
||||
"urbit-ob": "^5.0.0",
|
||||
"urbit-sigil-js": "^1.3.2",
|
||||
"yup": "^0.29.3",
|
||||
"normalize-wheel": "1.0.1"
|
||||
"yup": "^0.29.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.9.0",
|
||||
@ -54,9 +55,11 @@
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@types/lodash": "^4.14.155",
|
||||
"@types/react": "^16.9.38",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-router-dom": "^5.1.5",
|
||||
"@types/styled-components": "^5.1.2",
|
||||
"@types/styled-system": "^5.1.10",
|
||||
"@types/yup": "^0.29.7",
|
||||
"@typescript-eslint/eslint-plugin": "^3.8.0",
|
||||
"@typescript-eslint/parser": "^3.8.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
|
@ -26,7 +26,7 @@ export default class GroupsApi extends BaseApi<StoreState> {
|
||||
return this.proxyAction({ addMembers: { resource, ships } });
|
||||
}
|
||||
|
||||
changePolicy(resource: Resource, diff: GroupPolicyDiff) {
|
||||
changePolicy(resource: Resource, diff: Enc<GroupPolicyDiff>) {
|
||||
return this.proxyAction({ changePolicy: { resource, diff } });
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
|
||||
import BaseApi from './base';
|
||||
import { StoreState } from '../store/type';
|
||||
import { Path, Patp } from '~/types/noun';
|
||||
import { Path, Patp, Association, Metadata } from '~/types';
|
||||
|
||||
export default class MetadataApi extends BaseApi<StoreState> {
|
||||
|
||||
|
||||
metadataAdd(appName: string, appPath: Path, groupPath: Path, title: string, description: string, dateCreated: string, color: string, moduleName: string) {
|
||||
const creator = `~${this.ship}`;
|
||||
return this.metadataAction({
|
||||
@ -26,6 +27,20 @@ export default class MetadataApi extends BaseApi<StoreState> {
|
||||
});
|
||||
}
|
||||
|
||||
update(association: Association, newMetadata: Partial<Metadata>) {
|
||||
const metadata = {...association.metadata, ...newMetadata };
|
||||
return this.metadataAction({
|
||||
add: {
|
||||
'group-path': association['group-path'],
|
||||
resource: {
|
||||
'app-path': association['app-path'],
|
||||
'app-name': association['app-name'],
|
||||
},
|
||||
metadata
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private metadataAction(data) {
|
||||
return this.action('metadata-hook', 'metadata-action', data);
|
||||
}
|
||||
|
@ -187,8 +187,18 @@ export default class PublishApi extends BaseApi {
|
||||
});
|
||||
}
|
||||
|
||||
readNote(who: PatpNoSig, book: string, note: string) {
|
||||
return this.publishAction({
|
||||
read: {
|
||||
who,
|
||||
book,
|
||||
note
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateComment(who: PatpNoSig, book: string, note: string, comment: Path, body: string) {
|
||||
return this.publishAction({
|
||||
return this.publishAction({
|
||||
'edit-comment': {
|
||||
who,
|
||||
book,
|
||||
|
@ -1,4 +1,3 @@
|
||||
import defaultApps from './default-apps';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
|
||||
const indexes = new Map([
|
||||
@ -19,29 +18,13 @@ const result = function(title, link, app, host) {
|
||||
};
|
||||
};
|
||||
|
||||
const commandIndex = function () {
|
||||
const commandIndex = function (currentGroup) {
|
||||
// commands are special cased for default suite
|
||||
const commands = [];
|
||||
defaultApps
|
||||
.filter((e) => {
|
||||
return e !== 'dojo';
|
||||
})
|
||||
.map((e) => {
|
||||
let title = e;
|
||||
if (e === 'link') {
|
||||
title = 'Links';
|
||||
}
|
||||
|
||||
title = title.charAt(0).toUpperCase() + title.slice(1);
|
||||
|
||||
let obj = result(`${title}: Create`, `/~${e}/new`, e, null);
|
||||
commands.push(obj);
|
||||
|
||||
if (title === 'Groups') {
|
||||
obj = result(`${title}: Join Group`, `/~${e}/join`, title, null);
|
||||
commands.push(obj);
|
||||
}
|
||||
});
|
||||
const workspace = currentGroup || '/home';
|
||||
commands.push(result(`Groups: Create`, `/~landscape/new`, 'Groups', null));
|
||||
commands.push(result(`Groups: Join`, `/~landscape/join`, 'Groups', null));
|
||||
commands.push(result(`Channel: Create`, `/~landscape${workspace}/new`, 'Groups', null));
|
||||
|
||||
return commands;
|
||||
};
|
||||
@ -54,7 +37,7 @@ const appIndex = function (apps) {
|
||||
.filter((e) => {
|
||||
return apps[e]?.type?.basic;
|
||||
})
|
||||
.sort((a,b) => {
|
||||
.sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((e) => {
|
||||
@ -66,26 +49,23 @@ const appIndex = function (apps) {
|
||||
);
|
||||
applications.push(obj);
|
||||
});
|
||||
// add groups separately
|
||||
applications.push(
|
||||
result('Groups', '/~groups', 'Groups', null)
|
||||
);
|
||||
return applications;
|
||||
};
|
||||
|
||||
const otherIndex = function() {
|
||||
const other = [];
|
||||
other.push(result('Home', '/~landscape/home', 'home', null));
|
||||
other.push(result('Profile and Settings', '/~profile/identity', 'profile', null));
|
||||
other.push(result('Log Out', '/~/logout', 'logout', null));
|
||||
|
||||
return other;
|
||||
};
|
||||
|
||||
export default function index(associations, apps) {
|
||||
export default function index(associations, apps, currentGroup, groups) {
|
||||
// all metadata from all apps is indexed
|
||||
// into subscriptions and groups
|
||||
// into subscriptions and landscape
|
||||
const subscriptions = [];
|
||||
const groups = [];
|
||||
const landscape = [];
|
||||
Object.keys(associations).filter((e) => {
|
||||
// skip apps with no metadata
|
||||
return Object.keys(associations[e]).length > 0;
|
||||
@ -112,16 +92,18 @@ export default function index(associations, apps) {
|
||||
if (app === 'groups') {
|
||||
const obj = result(
|
||||
title,
|
||||
`/~${app}${each['app-path']}`,
|
||||
`/~landscape${each['app-path']}`,
|
||||
app.charAt(0).toUpperCase() + app.slice(1),
|
||||
cite(shipStart.slice(0, shipStart.indexOf('/')))
|
||||
);
|
||||
groups.push(obj);
|
||||
landscape.push(obj);
|
||||
} else {
|
||||
const app = each.metadata.module || each['app-name'];
|
||||
const group = (groups[each['group-path']]?.hidden)
|
||||
? '/home' : each['group-path'];
|
||||
const obj = result(
|
||||
title,
|
||||
`/~${each['app-name']}/join${each['app-path']}${
|
||||
(each.metadata.module && '/' + each.metadata.module) || ''}`,
|
||||
`/~landscape${group}/join/${app}${each['app-path']}`,
|
||||
app.charAt(0).toUpperCase() + app.slice(1),
|
||||
(associations?.contacts?.[each['group-path']]?.metadata?.title || null)
|
||||
);
|
||||
@ -130,9 +112,9 @@ export default function index(associations, apps) {
|
||||
});
|
||||
});
|
||||
|
||||
indexes.set('commands', commandIndex());
|
||||
indexes.set('commands', commandIndex(currentGroup));
|
||||
indexes.set('subscriptions', subscriptions);
|
||||
indexes.set('groups', groups);
|
||||
indexes.set('groups', landscape);
|
||||
indexes.set('apps', appIndex(apps));
|
||||
indexes.set('other', otherIndex());
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { memo } from 'react';
|
||||
import { sigil, reactRenderer } from 'urbit-sigil-js';
|
||||
import { sigil, reactRenderer } from '@tlon/sigil-js';
|
||||
|
||||
export const foregroundFromBackground = (background) => {
|
||||
const rgb = {
|
||||
@ -13,7 +13,7 @@ export const foregroundFromBackground = (background) => {
|
||||
return ((whiteBrightness - brightness) < 50) ? 'black' : 'white';
|
||||
}
|
||||
|
||||
export const Sigil = memo(({ classes = '', color, ship, size, svgClass = '' }) => {
|
||||
export const Sigil = memo(({ classes = '', color, ship, size, svgClass = '', icon = false }) => {
|
||||
return ship.length > 14
|
||||
? (<div
|
||||
className={'bg-black dib ' + classes}
|
||||
@ -27,6 +27,7 @@ export const Sigil = memo(({ classes = '', color, ship, size, svgClass = '' }) =
|
||||
patp: ship,
|
||||
renderer: reactRenderer,
|
||||
size: size,
|
||||
icon,
|
||||
colors: [
|
||||
color,
|
||||
foregroundFromBackground(color)
|
||||
@ -36,4 +37,4 @@ export const Sigil = memo(({ classes = '', color, ship, size, svgClass = '' }) =
|
||||
</div>)
|
||||
})
|
||||
|
||||
export default Sigil;
|
||||
export default Sigil;
|
||||
|
64
pkg/interface/src/logic/lib/useDrag.ts
Normal file
64
pkg/interface/src/logic/lib/useDrag.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
|
||||
function validateDragEvent(e: DragEvent): FileList | null {
|
||||
const files = e.dataTransfer?.files;
|
||||
console.log(files);
|
||||
if(!files?.length) {
|
||||
return null;
|
||||
}
|
||||
return files || null;
|
||||
}
|
||||
|
||||
export function useFileDrag(dragged: (f: FileList) => void) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(e: DragEvent) => {
|
||||
if (!validateDragEvent(e)) {
|
||||
return;
|
||||
}
|
||||
setDragging(true);
|
||||
},
|
||||
[setDragging]
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: DragEvent) => {
|
||||
setDragging(false);
|
||||
e.preventDefault();
|
||||
const files = validateDragEvent(e);
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
dragged(files);
|
||||
},
|
||||
[setDragging, dragged]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
},
|
||||
[setDragging]
|
||||
);
|
||||
|
||||
const onDragLeave = useCallback(
|
||||
(e: DragEvent) => {
|
||||
const over = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!over || !(e.currentTarget as any)?.contains(over)) {
|
||||
setDragging(false);
|
||||
}
|
||||
},
|
||||
[setDragging]
|
||||
);
|
||||
|
||||
const bind = {
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnter,
|
||||
};
|
||||
|
||||
return { bind, dragging };
|
||||
}
|
@ -1,22 +1,36 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
export function useLocalStorageState<T>(key: string, initial: T) {
|
||||
const [state, _setState] = useState(() => {
|
||||
const s = localStorage.getItem(key);
|
||||
if(s) {
|
||||
function retrieve<T>(key: string, initial: T): T {
|
||||
const s = localStorage.getItem(key);
|
||||
if (s) {
|
||||
try {
|
||||
return JSON.parse(s) as T;
|
||||
} catch (e) {
|
||||
return initial;
|
||||
}
|
||||
return initial;
|
||||
}
|
||||
return initial;
|
||||
}
|
||||
|
||||
});
|
||||
interface SetStateFunc<T> {
|
||||
(t: T): T;
|
||||
}
|
||||
type SetState<T> = T | SetStateFunc<T>;
|
||||
export function useLocalStorageState<T>(key: string, initial: T) {
|
||||
const [state, _setState] = useState(() => retrieve(key, initial));
|
||||
|
||||
const setState = useCallback((s: T) => {
|
||||
_setState(s);
|
||||
localStorage.setItem(key, JSON.stringify(s));
|
||||
useEffect(() => {
|
||||
_setState(retrieve(key, initial));
|
||||
}, [key]);
|
||||
|
||||
}, [_setState]);
|
||||
const setState = useCallback(
|
||||
(s: SetState<T>) => {
|
||||
const updated = typeof s === "function" ? s(state) : s;
|
||||
_setState(updated);
|
||||
localStorage.setItem(key, JSON.stringify(updated));
|
||||
},
|
||||
[_setState, key, state]
|
||||
);
|
||||
|
||||
return [state, setState] as const;
|
||||
}
|
||||
|
||||
|
||||
|
23
pkg/interface/src/logic/lib/useOutsideClick.ts
Normal file
23
pkg/interface/src/logic/lib/useOutsideClick.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { useEffect, RefObject } from "react";
|
||||
|
||||
export function useOutsideClick(
|
||||
ref: RefObject<HTMLElement>,
|
||||
onClick: () => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (
|
||||
ref.current &&
|
||||
!ref.current.contains(event.target as any) &&
|
||||
!document.querySelector("#portal-root")!.contains(event.target as any)
|
||||
) {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
}, [ref.current, onClick]);
|
||||
}
|
33
pkg/interface/src/logic/lib/useStatelessAsyncClickable.ts
Normal file
33
pkg/interface/src/logic/lib/useStatelessAsyncClickable.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { MouseEvent, useCallback, useState, useEffect } from "react";
|
||||
export type AsyncClickableState = "waiting" | "error" | "loading" | "success";
|
||||
|
||||
export function useStatelessAsyncClickable(
|
||||
onClick: (e: MouseEvent) => Promise<void>,
|
||||
name: string
|
||||
) {
|
||||
const [state, setState] = useState<ButtonState>("waiting");
|
||||
const handleClick = useCallback(
|
||||
async (e: MouseEvent) => {
|
||||
try {
|
||||
setState("loading");
|
||||
await onClick(e);
|
||||
setState("success");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setState("error");
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setState("waiting");
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
[onClick, setState]
|
||||
);
|
||||
|
||||
// When name changes, reset button
|
||||
useEffect(() => {
|
||||
setState("waiting");
|
||||
}, [name]);
|
||||
|
||||
return { buttonState: state, onClick: handleClick };
|
||||
}
|
@ -2,6 +2,21 @@ import _ from 'lodash';
|
||||
|
||||
export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i;
|
||||
|
||||
export function clamp(x,min,max) {
|
||||
return Math.max(min, Math.min(max, x));
|
||||
}
|
||||
|
||||
// color is a #000000 color
|
||||
export function adjustHex(color, amount) {
|
||||
const res = _.chain(color.slice(1))
|
||||
.split('').chunk(2) // get individual color channels
|
||||
.map(c => parseInt(c.join(''), 16)) // as hex
|
||||
.map(c => clamp(c + amount, 0, 255).toString(16)) // adjust
|
||||
.join('').value();
|
||||
return `#${res}`;
|
||||
}
|
||||
|
||||
|
||||
export function resourceAsPath(resource) {
|
||||
const { name, ship } = resource;
|
||||
return `/ship/~${ship}/${name}`;
|
||||
@ -19,11 +34,6 @@ export function uuid() {
|
||||
return str.slice(0,-1);
|
||||
}
|
||||
|
||||
export function isPatTa(str) {
|
||||
const r = /^[a-z,0-9,\-,\.,_,~]+$/.exec(str);
|
||||
return Boolean(r);
|
||||
}
|
||||
|
||||
/*
|
||||
Goes from:
|
||||
~2018.7.17..23.15.09..5be5 // urbit @da
|
||||
@ -64,6 +74,9 @@ export function dateToDa(d, mil) {
|
||||
}
|
||||
|
||||
export function deSig(ship) {
|
||||
if(!ship) {
|
||||
return null;
|
||||
}
|
||||
return ship.replace('~', '');
|
||||
}
|
||||
|
||||
@ -78,30 +91,13 @@ export function uxToHex(ux) {
|
||||
}
|
||||
|
||||
export function hexToUx(hex) {
|
||||
const ux = _.chain(hex.split(""))
|
||||
const ux = _.chain(hex.split(''))
|
||||
.chunk(4)
|
||||
.map((x) => _.dropWhile(x, (y) => y === 0).join(""))
|
||||
.join(".");
|
||||
.map(x => _.dropWhile(x, y => y === 0).join(''))
|
||||
.join('.');
|
||||
return `0x${ux}`;
|
||||
}
|
||||
|
||||
function hexToDec(hex) {
|
||||
const alphabet = '0123456789ABCDEF'.split('');
|
||||
return hex.reverse().reduce((acc, digit, idx) => {
|
||||
const dec = alphabet.findIndex(a => a === digit.toUpperCase());
|
||||
if(dec < 0) {
|
||||
console.error(hex);
|
||||
throw new Error('Incorrect hex formatting');
|
||||
}
|
||||
return acc + dec * (16 ** idx);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function hexToRgba(hex, a) {
|
||||
const [r,g,b] = _.chunk(hex, 2).map(hexToDec);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
export function writeText(str) {
|
||||
return new Promise(((resolve, reject) => {
|
||||
const range = document.createRange();
|
||||
@ -148,6 +144,11 @@ export function cite(ship) {
|
||||
return `~${patp}`;
|
||||
}
|
||||
|
||||
export function alphabeticalOrder(a,b) {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
}
|
||||
|
||||
// TODO: deprecated
|
||||
export function alphabetiseAssociations(associations) {
|
||||
const result = {};
|
||||
Object.keys(associations).sort((a, b) => {
|
||||
@ -163,31 +164,13 @@ export function alphabetiseAssociations(associations) {
|
||||
? associations[b].metadata.title
|
||||
: b.substr(1);
|
||||
}
|
||||
return aName.toLowerCase().localeCompare(bName.toLowerCase());
|
||||
return alphabeticalOrder(aName,bName);
|
||||
}).map((each) => {
|
||||
result[each] = associations[each];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// encodes string into base64url,
|
||||
// by encoding into base64 and replacing non-url-safe characters.
|
||||
//
|
||||
export function base64urlEncode(string) {
|
||||
return window.btoa(string)
|
||||
.split('+').join('-')
|
||||
.split('/').join('_');
|
||||
}
|
||||
|
||||
// decode base64url. inverse of base64urlEncode above.
|
||||
//
|
||||
export function base64urlDecode(string) {
|
||||
return window.atob(
|
||||
string.split('_').join('/')
|
||||
.split('-').join('+')
|
||||
);
|
||||
}
|
||||
|
||||
// encode the string into @ta-safe format, using logic from +wood.
|
||||
// for example, 'some Chars!' becomes '~.some.~43.hars~21.'
|
||||
//
|
||||
@ -225,32 +208,9 @@ export function stringToTa(string) {
|
||||
return '~.' + out;
|
||||
}
|
||||
|
||||
// used in Links
|
||||
|
||||
export function makeRoutePath(
|
||||
resource,
|
||||
popout = false,
|
||||
page = 0,
|
||||
url = null,
|
||||
index = 0,
|
||||
compage = 0
|
||||
) {
|
||||
let route = "/~link" + (popout ? "/popout" : "") + resource;
|
||||
if (!url) {
|
||||
if (page !== 0) {
|
||||
route = route + "/" + page;
|
||||
}
|
||||
} else {
|
||||
route = `${route}/${page}/${index}/${base64urlEncode(url)}`;
|
||||
if (compage !== 0) {
|
||||
route = route + "/" + compage;
|
||||
}
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
export function amOwnerOfGroup(groupPath) {
|
||||
if (!groupPath) return false;
|
||||
if (!groupPath)
|
||||
return false;
|
||||
const groupOwner = /(\/~)?\/~([a-z-]{3,})\/.*/.exec(groupPath)[2];
|
||||
return window.ship === groupOwner;
|
||||
}
|
||||
@ -258,20 +218,20 @@ export function amOwnerOfGroup(groupPath) {
|
||||
export function getContactDetails(contact) {
|
||||
const member = !contact;
|
||||
contact = contact || {
|
||||
nickname: "",
|
||||
nickname: '',
|
||||
avatar: null,
|
||||
color: "0x0",
|
||||
color: '0x0'
|
||||
};
|
||||
const nickname = contact.nickname || "";
|
||||
const color = uxToHex(contact.color || "0x0");
|
||||
const nickname = contact.nickname || '';
|
||||
const color = uxToHex(contact.color || '0x0');
|
||||
const avatar = contact.avatar || null;
|
||||
return { nickname, color, member, avatar };
|
||||
}
|
||||
|
||||
export function stringToSymbol(str) {
|
||||
let result = '';
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var n = str.charCodeAt(i);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const n = str.charCodeAt(i);
|
||||
if (((n >= 97) && (n <= 122)) ||
|
||||
((n >= 48) && (n <= 57))) {
|
||||
result += str[i];
|
||||
|
24
pkg/interface/src/logic/lib/workspace.ts
Normal file
24
pkg/interface/src/logic/lib/workspace.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Associations, Workspace } from "~/types";
|
||||
|
||||
export function getTitleFromWorkspace(
|
||||
associations: Associations,
|
||||
workspace: Workspace
|
||||
) {
|
||||
switch (workspace.type) {
|
||||
case "home":
|
||||
return "Home";
|
||||
case "group":
|
||||
const association = associations.contacts[workspace.group];
|
||||
return association?.metadata?.title || "";
|
||||
}
|
||||
}
|
||||
|
||||
export function getGroupFromWorkspace(
|
||||
workspace: Workspace
|
||||
): string | undefined {
|
||||
if (workspace.type === "group") {
|
||||
return workspace.group;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
@ -100,5 +100,6 @@ export default class ChatReducer<S extends ChatState> {
|
||||
mailbox.splice(index, 1);
|
||||
}
|
||||
}
|
||||
state.pendingMessages.set(msg.path, mailbox);
|
||||
}
|
||||
}
|
||||
|
@ -1,212 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { StoreState } from '../../store/type';
|
||||
import { LinkUpdate, Pagination } from '~/types/link-update';
|
||||
|
||||
// page size as expected from link-view.
|
||||
// must change in parallel with the +page-size in /app/link-view to
|
||||
// ensure sane behavior.
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
type LinkState = Pick<StoreState, 'linksSeen' | 'links' | 'linkListening' | 'linkComments'>;
|
||||
|
||||
export default class LinkUpdateReducer<S extends LinkState> {
|
||||
reduce(json: any, state: S) {
|
||||
const data = _.get(json, 'link-update', false);
|
||||
if(data) {
|
||||
this.submissionsPage(data, state);
|
||||
this.submissionsUpdate(data, state);
|
||||
this.discussionsPage(data, state);
|
||||
this.discussionsUpdate(data, state);
|
||||
this.observationUpdate(data, state);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
submissionsPage(json: LinkUpdate, state: S) {
|
||||
const data = _.get(json, 'initial-submissions', false);
|
||||
if (data) {
|
||||
// { "initial-submissions": {
|
||||
// "/~ship/group": {
|
||||
// page: [{ship, timestamp, title, url}]
|
||||
// page-number: 0
|
||||
// total-items: 1
|
||||
// total-pages: 1
|
||||
// }
|
||||
// } }
|
||||
|
||||
for (var path of Object.keys(data)) {
|
||||
const here = data[path];
|
||||
const page = here.pageNumber;
|
||||
|
||||
// if we didn't have any state for this path yet, initialize.
|
||||
if (!state.links[path]) {
|
||||
state.links[path] = {
|
||||
local: {},
|
||||
totalItems: here.totalItems,
|
||||
totalPages: here.totalPages,
|
||||
unseenCount: here.unseenCount
|
||||
};
|
||||
}
|
||||
|
||||
// since data contains an up-to-date full version of the page,
|
||||
// we can safely overwrite the one in state.
|
||||
if (typeof page === 'number' && here.page) {
|
||||
state.links[path][page] = here.page;
|
||||
state.links[path].local[page] = false;
|
||||
}
|
||||
state.links[path].totalPages = here.totalPages;
|
||||
state.links[path].totalItems = here.totalItems;
|
||||
state.links[path].unseenCount = here.unseenCount;
|
||||
|
||||
// write seen status to a separate structure,
|
||||
// for easier modification later.
|
||||
if (!state.linksSeen[path]) {
|
||||
state.linksSeen[path] = {};
|
||||
}
|
||||
(here.page || []).map((submission) => {
|
||||
state.linksSeen[path][submission.url] = submission.seen;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submissionsUpdate(json: LinkUpdate, state: S) {
|
||||
const data = _.get(json, 'submissions', false);
|
||||
if (data) {
|
||||
// { "submissions": {
|
||||
// path: /~ship/group
|
||||
// pages: [{ship, timestamp, title, url}]
|
||||
// } }
|
||||
|
||||
const path = data.path;
|
||||
|
||||
// stub in a comment count, which is more or less guaranteed to be 0
|
||||
data.pages = data.pages.map((submission) => {
|
||||
submission.commentCount = 0;
|
||||
state.linksSeen[path][submission.url] = false;
|
||||
return submission;
|
||||
});
|
||||
|
||||
// add the new submissions to state, update totals
|
||||
state.links[path] = this._addNewItems(
|
||||
data.pages, state.links[path]
|
||||
);
|
||||
state.links[path].unseenCount =
|
||||
(state.links[path].unseenCount || 0) + data.pages.length;
|
||||
}
|
||||
}
|
||||
|
||||
discussionsPage(json: LinkUpdate, state: S) {
|
||||
const data = _.get(json, 'initial-discussions', false);
|
||||
if (data) {
|
||||
// { "initial-discussions": {
|
||||
// path: "/~ship/group"
|
||||
// url: https://urbit.org/
|
||||
// page: [{ship, timestamp, title, url}]
|
||||
// page-number: 0
|
||||
// total-items: 1
|
||||
// total-pages: 1
|
||||
// } }
|
||||
|
||||
const path = data.path;
|
||||
const url = data.url;
|
||||
const page = data.pageNumber;
|
||||
|
||||
// if we didn't have any state for this path yet, initialize.
|
||||
if (!state.linkComments[path]) {
|
||||
state.linkComments[path] = {};
|
||||
}
|
||||
let comments = {...{
|
||||
local: {},
|
||||
totalPages: data.totalPages,
|
||||
totalItems: data.totalItems
|
||||
}, ...state.linkComments[path][url] };
|
||||
|
||||
state.linkComments[path][url] = comments;
|
||||
const here = state.linkComments[path][url];
|
||||
|
||||
// since data contains an up-to-date full version of the page,
|
||||
// we can safely overwrite the one in state.
|
||||
here[page] = data.page;
|
||||
here.local[page] = false;
|
||||
here.totalPages = data.totalPages;
|
||||
here.totalItems = data.totalItems;
|
||||
}
|
||||
}
|
||||
|
||||
discussionsUpdate(json: LinkUpdate, state: S) {
|
||||
const data = _.get(json, 'discussions', false);
|
||||
if (data) {
|
||||
// { "discussions": {
|
||||
// path: /~ship/path
|
||||
// url: 'https://urbit.org'
|
||||
// comments: [{ship, timestamp, udon}]
|
||||
// } }
|
||||
|
||||
const path = data.path;
|
||||
const url = data.url;
|
||||
|
||||
// add new comments to state, update totals
|
||||
state.linkComments[path][url] = this._addNewItems(
|
||||
data.comments || [], state.linkComments[path][url]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
observationUpdate(json: LinkUpdate, state: S) {
|
||||
const data = _.get(json, 'observation', false);
|
||||
if (data) {
|
||||
// { "observation": {
|
||||
// path: /~ship/path
|
||||
// urls: ['https://urbit.org']
|
||||
// } }
|
||||
|
||||
const path = data.path;
|
||||
if (!state.linksSeen[path]) {
|
||||
state.linksSeen[path] = {};
|
||||
}
|
||||
const seen = state.linksSeen[path];
|
||||
|
||||
// mark urls as seen
|
||||
data.urls.map((url) => {
|
||||
seen[url] = true;
|
||||
});
|
||||
if (state.links[path]) {
|
||||
state.links[path].unseenCount =
|
||||
state.links[path].unseenCount - data.urls.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
_addNewItems<S extends { time: number }>(items: S[], pages: Pagination<S>, page = 0) {
|
||||
if (!pages) {
|
||||
pages = {
|
||||
local: {},
|
||||
totalPages: 0,
|
||||
totalItems: 0
|
||||
};
|
||||
}
|
||||
const i = page;
|
||||
if (!pages[i]) {
|
||||
pages[i] = [];
|
||||
// if we know this page exists in the backend, flag it as "local",
|
||||
// so that we know to initiate a "fetch the rest" request when we want
|
||||
// to display the page.
|
||||
pages.local[i] = (page < pages.totalPages);
|
||||
}
|
||||
pages[i] = items.concat(pages[i]);
|
||||
pages[i].sort((a, b) => b.time - a.time);
|
||||
pages.totalItems = pages.totalItems + items.length;
|
||||
if (pages[i].length <= PAGE_SIZE) {
|
||||
pages.totalPages = Math.ceil(pages.totalItems / PAGE_SIZE);
|
||||
return pages;
|
||||
}
|
||||
// overflow into next page
|
||||
const tail = pages[i].slice(PAGE_SIZE);
|
||||
pages[i].length = PAGE_SIZE;
|
||||
pages.totalItems = pages.totalItems - tail.length;
|
||||
return this._addNewItems(tail, pages, page+1);
|
||||
}
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { StoreState } from '../../store/type';
|
||||
import { Cage } from '~/types/cage';
|
||||
import { LinkListenUpdate } from '~/types/link-listen-update';
|
||||
|
||||
type LinkListenState = Pick<StoreState, 'linkListening'>;
|
||||
|
||||
export default class LinkListenReducer<S extends LinkListenState> {
|
||||
reduce(json: Cage, state: S) {
|
||||
const data = _.get(json, 'link-listen-update', false);
|
||||
if (data) {
|
||||
this.listening(data, state);
|
||||
this.watch(data, state);
|
||||
this.leave(data, state);
|
||||
}
|
||||
}
|
||||
|
||||
listening(json: LinkListenUpdate, state: S) {
|
||||
const data = _.get(json, 'listening', false);
|
||||
if (data) {
|
||||
state.linkListening = new Set(data);
|
||||
}
|
||||
}
|
||||
|
||||
watch(json: LinkListenUpdate, state: S) {
|
||||
const data = _.get(json, 'watch', false);
|
||||
if (data) {
|
||||
state.linkListening.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
leave(json: LinkListenUpdate, state: S) {
|
||||
const data = _.get(json, 'leave', false);
|
||||
if (data) {
|
||||
state.linkListening.delete(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ type LocalState = Pick<StoreState, 'sidebarShown' | 'omniboxShown' | 'baseHash'
|
||||
export default class LocalReducer<S extends LocalState> {
|
||||
rehydrate(state: S) {
|
||||
try {
|
||||
const json = JSON.parse(localStorage.getItem('localReducer') || '');
|
||||
const json = JSON.parse(localStorage.getItem('localReducer') || '{}');
|
||||
_.forIn(json, (value, key) => {
|
||||
state[key] = value;
|
||||
});
|
||||
|
@ -1,66 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { StoreState } from '../../store/type';
|
||||
import { Cage } from '~/types/cage';
|
||||
import { PermissionUpdate } from '~/types/permission-update';
|
||||
|
||||
type PermissionState = Pick<StoreState, "permissions">;
|
||||
|
||||
export default class PermissionReducer<S extends PermissionState> {
|
||||
reduce(json: Cage, state: S) {
|
||||
const data = _.get(json, 'permission-update', false);
|
||||
if (data) {
|
||||
this.initial(data, state);
|
||||
this.create(data, state);
|
||||
this.delete(data, state);
|
||||
this.add(data, state);
|
||||
this.remove(data, state);
|
||||
}
|
||||
}
|
||||
|
||||
initial(json: PermissionUpdate, state: S) {
|
||||
const data = _.get(json, 'initial', false);
|
||||
if (data) {
|
||||
for (const perm in data) {
|
||||
state.permissions[perm] = {
|
||||
who: new Set(data[perm].who),
|
||||
kind: data[perm].kind
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
create(json: PermissionUpdate, state: S) {
|
||||
const data = _.get(json, 'create', false);
|
||||
if (data) {
|
||||
state.permissions[data.path] = {
|
||||
kind: data.kind,
|
||||
who: new Set(data.who)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
delete(json: PermissionUpdate, state: S) {
|
||||
const data = _.get(json, 'delete', false);
|
||||
if (data) {
|
||||
delete state.permissions[data.path];
|
||||
}
|
||||
}
|
||||
|
||||
add(json: PermissionUpdate, state: S) {
|
||||
const data = _.get(json, 'add', false);
|
||||
if (data) {
|
||||
for (const member of data.who) {
|
||||
state.permissions[data.path].who.add(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(json: PermissionUpdate, state: S) {
|
||||
const data = _.get(json, 'remove', false);
|
||||
if (data) {
|
||||
for (const member of data.who) {
|
||||
state.permissions[data.path].who.delete(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -7,17 +7,27 @@ import ChatReducer from '../reducers/chat-update';
|
||||
import { StoreState } from './type';
|
||||
import { Cage } from '~/types/cage';
|
||||
import ContactReducer from '../reducers/contact-update';
|
||||
import LinkUpdateReducer from '../reducers/link-update';
|
||||
import S3Reducer from '../reducers/s3-update';
|
||||
import { GraphReducer } from '../reducers/graph-update';
|
||||
import GroupReducer from '../reducers/group-update';
|
||||
import PermissionReducer from '../reducers/permission-update';
|
||||
import PublishUpdateReducer from '../reducers/publish-update';
|
||||
import PublishResponseReducer from '../reducers/publish-response';
|
||||
import LaunchReducer from '../reducers/launch-update';
|
||||
import LinkListenReducer from '../reducers/listen-update';
|
||||
import ConnectionReducer from '../reducers/connection';
|
||||
|
||||
export const homeAssociation = {
|
||||
"app-path": "/home",
|
||||
"app-name": "contact",
|
||||
"group-path": "/home",
|
||||
metadata: {
|
||||
color: "0x0",
|
||||
title: "Home",
|
||||
description: "",
|
||||
"date-created": "",
|
||||
module: "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export default class GlobalStore extends BaseStore<StoreState> {
|
||||
inviteReducer = new InviteReducer();
|
||||
@ -25,11 +35,8 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
localReducer = new LocalReducer();
|
||||
chatReducer = new ChatReducer();
|
||||
contactReducer = new ContactReducer();
|
||||
linkReducer = new LinkUpdateReducer();
|
||||
linkListenReducer = new LinkListenReducer();
|
||||
s3Reducer = new S3Reducer();
|
||||
groupReducer = new GroupReducer();
|
||||
permissionReducer = new PermissionReducer();
|
||||
publishUpdateReducer = new PublishUpdateReducer();
|
||||
publishResponseReducer = new PublishResponseReducer();
|
||||
launchReducer = new LaunchReducer();
|
||||
@ -79,7 +86,6 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
},
|
||||
weather: {},
|
||||
userLocation: null,
|
||||
permissions: {},
|
||||
s3: {
|
||||
configuration: {
|
||||
buckets: new Set(),
|
||||
@ -87,10 +93,6 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
},
|
||||
credentials: null
|
||||
},
|
||||
links: {},
|
||||
linksSeen: {},
|
||||
linkListening: new Set(),
|
||||
linkComments: {},
|
||||
notebooks: {},
|
||||
contacts: {},
|
||||
dark: false,
|
||||
@ -105,14 +107,11 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
this.localReducer.reduce(data, this.state);
|
||||
this.chatReducer.reduce(data, this.state);
|
||||
this.contactReducer.reduce(data, this.state);
|
||||
this.linkReducer.reduce(data, this.state);
|
||||
this.s3Reducer.reduce(data, this.state);
|
||||
this.groupReducer.reduce(data, this.state);
|
||||
this.permissionReducer.reduce(data, this.state);
|
||||
this.publishUpdateReducer.reduce(data, this.state);
|
||||
this.publishResponseReducer.reduce(data, this.state);
|
||||
this.launchReducer.reduce(data, this.state);
|
||||
this.linkListenReducer.reduce(data, this.state);
|
||||
this.connReducer.reduce(data, this.state);
|
||||
GraphReducer(data, this.state);
|
||||
}
|
||||
|
@ -7,9 +7,7 @@ import { Rolodex } from '~/types/contact-update';
|
||||
import { Notebooks } from '~/types/publish-update';
|
||||
import { Groups } from '~/types/group-update';
|
||||
import { S3State } from '~/types/s3-update';
|
||||
import { Permissions } from '~/types/permission-update';
|
||||
import { LaunchState, WeatherState } from '~/types/launch-update';
|
||||
import { LinkComments, LinkCollections, LinkSeen } from '~/types/link-update';
|
||||
import { ConnectionStatus } from '~/types/connection';
|
||||
import {Graphs} from '~/types/graph-update';
|
||||
import { BackgroundConfig, LocalUpdateRemoteContentPolicy } from '~/types/local-update';
|
||||
@ -27,6 +25,7 @@ export interface StoreState {
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
|
||||
// invite state
|
||||
invites: Invites;
|
||||
// metadata state
|
||||
@ -36,7 +35,6 @@ export interface StoreState {
|
||||
// groups state
|
||||
groups: Groups;
|
||||
groupKeys: Set<Path>;
|
||||
permissions: Permissions;
|
||||
s3: S3State;
|
||||
graphs: Graphs;
|
||||
graphKeys: Set<string>;
|
||||
@ -48,12 +46,6 @@ export interface StoreState {
|
||||
weather: WeatherState | {} | null;
|
||||
userLocation: string | null;
|
||||
|
||||
// links state
|
||||
linksSeen: LinkSeen;
|
||||
linkListening: Set<Path>;
|
||||
links: LinkCollections;
|
||||
linkComments: LinkComments;
|
||||
|
||||
// publish state
|
||||
notebooks: Notebooks;
|
||||
|
||||
|
@ -18,11 +18,6 @@ const publishSubscriptions: AppSubscription[] = [
|
||||
['/primary', 'publish'],
|
||||
];
|
||||
|
||||
const linkSubscriptions: AppSubscription[] = [
|
||||
['/json/seen', 'link-view'],
|
||||
['/listening', 'link-listen-hook']
|
||||
]
|
||||
|
||||
const groupSubscriptions: AppSubscription[] = [
|
||||
['/synced', 'contact-hook']
|
||||
];
|
||||
@ -31,11 +26,10 @@ const graphSubscriptions: AppSubscription[] = [
|
||||
['/updates', 'graph-store']
|
||||
];
|
||||
|
||||
type AppName = 'publish' | 'chat' | 'link' | 'groups' | 'graph';
|
||||
type AppName = 'publish' | 'chat' | 'groups' | 'graph';
|
||||
const appSubscriptions: Record<AppName, AppSubscription[]> = {
|
||||
chat: chatSubscriptions,
|
||||
publish: publishSubscriptions,
|
||||
link: linkSubscriptions,
|
||||
groups: groupSubscriptions,
|
||||
graph: graphSubscriptions
|
||||
};
|
||||
@ -44,7 +38,6 @@ export default class GlobalSubscription extends BaseSubscription<StoreState> {
|
||||
openSubscriptions: Record<AppName, number[]> = {
|
||||
chat: [],
|
||||
publish: [],
|
||||
link: [],
|
||||
groups: [],
|
||||
graph: []
|
||||
};
|
||||
|
@ -17,3 +17,4 @@ export * from './permission-update';
|
||||
export * from './publish-response';
|
||||
export * from './publish-update';
|
||||
export * from './s3-update';
|
||||
export * from './workspace';
|
||||
|
@ -1,18 +0,0 @@
|
||||
import { Path } from './noun';
|
||||
|
||||
interface LinkListenUpdateListening {
|
||||
listening: Path[];
|
||||
}
|
||||
|
||||
interface LinkListenUpdateWatch {
|
||||
watch: Path;
|
||||
}
|
||||
|
||||
interface LinkListenUpdateLeave {
|
||||
leave: Path;
|
||||
}
|
||||
|
||||
export type LinkListenUpdate =
|
||||
LinkListenUpdateListening
|
||||
| LinkListenUpdateWatch
|
||||
| LinkListenUpdateLeave;
|
@ -1,84 +0,0 @@
|
||||
import { PatpNoSig, Path } from "./noun";
|
||||
|
||||
export type LinkCollections = {
|
||||
[p in Path]: Collection;
|
||||
};
|
||||
|
||||
export type LinkSeen = {
|
||||
[p in Path]: {
|
||||
[url: string]: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type Pagination<S> = {
|
||||
local: LocalPages;
|
||||
[p: number]: S[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type LinkComments = {
|
||||
[p in Path]: {
|
||||
[url: string]: Pagination<LinkComment> & {
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface LinkComment {
|
||||
ship: PatpNoSig;
|
||||
time: number;
|
||||
udon: string;
|
||||
}
|
||||
|
||||
interface CollectionStats {
|
||||
unseenCount: number;
|
||||
}
|
||||
|
||||
type LocalPages = {
|
||||
[p: number]: boolean;
|
||||
}
|
||||
|
||||
type Collection = CollectionStats & Pagination<Link>;
|
||||
|
||||
interface Link {
|
||||
commentCount: number;
|
||||
seen: boolean;
|
||||
ship: PatpNoSig;
|
||||
time: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface LinkInitialSubmissions {
|
||||
'initial-submissions': {
|
||||
[p in Path]: CollectionStats & {
|
||||
pageNumber?: number;
|
||||
pages?: Link[];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
interface LinkUpdateSubmission {
|
||||
'submissions': {
|
||||
path: Path;
|
||||
pages: Link[];
|
||||
}
|
||||
}
|
||||
|
||||
interface LinkInitialDiscussion {
|
||||
'intitial-discussion': {
|
||||
path: Path;
|
||||
url: string;
|
||||
page: Comment[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
pageNumber: number;
|
||||
}
|
||||
}
|
||||
|
||||
export type LinkUpdate =
|
||||
LinkInitialSubmissions
|
||||
| LinkUpdateSubmission
|
||||
| LinkInitialDiscussion;
|
@ -45,10 +45,11 @@ export type Association = Resource & {
|
||||
metadata: Metadata;
|
||||
};
|
||||
|
||||
interface Metadata {
|
||||
export interface Metadata {
|
||||
color: string;
|
||||
creator: Patp;
|
||||
'date-created': string;
|
||||
description: string;
|
||||
title: string;
|
||||
module: string;
|
||||
}
|
||||
|
@ -1,55 +0,0 @@
|
||||
import { Path, PatpNoSig } from './noun';
|
||||
|
||||
export type PermissionUpdate =
|
||||
PermissionUpdateInitial
|
||||
| PermissionUpdateCreate
|
||||
| PermissionUpdateDelete
|
||||
| PermissionUpdateRemove
|
||||
| PermissionUpdateAdd;
|
||||
|
||||
interface PermissionUpdateInitial {
|
||||
initial: {
|
||||
[p in Path]: {
|
||||
who: PatpNoSig[];
|
||||
kind: PermissionKind;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface PermissionUpdateCreate {
|
||||
create: {
|
||||
path: Path;
|
||||
kind: PermissionKind;
|
||||
who: PatpNoSig[];
|
||||
}
|
||||
}
|
||||
|
||||
interface PermissionUpdateDelete {
|
||||
delete: {
|
||||
path: Path;
|
||||
}
|
||||
}
|
||||
|
||||
interface PermissionUpdateAdd {
|
||||
add: {
|
||||
path: Path;
|
||||
who: PatpNoSig[];
|
||||
}
|
||||
}
|
||||
|
||||
interface PermissionUpdateRemove {
|
||||
remove: {
|
||||
path: Path;
|
||||
who: PatpNoSig[];
|
||||
}
|
||||
}
|
||||
|
||||
export type Permissions = {
|
||||
[p in Path]: Permission;
|
||||
};
|
||||
export interface Permission {
|
||||
who: Set<PatpNoSig>;
|
||||
kind: PermissionKind;
|
||||
}
|
||||
|
||||
export type PermissionKind = 'white' | 'black';
|
12
pkg/interface/src/types/workspace.ts
Normal file
12
pkg/interface/src/types/workspace.ts
Normal file
@ -0,0 +1,12 @@
|
||||
|
||||
|
||||
interface GroupWorkspace {
|
||||
type: 'group';
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface HomeWorkspace {
|
||||
type: 'home'
|
||||
}
|
||||
|
||||
export type Workspace = HomeWorkspace | GroupWorkspace;
|
@ -3,7 +3,7 @@ import 'react-hot-loader';
|
||||
import * as React from 'react';
|
||||
import { BrowserRouter as Router, withRouter } from 'react-router-dom';
|
||||
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
|
||||
import { sigil as sigiljs, stringRenderer } from 'urbit-sigil-js';
|
||||
import { sigil as sigiljs, stringRenderer } from '@tlon/sigil-js';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import Mousetrap from 'mousetrap';
|
||||
@ -14,9 +14,10 @@ import './css/fonts.css';
|
||||
import light from './themes/light';
|
||||
import dark from './themes/old-dark';
|
||||
|
||||
import { Content } from './components/Content';
|
||||
import { Content } from './landscape/components/Content';
|
||||
import StatusBar from './components/StatusBar';
|
||||
import Omnibox from './components/leap/Omnibox';
|
||||
import ErrorBoundary from '~/views/components/ErrorBoundary';
|
||||
|
||||
import GlobalStore from '~/logic/store/store';
|
||||
import GlobalSubscription from '~/logic/subscription/global';
|
||||
@ -44,18 +45,18 @@ const Root = styled.div`
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${ p => p.theme.colors.gray } ${ p => p.theme.colors.white };
|
||||
}
|
||||
|
||||
|
||||
/* Works on Chrome/Edge/Safari */
|
||||
*::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
width: 6px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: ${ p => p.theme.colors.white };
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: ${ p => p.theme.colors.gray };
|
||||
border-radius: 1rem;
|
||||
border: 3px solid ${ p => p.theme.colors.white };
|
||||
border: 0px solid transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
@ -130,32 +131,40 @@ class App extends React.Component {
|
||||
? <link rel="icon" type="image/svg+xml" href={this.faviconString()} />
|
||||
: null}
|
||||
</Helmet>
|
||||
<Root background={background} >
|
||||
<Root background={background}>
|
||||
<Router>
|
||||
<StatusBarWithRouter
|
||||
props={this.props}
|
||||
associations={associations}
|
||||
invites={this.state.invites}
|
||||
api={this.api}
|
||||
connection={this.state.connection}
|
||||
subscription={this.subscription}
|
||||
ship={this.ship}
|
||||
/>
|
||||
<Omnibox
|
||||
associations={state.associations}
|
||||
apps={state.launch}
|
||||
api={this.api}
|
||||
dark={state.dark}
|
||||
show={state.omniboxShown}
|
||||
/>
|
||||
<Content
|
||||
ship={this.ship}
|
||||
api={this.api}
|
||||
subscription={this.subscription}
|
||||
{...state}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<StatusBarWithRouter
|
||||
props={this.props}
|
||||
associations={associations}
|
||||
invites={this.state.invites}
|
||||
api={this.api}
|
||||
connection={this.state.connection}
|
||||
subscription={this.subscription}
|
||||
ship={this.ship}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<Omnibox
|
||||
associations={state.associations}
|
||||
apps={state.launch}
|
||||
api={this.api}
|
||||
dark={state.dark}
|
||||
groups={state.groups}
|
||||
show={state.omniboxShown}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<Content
|
||||
ship={this.ship}
|
||||
api={this.api}
|
||||
subscription={this.subscription}
|
||||
{...state}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
</Root>
|
||||
<div id="portal-root" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
139
pkg/interface/src/views/apps/chat/ChatResource.tsx
Normal file
139
pkg/interface/src/views/apps/chat/ChatResource.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import React, { useRef, useCallback } from "react";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { Col } from "@tlon/indigo-react";
|
||||
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import { StoreState } from "~/logic/store/type";
|
||||
import { useFileDrag } from "~/logic/lib/useDrag";
|
||||
import ChatWindow from "./components/ChatWindow";
|
||||
import ChatInput from "./components/ChatInput";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { deSig } from "~/logic/lib/util";
|
||||
import { SubmitDragger } from "~/views/components/s3-upload";
|
||||
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
|
||||
|
||||
type ChatResourceProps = StoreState & {
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
baseUrl: string;
|
||||
} & RouteComponentProps;
|
||||
|
||||
export function ChatResource(props: ChatResourceProps) {
|
||||
const station = props.association["app-path"];
|
||||
if (!props.chatInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { envelopes, config } = props.inbox[station];
|
||||
const { read, length } = config;
|
||||
|
||||
const groupPath = props.association["group-path"];
|
||||
const group = props.groups[groupPath];
|
||||
const contacts = props.contacts[groupPath] || {};
|
||||
|
||||
const pendingMessages = (props.pendingMessages.get(station) || []).map(
|
||||
(value) => ({
|
||||
...value,
|
||||
pending: true,
|
||||
})
|
||||
);
|
||||
|
||||
const isChatMissing =
|
||||
(props.chatInitialized &&
|
||||
!(station in props.inbox) &&
|
||||
props.chatSynced &&
|
||||
!(station in props.chatSynced)) ||
|
||||
false;
|
||||
|
||||
const isChatLoading =
|
||||
(props.chatInitialized &&
|
||||
!(station in props.inbox) &&
|
||||
props.chatSynced &&
|
||||
station in props.chatSynced) ||
|
||||
false;
|
||||
|
||||
const isChatUnsynced =
|
||||
(props.chatSynced &&
|
||||
!(station in props.chatSynced) &&
|
||||
envelopes.length > 0) ||
|
||||
false;
|
||||
|
||||
const unreadCount = length - read;
|
||||
const unreadMsg = unreadCount > 0 && envelopes[unreadCount - 1];
|
||||
|
||||
const [, owner, name] = station.split("/");
|
||||
const ourContact = contacts?.[window.ship];
|
||||
const lastMsgNum = envelopes.length || 0;
|
||||
|
||||
const chatInput = useRef<ChatInput>();
|
||||
|
||||
const onFileDrag = useCallback(
|
||||
(files: FileList) => {
|
||||
if (!chatInput.current) {
|
||||
return;
|
||||
}
|
||||
chatInput.current?.uploadFiles(files);
|
||||
},
|
||||
[chatInput?.current]
|
||||
);
|
||||
|
||||
const { bind, dragging } = useFileDrag(onFileDrag);
|
||||
|
||||
const [unsent, setUnsent] = useLocalStorageState<Record<string, string>>(
|
||||
"chat-unsent",
|
||||
{}
|
||||
);
|
||||
|
||||
const appendUnsent = useCallback(
|
||||
(u: string) => setUnsent((s) => ({ ...s, [station]: u })),
|
||||
[station]
|
||||
);
|
||||
|
||||
const clearUnsent = useCallback(() => setUnsent((s) => _.omit(s, station)), [
|
||||
station,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Col {...bind} height="100%" overflow="hidden" position="relative">
|
||||
{dragging && <SubmitDragger />}
|
||||
<ChatWindow
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
mailboxSize={length}
|
||||
match={props.match as any}
|
||||
stationPendingMessages={pendingMessages}
|
||||
history={props.history}
|
||||
isChatMissing={isChatMissing}
|
||||
isChatLoading={isChatLoading}
|
||||
isChatUnsynced={isChatUnsynced}
|
||||
unreadCount={unreadCount}
|
||||
unreadMsg={unreadMsg}
|
||||
envelopes={envelopes || []}
|
||||
contacts={contacts}
|
||||
association={props.association}
|
||||
group={group}
|
||||
ship={owner}
|
||||
station={station}
|
||||
allStations={Object.keys(props.inbox)}
|
||||
api={props.api}
|
||||
hideNicknames={props.hideNicknames}
|
||||
hideAvatars={props.hideAvatars}
|
||||
location={props.location}
|
||||
/>
|
||||
<ChatInput
|
||||
ref={chatInput}
|
||||
api={props.api}
|
||||
numMsgs={lastMsgNum}
|
||||
station={station}
|
||||
ourContact={ourContact}
|
||||
envelopes={envelopes || []}
|
||||
contacts={contacts}
|
||||
onUnmount={appendUnsent}
|
||||
s3={props.s3}
|
||||
hideAvatars={props.hideAvatars}
|
||||
placeholder="Message..."
|
||||
message={unsent[station] || ""}
|
||||
deleteMessage={clearUnsent}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -1,327 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import './css/custom.css';
|
||||
|
||||
import { Skeleton } from './components/skeleton';
|
||||
import { Sidebar } from './components/sidebar';
|
||||
import { ChatScreen } from './components/chat';
|
||||
import { SettingsScreen } from './components/settings';
|
||||
import { NewScreen } from './components/new';
|
||||
import { JoinScreen } from './components/join';
|
||||
import { NewDmScreen } from './components/new-dm';
|
||||
import { PatpNoSig } from '~/types/noun';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { StoreState } from '~/logic/store/type';
|
||||
import GlobalSubscription from '~/logic/subscription/global';
|
||||
import {groupBunts} from '~/types/group-update';
|
||||
|
||||
type ChatAppProps = StoreState & {
|
||||
ship: PatpNoSig;
|
||||
api: GlobalApi;
|
||||
subscription: GlobalSubscription;
|
||||
};
|
||||
|
||||
export default class ChatApp extends React.Component<ChatAppProps, {}> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// preload spinner asset
|
||||
new Image().src = '/~landscape/img/Spinner.png';
|
||||
|
||||
this.props.subscription.startApp('chat');
|
||||
|
||||
if (!this.props.sidebarShown) {
|
||||
this.props.api.local.sidebarToggle();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.subscription.stopApp('chat');
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const messagePreviews = {};
|
||||
const unreads = {};
|
||||
let totalUnreads = 0;
|
||||
|
||||
const associations = props.associations
|
||||
? props.associations
|
||||
: { chat: {}, contacts: {} };
|
||||
|
||||
Object.keys(props.inbox).forEach((stat) => {
|
||||
const envelopes = props.inbox[stat].envelopes;
|
||||
|
||||
if (envelopes.length === 0) {
|
||||
messagePreviews[stat] = false;
|
||||
} else {
|
||||
messagePreviews[stat] = envelopes[0];
|
||||
}
|
||||
|
||||
const unread = Math.max(
|
||||
props.inbox[stat].config.length - props.inbox[stat].config.read,
|
||||
0
|
||||
);
|
||||
unreads[stat] = Boolean(unread);
|
||||
if (
|
||||
unread &&
|
||||
stat in associations.chat
|
||||
) {
|
||||
totalUnreads += unread;
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
invites,
|
||||
s3,
|
||||
sidebarShown,
|
||||
inbox,
|
||||
contacts,
|
||||
chatSynced,
|
||||
api,
|
||||
chatInitialized,
|
||||
pendingMessages,
|
||||
groups,
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
remoteContentPolicy
|
||||
} = props;
|
||||
|
||||
const renderChannelSidebar = (props, station?) => (
|
||||
<Sidebar
|
||||
inbox={inbox}
|
||||
messagePreviews={messagePreviews}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
invites={invites['/chat'] || {}}
|
||||
unreads={unreads}
|
||||
api={api}
|
||||
station={station}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet defer={false}>
|
||||
<title>{totalUnreads > 0 ? `(${totalUnreads}) ` : ''}OS1 - Chat</title>
|
||||
</Helmet>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
chatHideonMobile={true}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
>
|
||||
<div className="h-100 w-100 overflow-x-hidden flex flex-column bg-white bg-gray0-d">
|
||||
<div className="pl3 pr3 pt2 dt pb3 w-100 h-100">
|
||||
<p className="f8 pt3 gray2 w-100 h-100 dtc v-mid tc">
|
||||
Select, create, or join a chat to begin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/new/dm/:ship?"
|
||||
render={(props) => {
|
||||
const ship = props.match.params.ship;
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<NewDmScreen
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
groups={groups || {}}
|
||||
contacts={contacts || {}}
|
||||
associations={associations.contacts}
|
||||
chatSynced={chatSynced || {}}
|
||||
autoCreate={ship}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/new"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<NewScreen
|
||||
api={api}
|
||||
inbox={inbox || {}}
|
||||
groups={groups}
|
||||
contacts={contacts || {}}
|
||||
associations={associations.contacts}
|
||||
chatSynced={chatSynced || {}}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/join/:ship?/:station?"
|
||||
render={(props) => {
|
||||
let station = `/${props.match.params.ship}/${props.match.params.station}`;
|
||||
|
||||
// ensure we know joined chats
|
||||
if(!chatInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
sidebar={renderChannelSidebar(props)}
|
||||
sidebarShown={sidebarShown}
|
||||
>
|
||||
<JoinScreen
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
station={station}
|
||||
chatSynced={chatSynced || {}}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/(popout)?/room/(~)?/:ship/:station+"
|
||||
render={(props) => {
|
||||
let station = `/${props.match.params.ship}/${props.match.params.station}`;
|
||||
const mailbox = inbox[station] || {
|
||||
config: {
|
||||
read: 0,
|
||||
length: 0
|
||||
},
|
||||
envelopes: []
|
||||
};
|
||||
|
||||
let roomContacts = {};
|
||||
const associatedGroup =
|
||||
station in associations['chat'] &&
|
||||
'group-path' in associations.chat[station]
|
||||
? associations.chat[station]['group-path']
|
||||
: '';
|
||||
|
||||
if (associations.chat[station] && associatedGroup in contacts) {
|
||||
roomContacts = contacts[associatedGroup];
|
||||
}
|
||||
|
||||
const association =
|
||||
station in associations['chat'] ? associations.chat[station] : {};
|
||||
|
||||
const group = groups[association['group-path']] || groupBunts.group();
|
||||
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebar={renderChannelSidebar(props, station)}
|
||||
>
|
||||
<ChatScreen
|
||||
chatSynced={chatSynced || {}}
|
||||
station={station}
|
||||
association={association}
|
||||
api={api}
|
||||
read={mailbox.config.read}
|
||||
mailboxSize={mailbox.config.length}
|
||||
envelopes={mailbox.envelopes}
|
||||
inbox={inbox}
|
||||
contacts={roomContacts}
|
||||
group={group}
|
||||
pendingMessages={pendingMessages}
|
||||
s3={s3}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
chatInitialized={chatInitialized}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/~chat/(popout)?/settings/(~)?/:ship/:station+"
|
||||
render={(props) => {
|
||||
let station = `/${props.match.params.ship}/${props.match.params.station}`;
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
|
||||
const association =
|
||||
station in associations['chat'] ? associations.chat[station] : {};
|
||||
const group = groups[association['group-path']] || groupBunts.group();
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebar={renderChannelSidebar(props, station)}
|
||||
>
|
||||
<SettingsScreen
|
||||
{...props}
|
||||
station={station}
|
||||
association={association}
|
||||
groups={groups || {}}
|
||||
group={group}
|
||||
contacts={contacts || {}}
|
||||
associations={associations.contacts}
|
||||
api={api}
|
||||
inbox={inbox}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import ChatEditor from './chat-editor';
|
||||
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload'
|
||||
;
|
||||
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload' ;
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage';
|
||||
@ -13,8 +12,7 @@ interface ChatInputProps {
|
||||
api: GlobalApi;
|
||||
numMsgs: number;
|
||||
station: any;
|
||||
owner: string;
|
||||
ownerContact: any;
|
||||
ourContact: any;
|
||||
envelopes: Envelope[];
|
||||
contacts: Contacts;
|
||||
onUnmount(msg: string): void;
|
||||
@ -51,7 +49,7 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
|
||||
this.submit = this.submit.bind(this);
|
||||
this.toggleCode = this.toggleCode.bind(this);
|
||||
|
||||
|
||||
}
|
||||
|
||||
toggleCode() {
|
||||
@ -83,7 +81,7 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
submit(text) {
|
||||
const { props, state } = this;
|
||||
@ -135,7 +133,7 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
{ url }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
uploadError(error) {
|
||||
@ -170,37 +168,32 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const color = props.ownerContact
|
||||
? uxToHex(props.ownerContact.color) : '000000';
|
||||
const color = props.ourContact
|
||||
? uxToHex(props.ourContact.color) : '000000';
|
||||
|
||||
const sigilClass = props.ownerContact
|
||||
const sigilClass = props.ourContact
|
||||
? '' : 'mix-blend-diff';
|
||||
|
||||
const avatar = (
|
||||
props.ownerContact &&
|
||||
((props.ownerContact.avatar !== null) && !props.hideAvatars)
|
||||
props.ourContact &&
|
||||
((props.ourContact.avatar !== null) && !props.hideAvatars)
|
||||
)
|
||||
? <img src={props.ownerContact.avatar} height={24} width={24} className="dib" />
|
||||
? <img src={props.ourContact.avatar} height={16} width={16} className="dib" />
|
||||
: <Sigil
|
||||
ship={window.ship}
|
||||
size={24}
|
||||
size={16}
|
||||
color={`#${color}`}
|
||||
classes={sigilClass}
|
||||
/>;
|
||||
|
||||
return (
|
||||
<div className={
|
||||
"pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white " +
|
||||
"cf items-center flex black white-d bt b--gray4 b--gray1-d bg-white" +
|
||||
"bg-gray0-d relative"
|
||||
}
|
||||
style={{ flexGrow: 1 }}
|
||||
>
|
||||
<div className="fl"
|
||||
style={{
|
||||
marginTop: 6,
|
||||
flexBasis: 24,
|
||||
height: 24
|
||||
}}>
|
||||
<div className="pa2 flex items-center">
|
||||
{avatar}
|
||||
</div>
|
||||
<ChatEditor
|
||||
@ -212,12 +205,11 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
onPaste={this.onPaste.bind(this)}
|
||||
placeholder='Message...'
|
||||
/>
|
||||
<div className="ml2 mr2"
|
||||
<div className="ml2 mr2 flex-shrink-0"
|
||||
style={{
|
||||
height: '16px',
|
||||
width: '16px',
|
||||
flexBasis: 16,
|
||||
marginTop: 10
|
||||
}}>
|
||||
<S3Upload
|
||||
ref={this.s3Uploader}
|
||||
@ -229,17 +221,16 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
>
|
||||
<img
|
||||
className="invert-d"
|
||||
src="/~chat/img/ImageUpload.png"
|
||||
src="/~landscape/img/ImageUpload.png"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
</S3Upload>
|
||||
</div>
|
||||
<div style={{
|
||||
<div className="mr2 flex-shrink-0" style={{
|
||||
height: '16px',
|
||||
width: '16px',
|
||||
flexBasis: 16,
|
||||
marginTop: 10
|
||||
}}>
|
||||
<img style={{
|
||||
filter: state.inCodeMode ? 'invert(100%)' : '',
|
||||
@ -247,7 +238,7 @@ export default class ChatInput extends Component<ChatInputProps, ChatInputState>
|
||||
width: '14px',
|
||||
}}
|
||||
onClick={this.toggleCode}
|
||||
src="/~chat/img/CodeEval.png"
|
||||
src="/~landscape/img/CodeEval.png"
|
||||
className="contrast-10-d bg-white bg-none-d ba b--gray1-d br1" />
|
||||
</div>
|
||||
</div>
|
@ -1,7 +1,7 @@
|
||||
import React, { Component, PureComponent } from "react";
|
||||
import moment from "moment";
|
||||
import _ from "lodash";
|
||||
import { Box } from "@tlon/indigo-react";
|
||||
import { Box, Row, Text } from "@tlon/indigo-react";
|
||||
|
||||
import { OverlaySigil } from './overlay-sigil';
|
||||
import { uxToHex, cite, writeText } from '~/logic/lib/util';
|
||||
@ -14,14 +14,14 @@ import RemoteContent from '~/views/components/RemoteContent';
|
||||
export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
|
||||
|
||||
export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
|
||||
<div ref={ref} className="green2 flex items-center f9 absolute w-100 left-0">
|
||||
<hr className="dn-s ma0 w2 b--green2 bt-0" />
|
||||
<p className="mh4" style={{ whiteSpace: 'normal' }}>New messages below</p>
|
||||
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
|
||||
<div ref={ref} style={{ color: "#219dff" }} className="flex items-center f9 absolute w-100 left-0 pv0">
|
||||
<hr style={{ borderColor: "#219dff" }} className="dn-s ma0 w2 bt-0" />
|
||||
<p className="mh4 z-2" style={{ whiteSpace: 'normal' }}>New messages below</p>
|
||||
<hr style={{ borderColor: "#219dff" }} className="ma0 flex-grow-1 bt-0" />
|
||||
{dayBreak
|
||||
? <p className="gray2 mh4">{moment(when).calendar()}</p>
|
||||
: null}
|
||||
<hr style={{ width: "calc(50% - 48px)" }} className="b--green2 ma0 bt-0" />
|
||||
<hr style={{ width: "calc(50% - 48px)" }} style={{ borderColor: "#219dff" }} className="ma0 bt-0" />
|
||||
</div>
|
||||
));
|
||||
|
||||
@ -46,9 +46,12 @@ interface ChatMessageProps {
|
||||
className?: string;
|
||||
isPending: boolean;
|
||||
style?: any;
|
||||
allStations: any;
|
||||
scrollWindow: HTMLDivElement;
|
||||
isLastMessage?: boolean;
|
||||
unreadMarkerRef: React.RefObject<HTMLDivElement>;
|
||||
history: any;
|
||||
api: any;
|
||||
}
|
||||
|
||||
export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
@ -83,15 +86,18 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
measure,
|
||||
scrollWindow,
|
||||
isLastMessage,
|
||||
unreadMarkerRef
|
||||
unreadMarkerRef,
|
||||
allStations,
|
||||
history,
|
||||
api
|
||||
} = this.props;
|
||||
|
||||
|
||||
const renderSigil = Boolean((nextMsg && msg.author !== nextMsg.author) || !nextMsg || msg.number === 1);
|
||||
const dayBreak = nextMsg && new Date(msg.when).getDate() !== new Date(nextMsg.when).getDate();
|
||||
|
||||
const containerClass = `${renderSigil
|
||||
? `w-100 flex flex-wrap cf pr3 f7 pt4 pl3 lh-copy`
|
||||
: `w-100 flex flex-wrap cf pr3 hide-child`} ${isPending ? 'o-40' : ''} ${isLastMessage ? 'pb3' : ''} ${className}`
|
||||
? `cf pt2 pl3 lh-copy`
|
||||
: `items-center cf hide-child`} ${isPending ? 'o-40' : ''} ${className}`
|
||||
|
||||
const timestamp = moment.unix(msg.when / 1000).format(renderSigil ? 'hh:mm a' : 'hh:mm');
|
||||
|
||||
@ -112,6 +118,9 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
style,
|
||||
containerClass,
|
||||
isPending,
|
||||
allStations,
|
||||
history,
|
||||
api,
|
||||
scrollWindow
|
||||
};
|
||||
|
||||
@ -120,15 +129,26 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={this.divRef} className={containerClass} style={style} data-number={msg.number}>
|
||||
<Box
|
||||
width='100%'
|
||||
display='flex'
|
||||
flexWrap='wrap'
|
||||
pr={3}
|
||||
pb={isLastMessage ? 3 : 0}
|
||||
ref={this.divRef}
|
||||
className={containerClass}
|
||||
style={style}
|
||||
data-number={msg.number}
|
||||
mb={1}
|
||||
>
|
||||
{dayBreak && !isLastRead ? <DayBreak when={msg.when} /> : null}
|
||||
{renderSigil
|
||||
? <MessageWithSigil {...messageProps} />
|
||||
: <MessageWithoutSigil {...messageProps} />}
|
||||
<Box fontSize='0' position='relative' width='100%' overflow='hidden' style={unreadContainerStyle}>{isLastRead
|
||||
<Box fontSize={0} position='relative' width='100%' overflow='visible' style={unreadContainerStyle}>{isLastRead
|
||||
? <UnreadMarker dayBreak={dayBreak} when={msg.when} ref={unreadMarkerRef} />
|
||||
: null}</Box>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -145,6 +165,7 @@ interface MessageProps {
|
||||
containerClass: string;
|
||||
isPending: boolean;
|
||||
style: any;
|
||||
allStations: any;
|
||||
measure(element): void;
|
||||
scrollWindow: HTMLDivElement;
|
||||
};
|
||||
@ -161,6 +182,9 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
hideAvatars,
|
||||
remoteContentPolicy,
|
||||
measure,
|
||||
allStations,
|
||||
history,
|
||||
api,
|
||||
scrollWindow
|
||||
} = this.props;
|
||||
|
||||
@ -194,25 +218,35 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
scrollWindow={scrollWindow}
|
||||
className="fl pr3 v-top bg-white bg-gray0-d"
|
||||
allStations={allStations}
|
||||
history={history}
|
||||
api={api}
|
||||
className="fl pr3 v-top bg-white bg-gray0-d pt1"
|
||||
/>
|
||||
<div className="fr clamp-message white-d" style={{ flexGrow: 1, marginTop: -8 }}>
|
||||
<div className="hide-child" style={{ paddingTop: '6px' }}>
|
||||
<p className="v-mid f9 gray2 dib mr3 c-default">
|
||||
<span
|
||||
className={`mw5 db truncate pointer ${showNickname ? '' : 'mono'}`}
|
||||
ref={e => nameSpan = e}
|
||||
onClick={() => {
|
||||
writeText(msg.author);
|
||||
copyNotice(name);
|
||||
}}
|
||||
title={`~${msg.author}`}
|
||||
>{name}</span>
|
||||
</p>
|
||||
<p className="v-mid mono f9 gray2 dib">{timestamp}</p>
|
||||
<p className="v-mid mono f9 gray2 dib ml2 child dn-s">{datestamp}</p>
|
||||
</div>
|
||||
<MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} />
|
||||
<div className="clamp-message" style={{ flexGrow: 1 }}>
|
||||
<Box
|
||||
className="hide-child"
|
||||
pt={1}
|
||||
pb={1}
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
>
|
||||
<Text
|
||||
fontSize={0}
|
||||
mr={3}
|
||||
mono={!showNickname}
|
||||
className={`mw5 db truncate pointer`}
|
||||
ref={e => nameSpan = e}
|
||||
onClick={() => {
|
||||
writeText(msg.author);
|
||||
copyNotice(name);
|
||||
}}
|
||||
title={`~${msg.author}`}
|
||||
>{name}</Text>
|
||||
<Text gray mono className="v-mid">{timestamp}</Text>
|
||||
<Text gray mono ml={2} className="v-mid child dn-s">{datestamp}</Text>
|
||||
</Box>
|
||||
<Box fontSize={0}><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} /></Box>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@ -221,10 +255,10 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
|
||||
export const MessageWithoutSigil = ({ timestamp, msg, remoteContentPolicy, measure }) => (
|
||||
<>
|
||||
<p className="child pt2 pl2 pr1 mono f9 gray2 dib">{timestamp}</p>
|
||||
<div className="fr f7 clamp-message white-d pr3 lh-copy" style={{ flexGrow: 1 }}>
|
||||
<p className="child pr1 mono f9 gray2 dib">{timestamp}</p>
|
||||
<Box fontSize={0} className="clamp-message" style={{ flexGrow: 1 }}>
|
||||
<MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure}/>
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -233,21 +267,23 @@ export const MessageContent = ({ content, remoteContentPolicy, measure }) => {
|
||||
return <CodeContent content={content} />;
|
||||
} else if ('url' in content) {
|
||||
return (
|
||||
<RemoteContent
|
||||
url={content.url}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
onLoad={measure}
|
||||
imageProps={{style: {
|
||||
<Text fontSize={0} lineHeight="tall" color='gray'>
|
||||
<RemoteContent
|
||||
url={content.url}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
onLoad={measure}
|
||||
imageProps={{style: {
|
||||
maxWidth: '18rem'
|
||||
}}}
|
||||
videoProps={{style: {
|
||||
maxWidth: '18rem'
|
||||
}}}
|
||||
videoProps={{style: {
|
||||
maxWidth: '18rem'
|
||||
}}}
|
||||
/>
|
||||
}}}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
} else if ('me' in content) {
|
||||
return (
|
||||
<p className='f7 i lh-copy v-top'>
|
||||
<p className='f9 i lh-copy v-top'>
|
||||
{content.me}
|
||||
</p>
|
||||
);
|
||||
@ -283,4 +319,4 @@ export const MessagePlaceholder = ({ height, index, className = '', style = {},
|
||||
<span className="bg-gray5 bg-gray1-d db w-100 h-100 db" style={{height: `1em`, width: `${(index % 5) * 20}%`}}></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
@ -39,6 +39,7 @@ type ChatWindowProps = RouteComponentProps<{
|
||||
group: Group;
|
||||
ship: Patp;
|
||||
station: any;
|
||||
allStations: any;
|
||||
api: GlobalApi;
|
||||
hideNicknames: boolean;
|
||||
hideAvatars: boolean;
|
||||
@ -74,7 +75,6 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
this.handleWindowFocus = this.handleWindowFocus.bind(this);
|
||||
this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
|
||||
this.dismissIfLineVisible = this.dismissIfLineVisible.bind(this);
|
||||
this.lastRead = this.lastRead.bind(this);
|
||||
|
||||
this.virtualList = null;
|
||||
this.unreadMarkerRef = React.createRef();
|
||||
@ -124,10 +124,10 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: ChatWindowProps, prevState) {
|
||||
const { isChatMissing, history, envelopes, mailboxSize, stationPendingMessages, unreadCount } = this.props;
|
||||
const { isChatMissing, history, envelopes, mailboxSize, stationPendingMessages, unreadCount, station } = this.props;
|
||||
|
||||
if (isChatMissing) {
|
||||
history.push("/~chat");
|
||||
history.push("/~404");
|
||||
} else if (envelopes.length !== prevProps.envelopes.length && this.state.fetchPending) {
|
||||
this.setState({ fetchPending: false });
|
||||
}
|
||||
@ -150,6 +150,14 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
if (!this.state.fetchPending && prevState.fetchPending) {
|
||||
this.virtualList?.calculateVisibleItems();
|
||||
}
|
||||
|
||||
if (station !== prevProps.station) {
|
||||
this.virtualList?.resetScroll();
|
||||
this.scrollToUnread();
|
||||
this.setState({
|
||||
lastRead: unreadCount ? mailboxSize - unreadCount : Infinity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stayLockedIfActive() {
|
||||
@ -195,11 +203,6 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
});
|
||||
}
|
||||
|
||||
lastRead() {
|
||||
const { mailboxSize, unreadCount } = this.props;
|
||||
return mailboxSize - unreadCount;
|
||||
}
|
||||
|
||||
onScroll({ scrollTop, scrollHeight, windowHeight }) {
|
||||
if (!this.state.idle && scrollTop > IDLE_THRESHOLD) {
|
||||
this.setState({ idle: true });
|
||||
@ -240,6 +243,8 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
remoteContentPolicy,
|
||||
allStations,
|
||||
history
|
||||
} = this.props;
|
||||
|
||||
const unreadMarkerRef = this.unreadMarkerRef;
|
||||
@ -262,7 +267,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
lastMessage = mailboxSize + index;
|
||||
});
|
||||
|
||||
const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy, unreadMarkerRef };
|
||||
const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy, unreadMarkerRef, allStations, history, api };
|
||||
|
||||
return (
|
||||
<>
|
@ -11,7 +11,7 @@ export const BacklogElement = (props) => {
|
||||
"white-d flex items-center"
|
||||
}>
|
||||
<img className="invert-d spin-active v-mid"
|
||||
src="/~chat/img/Spinner.png"
|
||||
src="/~landscape/img/Spinner.png"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
@ -8,6 +8,8 @@ import 'codemirror/addon/display/placeholder';
|
||||
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
|
||||
import '../css/custom.css';
|
||||
|
||||
const BROWSER_REGEX =
|
||||
new RegExp(String(/Android|webOS|iPhone|iPad|iPod|BlackBerry/i));
|
||||
|
||||
@ -131,10 +133,10 @@ export default class ChatEditor extends Component {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'chat fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center' +
|
||||
'chat fr flex h-100 bg-gray0-d lh-copy w-100 items-center ' +
|
||||
(inCodeMode ? ' code' : '')
|
||||
}
|
||||
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}>
|
||||
style={{ flexGrow: 1, paddingTop: '3px', maxHeight: '224px', width: 'calc(100% - 88px)' }}>
|
||||
<CodeEditor
|
||||
value={message}
|
||||
options={options}
|
@ -1,194 +0,0 @@
|
||||
import React, { Component } from "react";
|
||||
import moment from "moment";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
|
||||
import { deSig } from "~/logic/lib/util";
|
||||
import { ChatHookUpdate } from "~/types/chat-hook-update";
|
||||
import { Inbox, Envelope } from "~/types/chat-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import { Path, Patp } from "~/types/noun";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import {Group} from "~/types/group-update";
|
||||
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||
import { SubmitDragger } from '~/views/components/s3-upload';
|
||||
|
||||
import ChatWindow from './lib/ChatWindow';
|
||||
import ChatHeader from './lib/ChatHeader';
|
||||
import ChatInput from "./lib/ChatInput";
|
||||
|
||||
|
||||
type ChatScreenProps = RouteComponentProps<{
|
||||
ship: Patp;
|
||||
station: string;
|
||||
}> & {
|
||||
chatSynced: ChatHookUpdate;
|
||||
station: any;
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
read: number;
|
||||
mailboxSize: number;
|
||||
inbox: Inbox;
|
||||
contacts: Contacts;
|
||||
group: Group;
|
||||
pendingMessages: Map<Path, Envelope[]>;
|
||||
s3: any;
|
||||
popout: boolean;
|
||||
sidebarShown: boolean;
|
||||
chatInitialized: boolean;
|
||||
envelopes: Envelope[];
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
};
|
||||
|
||||
interface ChatScreenState {
|
||||
messages: Map<string, string>;
|
||||
dragover: boolean;
|
||||
}
|
||||
|
||||
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||
private chatInput: React.RefObject<ChatInput>;
|
||||
lastNumPending = 0;
|
||||
activityTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
messages: new Map(),
|
||||
dragover: false,
|
||||
};
|
||||
|
||||
this.chatInput = React.createRef();
|
||||
|
||||
moment.updateLocale("en", {
|
||||
calendar: {
|
||||
sameDay: "[Today]",
|
||||
nextDay: "[Tomorrow]",
|
||||
nextWeek: "dddd",
|
||||
lastDay: "[Yesterday]",
|
||||
lastWeek: "[Last] dddd",
|
||||
sameElse: "DD/MM/YYYY",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
readyToUpload(): boolean {
|
||||
return Boolean(this.chatInput.current?.s3Uploader.current?.inputRef.current);
|
||||
}
|
||||
|
||||
onDragEnter(event) {
|
||||
if (!this.readyToUpload() || (!event.dataTransfer.files.length && !event.dataTransfer.types.includes('Files'))) {
|
||||
return;
|
||||
}
|
||||
this.setState({ dragover: true });
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent) {
|
||||
this.setState({ dragover: false });
|
||||
event.preventDefault();
|
||||
if (!event.dataTransfer || !event.dataTransfer.files.length) {
|
||||
return;
|
||||
}
|
||||
if (event.dataTransfer.items.length && !event.dataTransfer.files.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.chatInput.current?.uploadFiles(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const lastMsgNum = props.envelopes.length > 0 ? props.envelopes.length : 0;
|
||||
const ownerContact =
|
||||
window.ship in props.contacts ? props.contacts[window.ship] : false;
|
||||
|
||||
const pendingMessages = (props.pendingMessages.get(props.station) || [])
|
||||
.map((value) => ({
|
||||
...value,
|
||||
pending: true
|
||||
}));
|
||||
|
||||
const isChatMissing =
|
||||
props.chatInitialized &&
|
||||
!(props.station in props.inbox) &&
|
||||
props.chatSynced &&
|
||||
!(props.station in props.chatSynced);
|
||||
|
||||
const isChatLoading =
|
||||
props.chatInitialized &&
|
||||
!(props.station in props.inbox) &&
|
||||
props.chatSynced &&
|
||||
(props.station in props.chatSynced);
|
||||
|
||||
const isChatUnsynced =
|
||||
props.chatSynced &&
|
||||
!(props.station in props.chatSynced) &&
|
||||
props.envelopes.length > 0;
|
||||
|
||||
const unreadCount = props.mailboxSize - props.read;
|
||||
const unreadMsg = unreadCount > 0 && props.envelopes[unreadCount - 1];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={props.station}
|
||||
className="h-100 w-100 overflow-hidden flex flex-column relative"
|
||||
onDragEnter={this.onDragEnter.bind(this)}
|
||||
onDragOver={event => {
|
||||
event.preventDefault();
|
||||
if (
|
||||
!this.state.dragover
|
||||
&& (
|
||||
(event.dataTransfer.files.length && event.dataTransfer.files[0].kind === 'file')
|
||||
|| (event.dataTransfer.items.length && event.dataTransfer.items[0].kind === 'file')
|
||||
)
|
||||
) {
|
||||
this.setState({ dragover: true });
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
const over = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!over || !event.currentTarget.contains(over)) {
|
||||
this.setState({ dragover: false });
|
||||
}}
|
||||
}
|
||||
onDrop={this.onDrop.bind(this)}
|
||||
>
|
||||
{this.state.dragover ? <SubmitDragger /> : null}
|
||||
<ChatHeader {...props} />
|
||||
<ChatWindow
|
||||
isChatMissing={isChatMissing}
|
||||
isChatLoading={isChatLoading}
|
||||
isChatUnsynced={isChatUnsynced}
|
||||
unreadCount={unreadCount}
|
||||
unreadMsg={unreadMsg}
|
||||
stationPendingMessages={pendingMessages}
|
||||
ship={props.match.params.ship}
|
||||
{...props} />
|
||||
<ChatInput
|
||||
ref={this.chatInput}
|
||||
api={props.api}
|
||||
numMsgs={lastMsgNum}
|
||||
station={props.station}
|
||||
owner={deSig(props.match.params.ship)}
|
||||
ownerContact={ownerContact}
|
||||
envelopes={props.envelopes}
|
||||
contacts={props.contacts}
|
||||
onUnmount={(msg: string) => this.setState({
|
||||
messages: this.state.messages.set(props.station, msg)
|
||||
})}
|
||||
s3={props.s3}
|
||||
placeholder="Message..."
|
||||
message={this.state.messages.get(props.station) || ""}
|
||||
deleteMessage={() => this.setState({
|
||||
messages: this.state.messages.set(props.station, "")
|
||||
})}
|
||||
hideAvatars={props.hideAvatars}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -11,14 +11,14 @@ export default class CodeContent extends Component {
|
||||
(Boolean(content.code.output) &&
|
||||
content.code.output.length && content.code.output.length > 0) ?
|
||||
(
|
||||
<pre className={`code f7 clamp-attachment pa1 mt0 mb0`}>
|
||||
<pre className={`code f9 clamp-attachment pa1 mt0 mb0`}>
|
||||
{content.code.output[0].join('\n')}
|
||||
</pre>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="mv2">
|
||||
<pre className={`code f7 clamp-attachment pa1 mt0 mb0`}>
|
||||
<pre className={`code f9 clamp-attachment pa1 mt0 mb0`}>
|
||||
{content.code.expression}
|
||||
</pre>
|
||||
{outputElement}
|
@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
import { Box, Text } from '@tlon/indigo-react';
|
||||
|
||||
const DISABLED_BLOCK_TOKENS = [
|
||||
'indentedCode',
|
||||
@ -39,7 +39,7 @@ const MessageMarkdown = React.memo(props => (
|
||||
node.children[0].children[0].value = '>' + node.children[0].children[0].value;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}}
|
||||
plugins={[[
|
||||
@ -63,17 +63,19 @@ export default class TextContent extends Component {
|
||||
&& (urbitOb.isValidPatp(group[2]) // valid patp?
|
||||
&& (group[0] === content.text))) { // entire message is room name?
|
||||
return (
|
||||
<Link
|
||||
className="bb b--black b--white-d f7 mono lh-copy v-top"
|
||||
to={'/~groups/join/' + group.input}>
|
||||
{content.text}
|
||||
</Link>
|
||||
<Text fontSize={0} color='gray' lineHeight="tall">
|
||||
<Link
|
||||
className="bb b--black b--white-d mono"
|
||||
to={'/~landscape/join/' + group.input}>
|
||||
{content.text}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box style={{ overflowWrap: 'break-word' }}>
|
||||
<Text color='gray' fontSize={0} lineHeight="tall" style={{ overflowWrap: 'break-word' }}>
|
||||
<MessageMarkdown source={content.text} />
|
||||
</Box>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,109 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Spinner } from '../../../components/Spinner';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { Box, Text, ManagedTextInputField as Input, Button } from '@tlon/indigo-react';
|
||||
import { Formik, Form } from 'formik'
|
||||
import * as Yup from 'yup';
|
||||
|
||||
|
||||
const schema = Yup.object().shape({
|
||||
station: Yup.string()
|
||||
.lowercase()
|
||||
.trim()
|
||||
.test('is-station',
|
||||
'Chat must have a valid name',
|
||||
(val) =>
|
||||
val &&
|
||||
val.split('/').length === 2 &&
|
||||
urbitOb.isValidPatp(val.split('/')[0])
|
||||
)
|
||||
.required('Required')
|
||||
});
|
||||
|
||||
|
||||
export class JoinScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
awaiting: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.station) {
|
||||
this.onSubmit({ station: this.props.station });
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(values) {
|
||||
const { props } = this;
|
||||
this.setState({ awaiting: true }, () => {
|
||||
const station = values.station.trim();
|
||||
if (`/${station}` in props.chatSynced) {
|
||||
if (props.station) {
|
||||
props.history.replace(`/~chat/room${station}`);
|
||||
} else {
|
||||
props.history.push(`/~chat/room${station}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const ship = station.substr(1).slice(0,station.substr(1).indexOf('/'));
|
||||
|
||||
props.api.chat.join(ship, station, true).then(() => {
|
||||
if (props.station) {
|
||||
props.history.replace(`/~chat/room${station}`);
|
||||
} else {
|
||||
props.history.push(`/~chat/room${station}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
return (
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{ station: props.station }}
|
||||
validationSchema={schema}
|
||||
onSubmit={this.onSubmit.bind(this)}>
|
||||
<Form>
|
||||
<Box width="100%" height="100%" p={3} overflowX="hidden">
|
||||
<Box
|
||||
width="100%"
|
||||
pt={1} pb={5}
|
||||
display={['', 'none', 'none', 'none']}
|
||||
fontSize={0}>
|
||||
<Link to="/~chat/">{'⟵ All Chats'}</Link>
|
||||
</Box>
|
||||
<Text mb={3} fontSize={0}>Join Existing Chat</Text>
|
||||
<Box width="100%" maxWidth={350}>
|
||||
<Box mt={3} mb={3} display="block">
|
||||
<Text display="inline" fontSize={0}>
|
||||
Enter a{' '}
|
||||
</Text>
|
||||
<Text display="inline" fontSize={0} fontFamily="mono">
|
||||
~ship/chat-name
|
||||
</Text>
|
||||
</Box>
|
||||
<Input
|
||||
mt={4}
|
||||
id="station"
|
||||
placeholder="~zod/chatroom"
|
||||
fontFamily="mono"
|
||||
caption="Chat names use lowercase, hyphens, and slashes." />
|
||||
<Button>Join Chat</Button>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="mt4"
|
||||
text="Joining chat..." />
|
||||
</Box>
|
||||
</Box>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,63 +0,0 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { TabBar } from '~/views/components/chat-link-tabbar';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { deSig } from '~/logic/lib/util';
|
||||
|
||||
const ChatHeader = (props) => {
|
||||
const isInPopout = props.popout ? 'popout/' : '';
|
||||
const group = Array.from(props.group.members);
|
||||
let title = props.station.substr(1);
|
||||
if (props.association &&
|
||||
'metadata' in props.association &&
|
||||
props.association.metadata.tile !== '') {
|
||||
title = props.association.metadata.title;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
|
||||
style={{ height: '1rem' }}
|
||||
>
|
||||
<Link to="/~chat/">{'⟵ All Chats'}</Link>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative ' +
|
||||
'overflow-x-auto overflow-y-hidden flex-shrink-0 '
|
||||
}
|
||||
style={{ height: 48 }}
|
||||
>
|
||||
<SidebarSwitcher
|
||||
sidebarShown={props.sidebarShown}
|
||||
popout={props.popout}
|
||||
api={props.api}
|
||||
/>
|
||||
<Link
|
||||
to={'/~chat/' + isInPopout + 'room' + props.station}
|
||||
className="pt2 white-d"
|
||||
>
|
||||
<h2
|
||||
className={
|
||||
'dib f9 fw4 lh-solid v-top ' +
|
||||
(title === props.station.substr(1) ? 'mono' : '')
|
||||
}
|
||||
style={{ width: 'max-content' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</Link>
|
||||
<TabBar
|
||||
location={props.location}
|
||||
popoutHref={`/~chat/popout/room${props.station}`}
|
||||
settings={`/~chat/${isInPopout}settings${props.station}`}
|
||||
popout={props.popout}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatHeader;
|
@ -1,37 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class ChannelItem extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const { props } = this;
|
||||
props.history.push('/~chat/room' + props.box);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const unreadElem = props.unread ? 'fw6 white-d' : '';
|
||||
|
||||
const title = props.title;
|
||||
|
||||
const selectedCss = props.selected
|
||||
? 'bg-gray4 bg-gray1-d gray3-d c-default'
|
||||
: 'bg-white bg-gray0-d gray3-d hover-bg-gray5 hover-bg-gray1-d pointer';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'z1 ph5 pv1 ' + selectedCss}
|
||||
onClick={this.onClick.bind(this)}
|
||||
>
|
||||
<div className="w-100 v-mid">
|
||||
<p className={'dib f9 ' + unreadElem}>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
export const DeleteButton = memo(({ isOwner, station, changeLoading, association, contacts, api, history }) => {
|
||||
const leaveButtonClasses = (!isOwner) ? 'pointer' : 'c-default';
|
||||
const deleteButtonClasses = (isOwner) ?
|
||||
'b--red2 red2 pointer bg-gray0-d' :
|
||||
'b--gray3 gray3 bg-gray0-d c-default';
|
||||
|
||||
const deleteChat = () => {
|
||||
changeLoading(
|
||||
true,
|
||||
true,
|
||||
isOwner ? 'Deleting chat...' : 'Leaving chat...',
|
||||
() => {
|
||||
api.chat.delete(station).then(() => {
|
||||
history.push("/~chat");
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const groupPath = association['group-path'];
|
||||
const unmanagedVillage = !contacts[groupPath];
|
||||
|
||||
return (
|
||||
<div className="w-100 cf">
|
||||
<div className={'w-100 fl mt3 ' + ((isOwner) ? 'o-30' : '')}>
|
||||
<p className="f8 mt3 lh-copy db">Leave Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Remove this chat from your chat list.{' '}
|
||||
{unmanagedVillage
|
||||
? 'You will need to request for access again'
|
||||
: 'You will need to join again from the group page.'
|
||||
}
|
||||
</p>
|
||||
<a onClick={(!isOwner) ? deleteChat : null}
|
||||
className={
|
||||
'dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d ' +
|
||||
leaveButtonClasses
|
||||
}>
|
||||
Leave this chat
|
||||
</a>
|
||||
</div>
|
||||
<div className={'w-100 fl mt3 ' + ((!isOwner) ? 'o-30' : '')}>
|
||||
<p className="f8 mt3 lh-copy db">Delete Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Permanently delete this chat.{' '}
|
||||
All current members will no longer see this chat.
|
||||
</p>
|
||||
<a onClick={(isOwner) ? deleteChat : null}
|
||||
className={'dib f9 ba pa2 ' + deleteButtonClasses}
|
||||
>Delete this chat</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
@ -1,100 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChannelItem } from './channel-item';
|
||||
import { deSig, cite } from "~/logic/lib/util";
|
||||
|
||||
export class GroupItem extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
const association = props.association ? props.association : {};
|
||||
const DEFAULT_TITLE_REGEX = new RegExp(`(( <-> )?~(?:${window.ship}|${deSig(cite(window.ship))})( <-> )?)`);
|
||||
|
||||
let title = association['app-path'] ? association['app-path'] : 'Direct Messages';
|
||||
if (association.metadata && association.metadata.title) {
|
||||
title = association.metadata.title !== ''
|
||||
? association.metadata.title
|
||||
: title;
|
||||
}
|
||||
|
||||
const channels = props.channels ? props.channels : [];
|
||||
const first = (props.index === 0) ? 'mt1 ' : 'mt6 ';
|
||||
|
||||
const channelItems = channels.sort((a, b) => {
|
||||
if (props.index === 'dm') {
|
||||
const aPreview = props.messagePreviews[a];
|
||||
const bPreview = props.messagePreviews[b];
|
||||
const aWhen = aPreview ? aPreview.when : 0;
|
||||
const bWhen = bPreview ? bPreview.when : 0;
|
||||
|
||||
return bWhen - aWhen;
|
||||
} else {
|
||||
const aAssociation = a in props.chatMetadata ? props.chatMetadata[a] : {};
|
||||
const bAssociation = b in props.chatMetadata ? props.chatMetadata[b] : {};
|
||||
let aTitle = a;
|
||||
let bTitle = b;
|
||||
if (aAssociation.metadata && aAssociation.metadata.title) {
|
||||
aTitle = (aAssociation.metadata.title !== '')
|
||||
? aAssociation.metadata.title : a;
|
||||
}
|
||||
if (bAssociation.metadata && bAssociation.metadata.title) {
|
||||
bTitle =
|
||||
bAssociation.metadata.title !== '' ? bAssociation.metadata.title : b;
|
||||
}
|
||||
return aTitle.toLowerCase().localeCompare(bTitle.toLowerCase());
|
||||
}
|
||||
}).map((each, i) => {
|
||||
const unread = props.unreads[each];
|
||||
let title = each.substr(1);
|
||||
if (
|
||||
each in props.chatMetadata &&
|
||||
props.chatMetadata[each].metadata
|
||||
) {
|
||||
if (props.chatMetadata[each].metadata.title) {
|
||||
title = props.chatMetadata[each].metadata.title
|
||||
}
|
||||
}
|
||||
|
||||
if (DEFAULT_TITLE_REGEX.test(title) && props.index === "dm") {
|
||||
title = title.replace(DEFAULT_TITLE_REGEX, '');
|
||||
}
|
||||
const selected = props.station === each;
|
||||
|
||||
return (
|
||||
<ChannelItem
|
||||
key={i}
|
||||
unread={unread}
|
||||
title={title}
|
||||
selected={selected}
|
||||
box={each}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
if (channelItems.length === 0) {
|
||||
channelItems.push(<p className="gray2 mt4 f9 tc">No direct messages</p>);
|
||||
}
|
||||
|
||||
let dmLink = <div />;
|
||||
|
||||
if (props.index === 'dm') {
|
||||
dmLink = <Link
|
||||
key="link"
|
||||
className="absolute right-0 f9 top-0 mr4 green2 bg-gray5 bg-gray1-d b--transparent br1"
|
||||
to="/~chat/new/dm"
|
||||
style={{ padding: '0rem 0.2rem' }}
|
||||
>
|
||||
+ DM
|
||||
</Link>;
|
||||
}
|
||||
return (
|
||||
<div className={first + 'relative'}>
|
||||
<p className="f9 ph4 gray3" key="p">{title}</p>
|
||||
{dmLink}
|
||||
{channelItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupItem;
|
@ -1,104 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import Toggle from '~/views/components/toggle';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
|
||||
|
||||
export class GroupifyButton extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
inclusive: false,
|
||||
targetGroup: null
|
||||
};
|
||||
}
|
||||
|
||||
changeTargetGroup(target) {
|
||||
if (target.groups.length === 1) {
|
||||
this.setState({ targetGroup: target.groups[0] });
|
||||
} else {
|
||||
this.setState({ targetGroup: null });
|
||||
}
|
||||
}
|
||||
|
||||
changeInclusive(event) {
|
||||
this.setState({ inclusive: Boolean(event.target.checked) });
|
||||
}
|
||||
|
||||
renderInclusiveToggle() {
|
||||
return this.state.targetGroup ? (
|
||||
<div className="mt4">
|
||||
<Toggle
|
||||
boolean={inclusive}
|
||||
change={this.changeInclusive.bind(this)}
|
||||
/>
|
||||
<span className="dib f9 white-d inter ml3">
|
||||
Add all members to group
|
||||
</span>
|
||||
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
|
||||
Add chat members to the group if they aren't in it yet
|
||||
</p>
|
||||
</div>
|
||||
) : <div />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { inclusive, targetGroup } = this.state;
|
||||
const {
|
||||
api,
|
||||
isOwner,
|
||||
association,
|
||||
associations,
|
||||
contacts,
|
||||
groups,
|
||||
station
|
||||
} = this.props;
|
||||
|
||||
const groupPath = association['group-path'];
|
||||
const ownedUnmanagedVillage =
|
||||
isOwner &&
|
||||
!contacts[groupPath];
|
||||
|
||||
if (!ownedUnmanagedVillage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'w-100 fl mt3'} style={{ maxWidth: '29rem' }}>
|
||||
<p className="f8 mt3 lh-copy db">Convert Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Convert this chat into a group with associated chat, or select a
|
||||
group to add this chat to.
|
||||
</p>
|
||||
<InviteSearch
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
associations={associations}
|
||||
groupResults={true}
|
||||
shipResults={false}
|
||||
invites={{
|
||||
groups: targetGroup ? [targetGroup] : [],
|
||||
ships: []
|
||||
}}
|
||||
setInvite={this.changeTargetGroup.bind(this)}
|
||||
/>
|
||||
{this.renderInclusiveToggle()}
|
||||
<a onClick={() => {
|
||||
changeLoading(true, true, 'Converting to group...', () => {
|
||||
api.chat.groupify(
|
||||
station, targetGroup, inclusive
|
||||
).then(() => {
|
||||
changeLoading(false, false, '', () => {});
|
||||
});
|
||||
});
|
||||
}}
|
||||
className={
|
||||
'dib f9 black gray4-d bg-gray0-d ba pa2 mt4 b--black ' +
|
||||
'b--gray1-d pointer'
|
||||
}>Convert to group</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,84 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
|
||||
export class InviteElement extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
members: [],
|
||||
error: false,
|
||||
success: false,
|
||||
awaiting: false
|
||||
};
|
||||
this.setInvite = this.setInvite.bind(this);
|
||||
}
|
||||
|
||||
modifyMembers() {
|
||||
const { props, state } = this;
|
||||
|
||||
const aud = state.members.map(mem => `~${mem}`);
|
||||
|
||||
if (state.members.length === 0) {
|
||||
this.setState({
|
||||
error: true,
|
||||
success: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error: false,
|
||||
success: true,
|
||||
members: [],
|
||||
awaiting: true
|
||||
}, () => {
|
||||
props.api.chatView.invite(props.path, aud).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setInvite(invite) {
|
||||
this.setState({ members: invite.ships });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
let modifyButtonClasses = 'mt4 db f9 ba pa2 white-d bg-gray0-d b--black b--gray2-d pointer';
|
||||
if (state.error) {
|
||||
modifyButtonClasses = modifyButtonClasses + ' gray3';
|
||||
}
|
||||
|
||||
let buttonText = '';
|
||||
if (props.permissions.kind === 'black') {
|
||||
buttonText = 'Ban';
|
||||
} else if (props.permissions.kind === 'white') {
|
||||
buttonText = 'Invite';
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InviteSearch
|
||||
groups={{}}
|
||||
contacts={props.contacts}
|
||||
groupResults={false}
|
||||
shipResults={true}
|
||||
invites={{
|
||||
groups: [],
|
||||
ships: this.state.members
|
||||
}}
|
||||
setInvite={this.setInvite}
|
||||
/>
|
||||
<button
|
||||
onClick={this.modifyMembers.bind(this)}
|
||||
className={modifyButtonClasses}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
<Spinner awaiting={this.state.awaiting} classes="mt4" text="Inviting to chat..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Sigil } from '../../../../lib/sigil';
|
||||
import { uxToHex, cite } from '../../../../lib/util';
|
||||
|
||||
export class MemberElement extends Component {
|
||||
onRemove() {
|
||||
const { props } = this;
|
||||
props.api.groups.remove([`~${props.ship}`], props.path);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
let actionElem;
|
||||
if (props.ship === props.owner) {
|
||||
actionElem = (
|
||||
<p className="w-20 dib list-ship black white-d f8 c-default">
|
||||
Host
|
||||
</p>
|
||||
);
|
||||
} else if (window.ship !== props.ship && window.ship === props.owner) {
|
||||
actionElem = (
|
||||
<a onClick={this.onRemove.bind(this)}
|
||||
className="w-20 dib list-ship black white-d f8 pointer"
|
||||
>
|
||||
Ban
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
actionElem = (
|
||||
<span></span>
|
||||
);
|
||||
}
|
||||
|
||||
const name = props.contact
|
||||
? `${props.contact.nickname} (${cite(props.ship)})` : `${cite(props.ship)}`;
|
||||
const color = props.contact ? uxToHex(props.contact.color) : '000000';
|
||||
|
||||
const img = (props.contact && (props.contact.avatar !== null))
|
||||
? <img src={props.contact.avatar} height={32} width={32} className="dib" />
|
||||
: <Sigil ship={props.ship} size={32} color={`#${color}`} />;
|
||||
|
||||
return (
|
||||
<div className="flex mb2">
|
||||
{img}
|
||||
<p className={
|
||||
'w-70 mono list-ship dib v-mid black white-d ml2 nowrap f8'
|
||||
}
|
||||
>{name}</p>
|
||||
{actionElem}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,371 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import _ from 'lodash';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import Mousetrap from 'mousetrap';
|
||||
|
||||
import cn from 'classnames';
|
||||
import { Sigil } from '../../../../lib/sigil';
|
||||
import { hexToRgba, uxToHex, deSig } from '../../../../lib/util';
|
||||
|
||||
function ShipSearchItem({ ship, contacts, selected, onSelect }) {
|
||||
const contact = contacts[ship];
|
||||
let color = '#000000';
|
||||
let sigilClass = 'v-mid mix-blend-diff';
|
||||
let nickname;
|
||||
const nameStyle = {};
|
||||
const isSelected = ship === selected;
|
||||
if (contact) {
|
||||
const hex = uxToHex(contact.color);
|
||||
color = `#${hex}`;
|
||||
nameStyle.color = hexToRgba(hex, 0.7);
|
||||
nameStyle.textShadow = '0px 0px 0px #000';
|
||||
nameStyle.filter = 'contrast(1.3) saturate(1.5)';
|
||||
nameStyle.maxWidth = '200px';
|
||||
sigilClass = 'v-mid';
|
||||
nickname = contact.nickname;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(ship)}
|
||||
className={cn(
|
||||
'f9 pv1 ph3 pointer hover-bg-gray1-d hover-bg-gray4 relative flex items-center',
|
||||
{
|
||||
'white-d bg-gray0-d bg-white': !isSelected,
|
||||
'black-d bg-gray1-d bg-gray4': isSelected
|
||||
}
|
||||
)}
|
||||
key={ship}
|
||||
>
|
||||
<Sigil ship={'~' + ship} size={24} color={color} classes={sigilClass} />
|
||||
{nickname && (
|
||||
<p style={nameStyle} className="dib ml4 b truncate">
|
||||
{nickname}
|
||||
</p>
|
||||
)}
|
||||
<div className="mono gray2 gray4-d ml4">{'~' + ship}</div>
|
||||
<p className="nowrap ml4">{status}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export class ShipSearch extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
selected: null,
|
||||
suggestions: [],
|
||||
bound: false
|
||||
};
|
||||
|
||||
this.keymap = {
|
||||
Tab: cm =>
|
||||
this.nextAutocompleteSuggestion(),
|
||||
'Shift-Tab': cm =>
|
||||
this.nextAutocompleteSuggestion(true),
|
||||
'Up': cm =>
|
||||
this.nextAutocompleteSuggestion(true),
|
||||
'Escape': cm =>
|
||||
this.props.onClear(),
|
||||
'Down': cm =>
|
||||
this.nextAutocompleteSuggestion(),
|
||||
'Enter': (cm) => {
|
||||
if(this.props.searchTerm !== null) {
|
||||
this.props.onSelect(this.state.selected);
|
||||
}
|
||||
},
|
||||
'Shift-3': cm =>
|
||||
this.toggleCode()
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if(this.props.searchTerm !== null) {
|
||||
this.updateSuggestions(true);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
|
||||
if(!state.bound && props.inputRef) {
|
||||
this.bindShortcuts();
|
||||
}
|
||||
|
||||
if(props.searchTerm === null) {
|
||||
if(state.suggestions.length > 0) {
|
||||
this.setState({ suggestions: [] });
|
||||
}
|
||||
this.unbindShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
props.searchTerm === null &&
|
||||
props.searchTerm !== prevProps.searchTerm &&
|
||||
props.searchTerm.startsWith(prevProps.searchTerm)
|
||||
) {
|
||||
this.updateSuggestions();
|
||||
} else if (prevProps.searchTerm !== props.searchTerm) {
|
||||
this.updateSuggestions(true);
|
||||
}
|
||||
}
|
||||
|
||||
updateSuggestions(isStale = false) {
|
||||
const needle = this.props.searchTerm;
|
||||
const matchString = (hay) => {
|
||||
hay = hay.toLowerCase();
|
||||
|
||||
return (
|
||||
hay.startsWith(needle) ||
|
||||
_.some(_.words(hay), s => s.startsWith(needle))
|
||||
);
|
||||
};
|
||||
|
||||
let candidates = this.state.suggestions;
|
||||
|
||||
if (isStale || this.state.suggestions.length === 0) {
|
||||
const contacts = _.chain(this.props.contacts)
|
||||
.defaultTo({})
|
||||
.map((details, ship) => ({ ...details, ship }))
|
||||
.filter(
|
||||
({ nickname, ship }) => matchString(nickname) || matchString(ship)
|
||||
)
|
||||
.map('ship')
|
||||
.value();
|
||||
|
||||
const exactMatch = urbitOb.isValidPatp(`~${needle}`) ? [needle] : [];
|
||||
|
||||
candidates = _.chain(this.props.candidates)
|
||||
.defaultTo([])
|
||||
.union(contacts)
|
||||
.union(exactMatch)
|
||||
.value();
|
||||
}
|
||||
|
||||
const suggestions = _.chain(candidates)
|
||||
.filter(matchString)
|
||||
.filter(s => s.length < 28) // exclude comets
|
||||
.value();
|
||||
|
||||
this.bindShortcuts();
|
||||
this.setState({ suggestions, selected: suggestions[0] });
|
||||
}
|
||||
|
||||
bindCmShortcuts() {
|
||||
if(!this.props.cm) {
|
||||
return;
|
||||
}
|
||||
this.props.cm.addKeyMap(this.keymap);
|
||||
}
|
||||
|
||||
unbindCmShortcuts() {
|
||||
if(!this.props.cm) {
|
||||
return;
|
||||
}
|
||||
this.props.cm.removeKeyMap(this.keymap);
|
||||
}
|
||||
|
||||
bindShortcuts() {
|
||||
if (this.state.bound) {
|
||||
return;
|
||||
}
|
||||
if (!this.props.inputRef) {
|
||||
return this.bindCmShortcuts();
|
||||
}
|
||||
this.setState({ bound: true });
|
||||
if (!this.mousetrap) {
|
||||
this.mousetrap = new Mousetrap(this.props.inputRef);
|
||||
}
|
||||
|
||||
this.mousetrap.bind('enter', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.state.selected) {
|
||||
this.unbindShortcuts();
|
||||
this.props.onSelect(this.state.selected);
|
||||
}
|
||||
});
|
||||
|
||||
this.mousetrap.bind('tab', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.nextAutocompleteSuggestion(false);
|
||||
});
|
||||
this.mousetrap.bind(['up', 'shift+tab'], (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.nextAutocompleteSuggestion(true);
|
||||
});
|
||||
this.mousetrap.bind('down', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.nextAutocompleteSuggestion(false);
|
||||
});
|
||||
this.mousetrap.bind('esc', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onClear();
|
||||
});
|
||||
}
|
||||
|
||||
unbindShortcuts() {
|
||||
if(!this.props.inputRef) {
|
||||
this.unbindCmShortcuts();
|
||||
}
|
||||
|
||||
if (!this.state.bound) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ bound: false });
|
||||
this.mousetrap.unbind('enter');
|
||||
this.mousetrap.unbind('tab');
|
||||
this.mousetrap.unbind(['up', 'shift+tab']);
|
||||
this.mousetrap.unbind('down');
|
||||
this.mousetrap.unbind('esc');
|
||||
}
|
||||
|
||||
nextAutocompleteSuggestion(backward = false) {
|
||||
const { suggestions } = this.state;
|
||||
let idx = suggestions.findIndex(s => s === this.state.selected);
|
||||
|
||||
idx = backward ? idx - 1 : idx + 1;
|
||||
idx = idx % Math.min(suggestions.length, 5);
|
||||
if (idx < 0) {
|
||||
idx = suggestions.length - 1;
|
||||
}
|
||||
|
||||
this.setState({ selected: suggestions[idx] });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onSelect, contacts, popover, className } = this.props;
|
||||
const { selected, suggestions } = this.state;
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const popoverClasses = (popover && ' absolute ') || ' ';
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
popover
|
||||
? {
|
||||
bottom: '90%',
|
||||
left: '48px'
|
||||
}
|
||||
: {}
|
||||
}
|
||||
className={
|
||||
'black white-d bg-white bg-gray0-d ' +
|
||||
'w7 pv3 z-1 mt1 ba b--gray1-d b--gray4' +
|
||||
popoverClasses +
|
||||
className || ''
|
||||
}
|
||||
>
|
||||
{suggestions.slice(0, 5).map(ship => (
|
||||
<ShipSearchItem
|
||||
onSelect={onSelect}
|
||||
key={ship}
|
||||
selected={selected}
|
||||
contacts={contacts}
|
||||
ship={ship}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ShipSearchInput extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
searchTerm: ''
|
||||
};
|
||||
|
||||
this.inputRef = null;
|
||||
this.popoverRef = null;
|
||||
|
||||
this.search = this.search.bind(this);
|
||||
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.setInputRef = this.setInputRef.bind(this);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
const { popoverRef } = this;
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
if (!popoverRef || popoverRef.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onClear();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('mousedown', this.onClick);
|
||||
document.addEventListener('touchstart', this.onClick);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mousedown', this.onClick);
|
||||
document.removeEventListener('touchstart', this.onClick);
|
||||
}
|
||||
|
||||
setInputRef(ref) {
|
||||
this.inputRef = ref;
|
||||
if(ref) {
|
||||
ref.focus();
|
||||
}
|
||||
// update this.inputRef prop
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
search(e) {
|
||||
const searchTerm = e.target.value;
|
||||
this.setState({ searchTerm });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { state, props } = this;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref => (this.popoverRef = ref)}
|
||||
style={{ top: '150%', left: '-80px' }}
|
||||
className="b--gray2 b--solid ba absolute bg-white bg-gray0-d"
|
||||
>
|
||||
<textarea
|
||||
style={{ resize: 'none', maxWidth: '200px' }}
|
||||
className="ma2 pa2 b--gray4 ba b--solid w7 db bg-gray0-d white-d"
|
||||
rows={1}
|
||||
autocapitalise="none"
|
||||
autoFocus={
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(
|
||||
navigator.userAgent
|
||||
)
|
||||
? false
|
||||
: true
|
||||
}
|
||||
placeholder="Search for a ship"
|
||||
value={state.searchTerm}
|
||||
onChange={this.search}
|
||||
ref={this.setInputRef}
|
||||
/>
|
||||
<ShipSearch
|
||||
contacts={props.contacts}
|
||||
candidates={props.candidates}
|
||||
searchTerm={deSig(state.searchTerm)}
|
||||
inputRef={this.inputRef}
|
||||
onSelect={props.onSelect}
|
||||
onClear={props.onClear}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
export const UnreadNotice = (props) => {
|
||||
const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
|
||||
|
||||
if (!unreadMsg || (unreadCount === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let datestamp = moment.unix(unreadMsg.when / 1000).format('YYYY.M.D');
|
||||
const timestamp = moment.unix(unreadMsg.when / 1000).format('HH:mm');
|
||||
|
||||
if (datestamp === moment().format('YYYY.M.D')) {
|
||||
datestamp = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ left: '0px' }}
|
||||
className="pa4 w-100 absolute z-1 unread-notice">
|
||||
<div className={
|
||||
"ba b--green2 green2 bg-white bg-gray0-d flex items-center " +
|
||||
"pa2 f9 justify-between br1"
|
||||
}>
|
||||
<p className="lh-copy db pointer" onClick={onClick}>
|
||||
{unreadCount} new messages since{' '}
|
||||
{datestamp && (
|
||||
<>
|
||||
<span className="green3">~{datestamp}</span> at{' '}
|
||||
</>
|
||||
)}
|
||||
<span className="green3">{timestamp}</span>
|
||||
</p>
|
||||
<div onClick={dismissUnread}
|
||||
className="ml4 inter b--green2 pointer tr lh-copy">
|
||||
Mark as Read
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class Welcome extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
show: true
|
||||
};
|
||||
this.disableWelcome = this.disableWelcome.bind(this);
|
||||
}
|
||||
|
||||
disableWelcome() {
|
||||
this.setState({ show: false });
|
||||
localStorage.setItem('urbit-chat:wasWelcomed', JSON.stringify(true));
|
||||
}
|
||||
|
||||
render() {
|
||||
let wasWelcomed = localStorage.getItem('urbit-chat:wasWelcomed');
|
||||
if (wasWelcomed === null) {
|
||||
localStorage.setItem('urbit-chat:wasWelcomed', JSON.stringify(false));
|
||||
wasWelcomed = false;
|
||||
return wasWelcomed;
|
||||
} else {
|
||||
wasWelcomed = JSON.parse(wasWelcomed);
|
||||
}
|
||||
|
||||
const inbox = this.props.inbox ? this.props.inbox : {};
|
||||
|
||||
return ((!wasWelcomed && this.state.show) && (inbox.length !== 0)) ? (
|
||||
<div className="ma4 pa2 bg-welcome-green bg-gray1-d white-d">
|
||||
<p className="f8 lh-copy">Chats are instant, linear modes of conversation. Many chats can be bundled under one group.</p>
|
||||
<p className="f8 pt2 dib pointer bb"
|
||||
onClick={(() => this.disableWelcome())}
|
||||
>
|
||||
Close this
|
||||
</p>
|
||||
</div>
|
||||
) : <div />;
|
||||
}
|
||||
}
|
||||
|
||||
export default Welcome;
|
@ -1,237 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { deSig, cite } from '~/logic/lib/util';
|
||||
|
||||
export class NewDmScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
ships: [],
|
||||
station: null,
|
||||
awaiting: false,
|
||||
title: '',
|
||||
idName: '',
|
||||
description: ''
|
||||
};
|
||||
|
||||
this.titleChange = this.titleChange.bind(this);
|
||||
this.descriptionChange = this.descriptionChange.bind(this);
|
||||
this.onClickCreate = this.onClickCreate.bind(this);
|
||||
this.setInvite = this.setInvite.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { props } = this;
|
||||
if (props.autoCreate && urbitOb.isValidPatp(props.autoCreate)) {
|
||||
const addedShip = this.state.ships;
|
||||
addedShip.push(props.autoCreate.slice(1));
|
||||
this.setState(
|
||||
{
|
||||
ships: addedShip,
|
||||
awaiting: true
|
||||
},
|
||||
this.onClickCreate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props } = this;
|
||||
|
||||
if (prevProps !== props) {
|
||||
const { station } = this.state;
|
||||
if (station && station in props.inbox) {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push(`/~chat/room${station}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
titleChange(event) {
|
||||
const asciiSafe = event.target.value.toLowerCase()
|
||||
.replace(/[^a-z0-9~_.-]/g, '-');
|
||||
this.setState({
|
||||
idName: asciiSafe,
|
||||
title: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
descriptionChange(event) {
|
||||
this.setState({
|
||||
description: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
setInvite(value) {
|
||||
this.setState({
|
||||
ships: value.ships
|
||||
});
|
||||
}
|
||||
|
||||
onClickCreate() {
|
||||
const { props, state } = this;
|
||||
|
||||
if (state.ships.length === 1) {
|
||||
const station = `/~${window.ship}/dm--${state.ships[0]}`;
|
||||
|
||||
const theirStation = `/~${state.ships[0]}/dm--${window.ship}`;
|
||||
|
||||
if (station in props.inbox) {
|
||||
props.history.push(`/~chat/room${station}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (theirStation in props.inbox) {
|
||||
props.history.push(`/~chat/room${theirStation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const aud = state.ship !== window.ship ? [`~${state.ships[0]}`] : [];
|
||||
|
||||
let title = `${cite(window.ship)} <-> ${cite(state.ships[0])}`;
|
||||
|
||||
if (state.title !== '') {
|
||||
title = state.title;
|
||||
}
|
||||
this.setState(
|
||||
{
|
||||
station, awaiting: true
|
||||
},
|
||||
() => {
|
||||
const groupPath = `/ship/~${window.ship}/dm--${state.ships[0]}`;
|
||||
props.api.chat.create(
|
||||
title,
|
||||
state.description,
|
||||
station,
|
||||
groupPath,
|
||||
{ invite: { pending: aud } },
|
||||
aud,
|
||||
true,
|
||||
false
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (state.ships.length > 1) {
|
||||
const aud = state.ships.map(mem => `~${deSig(mem.trim())}`);
|
||||
|
||||
let title = 'Direct Message';
|
||||
|
||||
if (state.title !== '') {
|
||||
title = state.title;
|
||||
} else {
|
||||
const asciiSafe = title.toLowerCase()
|
||||
.replace(/[^a-z0-9~_.-]/g, '-');
|
||||
this.setState({ idName: asciiSafe });
|
||||
}
|
||||
|
||||
const station = `/~${window.ship}/${state.idName}-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
this.setState(
|
||||
{
|
||||
station, awaiting: true
|
||||
},
|
||||
() => {
|
||||
const groupPath = `/ship${station}`;
|
||||
props.api.chat.create(
|
||||
title,
|
||||
state.description,
|
||||
station,
|
||||
groupPath,
|
||||
{ invite: { pending: aud } },
|
||||
aud,
|
||||
true,
|
||||
false
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const createClasses = (state.idName || state.ships.length >= 1)
|
||||
? 'pointer dib f9 green2 bg-gray0-d ba pv3 ph4 b--green2 mt4'
|
||||
: 'pointer dib f9 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3 mt4';
|
||||
|
||||
const idClasses =
|
||||
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 ' +
|
||||
'focus-b--black focus-b--white-d mt1 ';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'h-100 w-100 mw6 pa3 pt4 overflow-x-hidden ' +
|
||||
'bg-gray0-d white-d flex flex-column'
|
||||
}
|
||||
>
|
||||
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
|
||||
<Link to="/~chat/">{'⟵ All Chats'}</Link>
|
||||
</div>
|
||||
<h2 className="mb3 f8">New Direct Message</h2>
|
||||
<div className="w-100">
|
||||
<p className="f8 mt4 db">
|
||||
Name
|
||||
<span className="gray3"> (Optional)</span>
|
||||
</p>
|
||||
<textarea
|
||||
className={idClasses}
|
||||
placeholder="The Passage"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.titleChange}
|
||||
/>
|
||||
<p className="f8 mt4 db">
|
||||
Description
|
||||
<span className="gray3"> (Optional)</span>
|
||||
</p>
|
||||
<textarea
|
||||
className={idClasses}
|
||||
placeholder="The most beautiful direct message"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.descriptionChange}
|
||||
/>
|
||||
<p className="f8 mt4 db">
|
||||
Invite Members
|
||||
</p>
|
||||
<p className="f9 gray2 db mv1">
|
||||
Selected ships will be invited to the direct message
|
||||
</p>
|
||||
<InviteSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
associations={props.associations}
|
||||
groupResults={false}
|
||||
shipResults={true}
|
||||
invites={{
|
||||
groups: [],
|
||||
ships: state.ships
|
||||
}}
|
||||
setInvite={this.setInvite}
|
||||
/>
|
||||
<button
|
||||
onClick={this.onClickCreate.bind(this)}
|
||||
className={createClasses}
|
||||
>
|
||||
Create Direct Message
|
||||
</button>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="mt4"
|
||||
text="Creating Direct Message..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,207 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { deSig } from '~/logic/lib/util';
|
||||
|
||||
export class NewScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
title: '',
|
||||
description: '',
|
||||
idName: '',
|
||||
groups: [],
|
||||
ships: [],
|
||||
privacy: 'invite',
|
||||
idError: false,
|
||||
allowHistory: true,
|
||||
createGroup: false,
|
||||
awaiting: false
|
||||
};
|
||||
|
||||
this.titleChange = this.titleChange.bind(this);
|
||||
this.descriptionChange = this.descriptionChange.bind(this);
|
||||
this.setInvite = this.setInvite.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { props, state } = this;
|
||||
|
||||
if (prevProps !== props) {
|
||||
const station = `/~${window.ship}/${state.idName}`;
|
||||
if (station in props.inbox) {
|
||||
props.history.push('/~chat/room' + station);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
titleChange(event) {
|
||||
const asciiSafe = event.target.value.toLowerCase()
|
||||
.replace(/[^a-z0-9~_.-]/g, '-');
|
||||
this.setState({
|
||||
idName: asciiSafe,
|
||||
title: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
descriptionChange(event) {
|
||||
this.setState({
|
||||
description: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
setInvite(value) {
|
||||
this.setState({
|
||||
groups: value.groups,
|
||||
ships: value.ships
|
||||
});
|
||||
}
|
||||
|
||||
onClickCreate() {
|
||||
const { props, state } = this;
|
||||
const grouped = (this.state.createGroup || (this.state.groups.length > 0));
|
||||
|
||||
if (!state.title) {
|
||||
this.setState({
|
||||
idError: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const station = `/${state.idName}` + (grouped ? `-${Math.floor(Math.random() * 10000)}` : '');
|
||||
|
||||
if (station in props.inbox) {
|
||||
this.setState({
|
||||
idError: true,
|
||||
success: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const aud = state.ships.map(mem => `~${deSig(mem.trim())}`);
|
||||
|
||||
if (this.textarea) {
|
||||
this.textarea.value = '';
|
||||
}
|
||||
|
||||
const policy = state.privacy === 'invite' ? { invite: { pending: aud } } : { open: { banRanks: [], banned: [] } };
|
||||
|
||||
this.setState({
|
||||
error: false,
|
||||
success: true,
|
||||
group: [],
|
||||
ships: [],
|
||||
awaiting: true
|
||||
}, () => {
|
||||
const appPath = `/~${window.ship}${station}`;
|
||||
let groupPath = `/ship${appPath}`;
|
||||
if (state.groups.length > 0) {
|
||||
groupPath = state.groups[0];
|
||||
}
|
||||
const submit = props.api.chat.create(
|
||||
state.title,
|
||||
state.description,
|
||||
appPath,
|
||||
groupPath,
|
||||
policy,
|
||||
aud,
|
||||
state.allowHistory,
|
||||
state.createGroup
|
||||
);
|
||||
submit.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push(`/~chat/room${appPath}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const createClasses = state.idName
|
||||
? 'pointer db f9 green2 bg-gray0-d ba pv3 ph4 b--green2 mt4'
|
||||
: 'pointer db f9 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3 mt4';
|
||||
|
||||
const idClasses =
|
||||
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 ' +
|
||||
'focus-b--black focus-b--white-d mt1 ';
|
||||
|
||||
let idErrElem = (<span />);
|
||||
if (state.idError) {
|
||||
idErrElem = (
|
||||
<span className="f9 inter red2 db pt2">
|
||||
Chat must have a valid name.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'h-100 w-100 mw6 pa3 pt4 overflow-x-hidden ' +
|
||||
'bg-gray0-d white-d flex flex-column'
|
||||
}
|
||||
>
|
||||
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
|
||||
<Link to="/~chat/">{'⟵ All Chats'}</Link>
|
||||
</div>
|
||||
<h2 className="mb4 f8">New Group Chat</h2>
|
||||
<div className="w-100">
|
||||
<p className="f8 mt4 db">Name</p>
|
||||
<textarea
|
||||
className={idClasses}
|
||||
placeholder="Secret Chat"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.titleChange}
|
||||
/>
|
||||
{idErrElem}
|
||||
<p className="f8 mt4 db">
|
||||
Description
|
||||
<span className="gray3"> (Optional)</span>
|
||||
</p>
|
||||
<textarea
|
||||
className={idClasses}
|
||||
placeholder="The coolest chat"
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.descriptionChange}
|
||||
/>
|
||||
<div className="mt4 db relative">
|
||||
<p className="f8">
|
||||
Select Group
|
||||
</p>
|
||||
<Link className="green2 absolute right-0 bottom-0 f9" to="/~groups/new">+New</Link>
|
||||
<p className="f9 gray2 db mv1">
|
||||
Chat will be added to selected group
|
||||
</p>
|
||||
</div>
|
||||
<InviteSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
associations={props.associations}
|
||||
groupResults={true}
|
||||
shipResults={false}
|
||||
invites={{
|
||||
groups: state.groups,
|
||||
ships: []
|
||||
}}
|
||||
setInvite={this.setInvite}
|
||||
/>
|
||||
<button
|
||||
onClick={this.onClickCreate.bind(this)}
|
||||
className={createClasses}
|
||||
>
|
||||
Start Chat
|
||||
</button>
|
||||
<Spinner awaiting={this.state.awaiting} classes="mt4" text="Creating chat..." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -55,13 +55,13 @@ export class OverlaySigil extends PureComponent {
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
const { hideAvatars } = props;
|
||||
const { hideAvatars, allStations } = props;
|
||||
|
||||
const img = (props.contact && (props.contact.avatar !== null) && !hideAvatars)
|
||||
? <img src={props.contact.avatar} height={24} width={24} className="dib" />
|
||||
? <img src={props.contact.avatar} height={16} width={16} className="dib" />
|
||||
: <Sigil
|
||||
ship={props.ship}
|
||||
size={24}
|
||||
size={16}
|
||||
color={props.color}
|
||||
classes={props.sigilClass}
|
||||
/>;
|
||||
@ -71,7 +71,6 @@ export class OverlaySigil extends PureComponent {
|
||||
onClick={this.profileShow}
|
||||
className={props.className + ' pointer relative'}
|
||||
ref={this.containerRef}
|
||||
style={{ height: '24px' }}
|
||||
>
|
||||
{state.profileClicked && (
|
||||
<ProfileOverlay
|
||||
@ -83,8 +82,11 @@ export class OverlaySigil extends PureComponent {
|
||||
association={props.association}
|
||||
group={props.group}
|
||||
onDismiss={this.profileHide}
|
||||
allStations={allStations}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
history={props.history}
|
||||
api={props.api}
|
||||
/>
|
||||
)}
|
||||
{img}
|
@ -3,6 +3,8 @@ import { Link } from 'react-router-dom';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
|
||||
import { Center, Button } from "@tlon/indigo-react";
|
||||
|
||||
export const OVERLAY_HEIGHT = 250;
|
||||
|
||||
export class ProfileOverlay extends PureComponent {
|
||||
@ -11,6 +13,7 @@ export class ProfileOverlay extends PureComponent {
|
||||
|
||||
this.popoverRef = React.createRef();
|
||||
this.onDocumentClick = this.onDocumentClick.bind(this);
|
||||
this.createAndRedirectToDM = this.createAndRedirectToDM.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -23,6 +26,42 @@ export class ProfileOverlay extends PureComponent {
|
||||
document.removeEventListener('touchstart', this.onDocumentClick);
|
||||
}
|
||||
|
||||
createAndRedirectToDM() {
|
||||
const { api, ship, history, allStations } = this.props;
|
||||
const station = `/~${window.ship}/dm--${ship}`;
|
||||
const theirStation = `/~${ship}/dm--${window.ship}`;
|
||||
|
||||
if (allStations.indexOf(station) !== -1) {
|
||||
history.push(`/~landscape/home/resource/chat${station}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allStations.indexOf(theirStation) !== -1) {
|
||||
history.push(`/~landscape/home/resource/chat${theirStation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupPath = `/ship/~${window.ship}/dm--${ship}`;
|
||||
const aud = ship !== window.ship ? [`~${ship}`] : [];
|
||||
const title = `${cite(window.ship)} <-> ${cite(ship)}`;
|
||||
|
||||
api.chat.create(
|
||||
title,
|
||||
'',
|
||||
station,
|
||||
groupPath,
|
||||
{ invite: { pending: aud } },
|
||||
aud,
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
// TODO: make a pretty loading state
|
||||
setTimeout(() => {
|
||||
history.push(`/~landscape/home/resource/chat${station}`);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
onDocumentClick(event) {
|
||||
const { popoverRef } = this;
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
@ -34,7 +73,7 @@ export class ProfileOverlay extends PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { contact, ship, color, topSpace, bottomSpace, group, association, hideNicknames, hideAvatars } = this.props;
|
||||
const { contact, ship, color, topSpace, bottomSpace, group, association, hideNicknames, hideAvatars, history } = this.props;
|
||||
|
||||
let top, bottom;
|
||||
if (topSpace < OVERLAY_HEIGHT / 2) {
|
||||
@ -50,10 +89,6 @@ export class ProfileOverlay extends PureComponent {
|
||||
|
||||
const isOwn = window.ship === ship;
|
||||
|
||||
const identityHref = group.hidden
|
||||
? '/~profile/identity'
|
||||
: `/~groups/view${association['group-path']}/${window.ship}`;
|
||||
|
||||
let img = contact?.avatar && !hideAvatars
|
||||
? <img src={contact.avatar} height={160} width={160} className="brt2 dib" />
|
||||
: <Sigil
|
||||
@ -65,9 +100,11 @@ export class ProfileOverlay extends PureComponent {
|
||||
/>;
|
||||
const showNickname = contact?.nickname && !hideNicknames;
|
||||
|
||||
if (!group.hidden) {
|
||||
img = <Link to={`/~groups/view${association['group-path']}/${ship}`}>{img}</Link>;
|
||||
}
|
||||
// TODO: we need to rethink this "top-level profile view" of other ships
|
||||
/*if (!group.hidden) {
|
||||
}*/
|
||||
|
||||
const isHidden = group.hidden;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -76,29 +113,28 @@ export class ProfileOverlay extends PureComponent {
|
||||
className="flex-col shadow-6 br2 bg-white bg-gray0-d inter absolute z-1 f9 lh-solid"
|
||||
>
|
||||
<div style={{ height: '160px', width: '160px' }}>
|
||||
{img}
|
||||
{img}
|
||||
</div>
|
||||
<div className="pv3 pl3 pr2">
|
||||
<div className="pv3 pl3 pr3">
|
||||
{showNickname && (
|
||||
<div className="b white-d truncate">{contact.nickname}</div>
|
||||
)}
|
||||
<div className="mono gray2">{cite(`~${ship}`)}</div>
|
||||
{!isOwn && (
|
||||
<Link
|
||||
to={`/~chat/new/dm/~${ship}`}
|
||||
className="b--green0 b--green2-d b--solid ba green2 mt3 tc pa2 pointer db"
|
||||
>
|
||||
<Button mt={2} width="100%" style={{ cursor: 'pointer' }} onClick={this.createAndRedirectToDM}>
|
||||
Send Message
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{isOwn && (
|
||||
<Link
|
||||
to={identityHref}
|
||||
className="b--black b--white-d ba black white-d mt3 tc pa2 pointer db"
|
||||
{(isOwn) ? (
|
||||
<Button
|
||||
mt='2'
|
||||
width='100%'
|
||||
style={{ cursor: 'pointer '}}
|
||||
onClick={() => (isHidden) ? history.push('/~profile/identity') : history.push(`${history.location.pathname}/popover/profile`)}
|
||||
>
|
||||
Edit Group Identity
|
||||
</Link>
|
||||
)}
|
||||
Edit Identity
|
||||
</Button>
|
||||
) : <div />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
@ -1,149 +0,0 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
|
||||
import { deSig } from '~/logic/lib/util';
|
||||
import { MetadataSettings } from '~/views/components/metadata/settings';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
|
||||
import ChatHeader from './lib/ChatHeader';
|
||||
import { DeleteButton } from './lib/delete-button';
|
||||
import { GroupifyButton } from './lib/groupify-button';
|
||||
|
||||
export class SettingsScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isLoading: false,
|
||||
awaiting: false,
|
||||
type: 'Editing chat...'
|
||||
};
|
||||
|
||||
this.changeLoading = this.changeLoading.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.state.isLoading && (this.props.station in this.props.inbox)) {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
if (state.isLoading && !(props.station in props.inbox)) {
|
||||
this.setState({
|
||||
isLoading: false
|
||||
}, () => {
|
||||
props.history.push('/~chat');
|
||||
});
|
||||
} else if (state.isLoading && (props.station in props.inbox)) {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
changeLoading(isLoading, awaiting, type, closure) {
|
||||
this.setState({
|
||||
isLoading,
|
||||
awaiting,
|
||||
type
|
||||
}, closure);
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
return (
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="absolute right-2 bottom-2 ba pa2 b--gray1-d"
|
||||
text={this.state.type}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderNormal() {
|
||||
const { state } = this;
|
||||
const {
|
||||
associations,
|
||||
association,
|
||||
contacts,
|
||||
groups,
|
||||
api,
|
||||
station,
|
||||
match,
|
||||
history
|
||||
} = this.props;
|
||||
const isOwner = deSig(match.params.ship) === window.ship;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<h2 className="f8 pb2">Chat Settings</h2>
|
||||
<GroupifyButton
|
||||
isOwner={isOwner}
|
||||
association={association}
|
||||
associations={associations}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
api={api}
|
||||
changeLoading={this.changeLoading} />
|
||||
<DeleteButton
|
||||
isOwner={isOwner}
|
||||
changeLoading={this.changeLoading}
|
||||
station={station}
|
||||
association={association}
|
||||
contacts={contacts}
|
||||
history={history}
|
||||
api={api} />
|
||||
<MetadataSettings
|
||||
isOwner={isOwner}
|
||||
changeLoading={this.changeLoading}
|
||||
api={api}
|
||||
association={association}
|
||||
resource="chat"
|
||||
app="chat"
|
||||
module=""
|
||||
/>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="absolute right-2 bottom-2 ba pa2 b--gray1-d"
|
||||
text={this.state.type}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { state } = this;
|
||||
const {
|
||||
api,
|
||||
group,
|
||||
association,
|
||||
station,
|
||||
popout,
|
||||
sidebarShown,
|
||||
match,
|
||||
location
|
||||
} = this.props;
|
||||
|
||||
const isInPopout = popout ? "popout/" : "";
|
||||
const title =
|
||||
( association &&
|
||||
('metadata' in association) &&
|
||||
(association.metadata.title !== '')
|
||||
) ? association.metadata.title : station.substr(1);
|
||||
|
||||
return (
|
||||
<div className="h-100 w-100 overflow-x-hidden flex flex-column white-d">
|
||||
<ChatHeader
|
||||
match={match}
|
||||
location={location}
|
||||
api={api}
|
||||
group={group}
|
||||
association={association}
|
||||
station={station}
|
||||
sidebarShown={sidebarShown}
|
||||
popout={popout} />
|
||||
<div className="w-100 pl3 mt4 cf">
|
||||
{(state.isLoading) ? this.renderLoading() : this.renderNormal() }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import Welcome from './lib/welcome';
|
||||
import { alphabetiseAssociations } from '~/logic/lib/util';
|
||||
import SidebarInvite from '~/views/components/SidebarInvite';
|
||||
import { GroupItem } from './lib/group-item';
|
||||
|
||||
export class Sidebar extends Component {
|
||||
onClickNew() {
|
||||
this.props.history.push('/~chat/new');
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const contactAssoc =
|
||||
(props.associations && 'contacts' in props.associations)
|
||||
? alphabetiseAssociations(props.associations.contacts) : {};
|
||||
|
||||
const chatAssoc =
|
||||
(props.associations && 'chat' in props.associations)
|
||||
? alphabetiseAssociations(props.associations.chat) : {};
|
||||
|
||||
const groupedChannels = {};
|
||||
Object.keys(props.inbox).map((box) => {
|
||||
const path = chatAssoc[box]
|
||||
? chatAssoc[box]['group-path'] : box;
|
||||
|
||||
if (path in contactAssoc) {
|
||||
if (groupedChannels[path]) {
|
||||
const array = groupedChannels[path];
|
||||
array.push(box);
|
||||
groupedChannels[path] = array;
|
||||
} else {
|
||||
groupedChannels[path] = [box];
|
||||
}
|
||||
} else {
|
||||
if (groupedChannels['dm']) {
|
||||
const array = groupedChannels['dm'];
|
||||
array.push(box);
|
||||
groupedChannels['dm'] = array;
|
||||
} else {
|
||||
groupedChannels['dm'] = [box];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sidebarInvites = Object.keys(props.invites)
|
||||
.map((uid) => {
|
||||
return (
|
||||
<SidebarInvite
|
||||
key={uid}
|
||||
invite={props.invites[uid]}
|
||||
onAccept={() => props.api.invite.accept('/chat', uid)}
|
||||
onDecline={() => props.api.invite.decline('/chat', uid)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const groupedItems = Object.keys(contactAssoc)
|
||||
.filter(each => (groupedChannels[each] || []).length !== 0)
|
||||
.map((each, i) => {
|
||||
const channels = groupedChannels[each] || [];
|
||||
return(
|
||||
<GroupItem
|
||||
key={i}
|
||||
index={i}
|
||||
association={contactAssoc[each]}
|
||||
chatMetadata={chatAssoc}
|
||||
channels={channels}
|
||||
inbox={props.inbox}
|
||||
station={props.station}
|
||||
unreads={props.unreads}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
// add direct messages after groups
|
||||
groupedItems.push(
|
||||
<GroupItem
|
||||
association={'dm'}
|
||||
chatMetadata={chatAssoc}
|
||||
channels={groupedChannels['dm']}
|
||||
inbox={props.inbox}
|
||||
station={props.station}
|
||||
unreads={props.unreads}
|
||||
index={'dm'}
|
||||
key={'dm'}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-100-minus-96-s h-100 w-100 overflow-x-hidden flex
|
||||
bg-gray0-d flex-column relative z1 lh-solid`}
|
||||
>
|
||||
<div className="w-100 bg-transparent pa4">
|
||||
<a
|
||||
className="dib f9 pointer green2 gray4-d mr4"
|
||||
onClick={this.onClickNew.bind(this)}
|
||||
>
|
||||
New Group Chat
|
||||
</a>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-100">
|
||||
<Welcome inbox={props.inbox} />
|
||||
{sidebarInvites}
|
||||
{groupedItems}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ErrorBoundary from '~/views/components/ErrorBoundary';
|
||||
|
||||
export class Skeleton extends Component {
|
||||
render() {
|
||||
// sidebar and chat panel conditional classes
|
||||
const sidebarHide = (!this.props.sidebarShown || this.props.popout)
|
||||
? 'dn' : '';
|
||||
|
||||
const sidebarHideOnMobile = this.props.sidebarHideOnMobile
|
||||
? 'dn-s' : '';
|
||||
|
||||
const chatHideOnMobile = this.props.chatHideonMobile
|
||||
? 'dn-s' : '';
|
||||
|
||||
// mobile-specific navigation classes
|
||||
const mobileNavClasses = classnames({
|
||||
'dn': this.props.chatHideOnMobile,
|
||||
'db dn-m dn-l dn-xl': !this.props.chatHideOnMobile,
|
||||
'w-100 inter pt4 f8': !this.props.chatHideOnMobile
|
||||
});
|
||||
|
||||
// popout switches out window chrome and borders
|
||||
const popoutWindow = this.props.popout
|
||||
? '' : 'ph4-m ph4-l ph4-xl pb4-m pb4-l pb4-xl';
|
||||
|
||||
const popoutBorder = this.props.popout
|
||||
? '' : 'ba-m ba-l ba-xl b--gray4 b--gray1-d br1 ';
|
||||
|
||||
return (
|
||||
// app outer skeleton
|
||||
<div className={'h-100 w-100 ' + popoutWindow}>
|
||||
{/* app window borders */}
|
||||
<div className={ 'bg-white bg-gray0-d cf w-100 flex h-100 ' + popoutBorder }>
|
||||
{/* sidebar skeleton, hidden on mobile when in chat panel */}
|
||||
<div
|
||||
className={
|
||||
`fl h-100 br b--gray4 b--gray1-d overflow-x-hidden
|
||||
flex-basis-full-s flex-basis-250-m flex-basis-250-l
|
||||
flex-basis-250-xl ` +
|
||||
sidebarHide +
|
||||
' ' +
|
||||
sidebarHideOnMobile
|
||||
}
|
||||
>
|
||||
{/* mobile-specific navigation */}
|
||||
<div className={mobileNavClasses}>
|
||||
<div className="bb b--gray4 b--gray1-d white-d inter f8 pl3 pb3">
|
||||
All Chats
|
||||
</div>
|
||||
</div>
|
||||
{/* sidebar component inside the sidebar skeleton */}
|
||||
{this.props.sidebar}
|
||||
</div>
|
||||
{/* right-hand panel for chat, members, settings */}
|
||||
<div
|
||||
className={'h-100 fr ' + chatHideOnMobile}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
width: 'calc(100% - 300px)'
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
{this.props.children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { Box, Text } from '@tlon/indigo-react';
|
||||
|
||||
export const UnreadNotice = (props) => {
|
||||
const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
|
||||
|
||||
if (!unreadMsg || (unreadCount === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let datestamp = moment.unix(unreadMsg.when / 1000).format('YYYY.M.D');
|
||||
const timestamp = moment.unix(unreadMsg.when / 1000).format('HH:mm');
|
||||
|
||||
if (datestamp === moment().format('YYYY.M.D')) {
|
||||
datestamp = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ left: '0px' }}
|
||||
p='4'
|
||||
width='100%'
|
||||
position='absolute'
|
||||
zIndex='1'
|
||||
className='unread-notice'
|
||||
>
|
||||
<Box
|
||||
backgroundColor='white'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
p='2'
|
||||
fontSize='0'
|
||||
justifyContent='space-between'
|
||||
borderRadius='1'
|
||||
border='1'
|
||||
borderColor='blue'>
|
||||
<Text flexShrink='0' display='block' cursor='pointer' onClick={onClick}>
|
||||
{unreadCount} new messages since{' '}
|
||||
{datestamp && (
|
||||
<>
|
||||
<Text color='blue'>~{datestamp}</Text> at{' '}
|
||||
</>
|
||||
)}
|
||||
<Text color='blue'>{timestamp}</Text>
|
||||
</Text>
|
||||
<Text
|
||||
ml='4'
|
||||
color='blue'
|
||||
cursor='pointer'
|
||||
textAlign='right'
|
||||
onClick={dismissUnread}>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
html, body {
|
||||
@ -8,14 +9,16 @@ html, body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
p, h1, h2, h3, h4, h5, h6, a, input, textarea, button {
|
||||
@ -161,7 +164,7 @@ h2 {
|
||||
}
|
||||
|
||||
.unread-notice {
|
||||
top: 48px;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
/* responsive */
|
||||
@ -225,12 +228,17 @@ blockquote {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
cursor: text;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat .CodeMirror * {
|
||||
font-family: 'Inter';
|
||||
}
|
||||
|
||||
.chat .cm-s-tlon.CodeMirror {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
@ -378,8 +386,8 @@ pre.CodeMirror-placeholder.CodeMirror-line-like { color: var(--gray); }
|
||||
|
||||
/* codemirror */
|
||||
.chat .cm-s-tlon.CodeMirror {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat .cm-s-tlon span.cm-def {
|
||||
|
@ -1,9 +1,7 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Route, Link } from 'react-router-dom';
|
||||
import classnames from 'classnames';
|
||||
import { Route } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { Popout } from './components/lib/icons/popout';
|
||||
import { History } from './components/history';
|
||||
import { Input } from './components/input';
|
||||
|
||||
@ -54,16 +52,8 @@ export default class DojoApp extends Component {
|
||||
>
|
||||
<Route
|
||||
exact
|
||||
path="/~dojo/:popout?"
|
||||
path="/~dojo/"
|
||||
render={(props) => {
|
||||
const popout = Boolean(props.match.params.popout);
|
||||
|
||||
const popoutClasses = classnames({
|
||||
'mh4-m mh4-l mh4-xl': !popout,
|
||||
'mb4-m mb4-l mb4-xl': !popout,
|
||||
'ba-m ba-l ba-xl': !popout
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-100 h-100 flex-m flex-l flex-xl">
|
||||
<div
|
||||
@ -75,14 +65,13 @@ export default class DojoApp extends Component {
|
||||
className={
|
||||
'pa3 bg-white bg-gray0-d black white-d mono w-100 f8 relative' +
|
||||
' h-100-m40-s b--gray2 br1 flex-auto flex flex-column ' +
|
||||
popoutClasses
|
||||
'mh4-m mh4-l mh4-xl mb4-m mb4-l mb4-xl ba-m ba-l ba-xl'
|
||||
}
|
||||
style={{
|
||||
lineHeight: '1.4',
|
||||
cursor: 'text'
|
||||
}}
|
||||
>
|
||||
<Popout popout={popout} />
|
||||
<History commandLog={this.state.txt} />
|
||||
<Input
|
||||
ship={this.props.ship}
|
||||
|
@ -1,29 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class Popout extends Component {
|
||||
render() {
|
||||
const hidePopoutIcon = this.props.popout
|
||||
? 'dn-m dn-l dn-xl'
|
||||
: 'dib-m dib-l dib-xl';
|
||||
return (
|
||||
<div
|
||||
className="db tr z-2"
|
||||
style={{
|
||||
right: 16,
|
||||
top: 16
|
||||
}}
|
||||
>
|
||||
<a href="/~dojo/popout" target="_blank">
|
||||
<img
|
||||
className={'flex-shrink-0 dn ' + hidePopoutIcon}
|
||||
src="/~dojo/img/popout.png"
|
||||
height="16"
|
||||
width="16"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Popout;
|
@ -1,341 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import './css/custom.css';
|
||||
|
||||
import { Skeleton } from './components/skeleton';
|
||||
import { NewScreen } from './components/new';
|
||||
import { ContactSidebar } from './components/lib/contact-sidebar';
|
||||
import { ContactCard } from './components/lib/contact-card';
|
||||
import { AddScreen } from './components/lib/add-contact';
|
||||
import { JoinScreen } from './components/join';
|
||||
import GroupDetail from './components/lib/group-detail';
|
||||
|
||||
import { PatpNoSig } from '~/types/noun';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { StoreState } from '~/logic/store/type';
|
||||
import GlobalSubscription from '~/logic/subscription/global';
|
||||
|
||||
|
||||
type GroupsAppProps = StoreState & {
|
||||
ship: PatpNoSig;
|
||||
api: GlobalApi;
|
||||
subscription: GlobalSubscription;
|
||||
}
|
||||
|
||||
export default class GroupsApp extends Component<GroupsAppProps, {}> {
|
||||
componentDidMount() {
|
||||
// preload spinner asset
|
||||
new Image().src = '/~landscape/img/Spinner.png';
|
||||
|
||||
this.props.subscription.startApp('groups')
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.subscription.stopApp('groups')
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const contacts = props.contacts || {};
|
||||
const defaultContacts =
|
||||
(Boolean(props.contacts) && '/~/default' in props.contacts) ?
|
||||
props.contacts['/~/default'] : {};
|
||||
|
||||
const invites =
|
||||
(Boolean(props.invites) && '/contacts' in props.invites) ?
|
||||
props.invites['/contacts'] : {};
|
||||
const s3 = props.s3 ? props.s3 : {};
|
||||
const groups = props.groups || {};
|
||||
const associations = props.associations || {};
|
||||
const { api } = props;
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>OS1 - Groups</title>
|
||||
</Helmet>
|
||||
<Switch>
|
||||
<Route exact path="/~groups"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
activeDrawer="groups"
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
invites={invites}
|
||||
associations={associations}
|
||||
>
|
||||
<div className="h-100 w-100 overflow-x-hidden bg-white bg-gray0-d dn db-ns">
|
||||
<div className="pl3 pr3 pt2 dt pb3 w-100 h-100">
|
||||
<p className="f9 pt3 gray2 w-100 h-100 dtc v-mid tc">
|
||||
Select a group to begin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~groups/new"
|
||||
render={(props) => {
|
||||
return (
|
||||
<Skeleton
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
invites={invites}
|
||||
associations={associations}
|
||||
activeDrawer="rightPanel"
|
||||
>
|
||||
<NewScreen
|
||||
history={props.history}
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~groups/join/:ship?/:name?"
|
||||
render={(props) => {
|
||||
const ship = props.match.params.ship || '';
|
||||
const name = props.match.params.name || '';
|
||||
return (
|
||||
<Skeleton
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
invites={invites}
|
||||
associations={associations}
|
||||
activeDrawer="rightPanel"
|
||||
>
|
||||
<JoinScreen
|
||||
history={props.history}
|
||||
groups={groups}
|
||||
contacts={contacts}
|
||||
api={api}
|
||||
ship={ship}
|
||||
name={name}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~groups/(detail)?/(settings)?/ship/:ship/:group/"
|
||||
render={(props) => {
|
||||
const groupPath =
|
||||
`/ship/${props.match.params.ship}/${props.match.params.group}`;
|
||||
const groupContacts = contacts[groupPath] || {};
|
||||
const group = groups[groupPath] ;
|
||||
const detail = Boolean(props.match.url.includes('/detail'));
|
||||
const settings = Boolean(props.match.url.includes('/settings'));
|
||||
|
||||
const association = (associations.contacts?.[groupPath])
|
||||
? associations.contacts[groupPath]
|
||||
: {};
|
||||
if(!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
invites={invites}
|
||||
groups={groups}
|
||||
activeDrawer={(detail || settings) ? 'detail' : 'contacts'}
|
||||
selected={groupPath}
|
||||
associations={associations}
|
||||
>
|
||||
<ContactSidebar
|
||||
contacts={groupContacts}
|
||||
defaultContacts={defaultContacts}
|
||||
group={group}
|
||||
activeDrawer={(detail || settings) ? 'detail' : 'contacts'}
|
||||
api={api}
|
||||
path={groupPath}
|
||||
groups={groups}
|
||||
{...props}
|
||||
/>
|
||||
<GroupDetail
|
||||
association={association}
|
||||
path={groupPath}
|
||||
group={group}
|
||||
groups={groups}
|
||||
activeDrawer={(detail || settings) ? 'detail' : 'contacts'}
|
||||
settings={settings}
|
||||
associations={associations}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~groups/add/ship/:ship/:group"
|
||||
render={(props) => {
|
||||
const groupPath =
|
||||
`/ship/${props.match.params.ship}/${props.match.params.group}`;
|
||||
const groupContacts = contacts[groupPath] || {};
|
||||
const group = groups[groupPath] || {};
|
||||
|
||||
if(!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
invites={invites}
|
||||
activeDrawer="rightPanel"
|
||||
selected={groupPath}
|
||||
associations={associations}
|
||||
>
|
||||
<ContactSidebar
|
||||
contacts={groupContacts}
|
||||
defaultContacts={defaultContacts}
|
||||
group={group}
|
||||
groups={groups}
|
||||
activeDrawer="rightPanel"
|
||||
path={groupPath}
|
||||
api={api}
|
||||
{...props}
|
||||
/>
|
||||
<AddScreen
|
||||
api={api}
|
||||
groups={groups}
|
||||
path={groupPath}
|
||||
history={props.history}
|
||||
contacts={contacts}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~groups/share/ship/:ship/:group"
|
||||
render={(props) => {
|
||||
const groupPath =
|
||||
`/ship/${props.match.params.ship}/${props.match.params.group}`;
|
||||
const shipPath = `${groupPath}/${window.ship}`;
|
||||
const rootIdentity = defaultContacts[window.ship] || {};
|
||||
|
||||
const groupContacts = contacts[groupPath] || {};
|
||||
const contact =
|
||||
(window.ship in groupContacts) ?
|
||||
groupContacts[window.ship] : {};
|
||||
const group = groups[groupPath];
|
||||
if(!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
invites={invites}
|
||||
activeDrawer="rightPanel"
|
||||
selected={groupPath}
|
||||
associations={associations}
|
||||
>
|
||||
<ContactSidebar
|
||||
activeDrawer="rightPanel"
|
||||
contacts={groupContacts}
|
||||
defaultContacts={defaultContacts}
|
||||
group={group}
|
||||
path={groupPath}
|
||||
api={api}
|
||||
selectedContact={shipPath}
|
||||
groups={groups}
|
||||
{...props}
|
||||
/>
|
||||
<ContactCard
|
||||
api={api}
|
||||
history={props.history}
|
||||
contact={contact}
|
||||
path={groupPath}
|
||||
ship={window.ship}
|
||||
share={true}
|
||||
rootIdentity={rootIdentity}
|
||||
s3={s3}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/~groups/view/ship/:ship/:group/:contact"
|
||||
render={(props) => {
|
||||
const groupPath =
|
||||
`/ship/${props.match.params.ship}/${props.match.params.group}`;
|
||||
const shipPath =
|
||||
`${groupPath}/${props.match.params.contact}`;
|
||||
|
||||
const groupContacts = contacts[groupPath] || {};
|
||||
const contact =
|
||||
(props.match.params.contact in groupContacts) ?
|
||||
groupContacts[props.match.params.contact] : {};
|
||||
const group = groups[groupPath] ;
|
||||
|
||||
const rootIdentity =
|
||||
props.match.params.contact === window.ship ?
|
||||
defaultContacts[window.ship] : null;
|
||||
if(!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
history={props.history}
|
||||
api={api}
|
||||
contacts={contacts}
|
||||
groups={groups}
|
||||
invites={invites}
|
||||
activeDrawer="rightPanel"
|
||||
selected={groupPath}
|
||||
associations={associations}
|
||||
>
|
||||
<ContactSidebar
|
||||
activeDrawer="rightPanel"
|
||||
contacts={groupContacts}
|
||||
defaultContacts={defaultContacts}
|
||||
group={group}
|
||||
path={groupPath}
|
||||
api={api}
|
||||
selectedContact={shipPath}
|
||||
groups={groups}
|
||||
{...props}
|
||||
/>
|
||||
<ContactCard
|
||||
api={api}
|
||||
history={props.history}
|
||||
contact={contact}
|
||||
path={groupPath}
|
||||
ship={props.match.params.contact}
|
||||
rootIdentity={rootIdentity}
|
||||
s3={s3}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,135 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import urbitOb from 'urbit-ob';
|
||||
|
||||
export class JoinScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
group: '',
|
||||
error: false,
|
||||
awaiting: null,
|
||||
disable: false
|
||||
};
|
||||
|
||||
this.groupChange = this.groupChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.componentDidUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { props, state } = this;
|
||||
// autojoin by URL, waits for group information
|
||||
if ((props.ship && props.name) &&
|
||||
(props.contacts && (Object.keys(props.contacts).length > 0) && !state.group)) {
|
||||
const incomingGroup = `${props.ship}/${props.name}`;
|
||||
// push to group if already exists
|
||||
if (`/ship/${incomingGroup}` in props.groups) {
|
||||
this.props.history.replace(`/~groups/ship/${incomingGroup}`);
|
||||
return;
|
||||
}
|
||||
this.setState({ group: incomingGroup }, () => {
|
||||
this.onClickJoin();
|
||||
});
|
||||
}
|
||||
// once we've joined, replace to group page
|
||||
if (props.groups) {
|
||||
if (state.awaiting) {
|
||||
const group = `/ship/${state.group}`;
|
||||
if (group in props.groups) {
|
||||
if (props.ship && props.name) {
|
||||
props.history.replace(`/~groups${group}`);
|
||||
} else {
|
||||
props.history.push(`/~groups${group}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClickJoin() {
|
||||
const { props, state } = this;
|
||||
|
||||
const { group } = state;
|
||||
const [ship, name] = group.split('/');
|
||||
|
||||
const text = 'Joining group';
|
||||
|
||||
this.props.api.contacts.join({ ship, name }).then(() => {
|
||||
this.setState({ awaiting: text });
|
||||
});
|
||||
}
|
||||
|
||||
groupChange(event) {
|
||||
const [ship, name] = event.target.value.split('/');
|
||||
const validGroup = urbitOb.isValidPatp(ship);
|
||||
this.setState({
|
||||
group: event.target.value,
|
||||
error: !validGroup
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { state } = this;
|
||||
|
||||
let joinClasses = 'db f9 green2 ba pa2 b--green2 bg-gray0-d pointer';
|
||||
|
||||
let errElem = (<span />);
|
||||
if (state.error) {
|
||||
joinClasses = 'db f9 gray2 ba pa2 b--gray3 bg-gray0-d';
|
||||
errElem = (
|
||||
<span className="f9 inter red2 db">
|
||||
Group must have a valid name.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'h-100 w-100 pt4 overflow-x-hidden flex flex-column ' +
|
||||
'bg-gray0-d white-d pa3'}
|
||||
>
|
||||
<div
|
||||
className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8"
|
||||
>
|
||||
<Link to="/~groups/">{'⟵ All Groups'}</Link>
|
||||
</div>
|
||||
<h2 className="mb3 f8">Join an Existing Group</h2>
|
||||
<div className="w-100">
|
||||
<p className="f8 lh-copy mt3 db">Enter a <span className="mono">~ship/group-name</span></p>
|
||||
<p className="f9 gray2 mb4">Group names use lowercase, hyphens, and slashes.</p>
|
||||
<textarea
|
||||
className={'f7 mono ba bg-gray0-d white-d pa3 mb2 db ' +
|
||||
'focus-b--black focus-b--white-d b--gray3 b--gray2-d nowrap overflow-y-hidden'}
|
||||
placeholder="~zod/group-name"
|
||||
spellCheck="false"
|
||||
rows={1}
|
||||
cols={32}
|
||||
autoFocus={true}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.onClickJoin();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
resize: 'none'
|
||||
}}
|
||||
onChange={this.groupChange}
|
||||
value={this.state.group}
|
||||
/>
|
||||
{errElem}
|
||||
<br />
|
||||
<button
|
||||
disabled={this.state.error}
|
||||
onClick={this.onClickJoin.bind(this)}
|
||||
className={joinClasses}
|
||||
>Join Group</button>
|
||||
<Spinner awaiting={this.state.awaiting} classes="mt4" text="Joining group..." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,122 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import _ from 'lodash';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { InviteSearch, Invites } from '~/views/components/InviteSearch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { uuid } from '../../../../lib/util';
|
||||
import { Groups } from '~/types/group-update';
|
||||
import { Rolodex } from '~/types/contact-update';
|
||||
import { Path } from '~/types/noun';
|
||||
import GlobalApi from '../../../../api/global';
|
||||
import { History } from 'history';
|
||||
|
||||
interface AddScreenState {
|
||||
invites: Invites;
|
||||
awaiting: boolean;
|
||||
}
|
||||
|
||||
interface AddScreenProps {
|
||||
path: Path;
|
||||
contacts: Rolodex;
|
||||
groups: Groups;
|
||||
api: GlobalApi;
|
||||
history: History;
|
||||
}
|
||||
|
||||
export class AddScreen extends Component<AddScreenProps, AddScreenState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
invites: {
|
||||
groups: [],
|
||||
ships: [],
|
||||
},
|
||||
awaiting: false,
|
||||
};
|
||||
|
||||
this.invChange = this.invChange.bind(this);
|
||||
}
|
||||
|
||||
invChange(value) {
|
||||
this.setState({
|
||||
invites: value,
|
||||
});
|
||||
}
|
||||
|
||||
onClickAdd() {
|
||||
const { props, state } = this;
|
||||
|
||||
let [, , ship, name] = props.path.split('/');
|
||||
const resource = { ship, name };
|
||||
|
||||
const aud = state.invites.ships.map((ship) => `~${ship}`);
|
||||
|
||||
this.setState(
|
||||
{
|
||||
invites: {
|
||||
groups: [],
|
||||
ships: [],
|
||||
},
|
||||
awaiting: true,
|
||||
},
|
||||
() => {
|
||||
const submit = aud.reduce(
|
||||
(acc, recipient) =>
|
||||
acc.then(() => {
|
||||
return props.api.contacts.invite(resource, recipient);
|
||||
}),
|
||||
Promise.resolve()
|
||||
);
|
||||
submit.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push('/~groups' + props.path);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
return (
|
||||
<div className='h-100 w-100 flex flex-column overflow-y-scroll white-d'>
|
||||
<div className='w-100 dn-m dn-l dn-xl inter pt1 pb6 pl3 pt3 f8'>
|
||||
<Link to={'/~groups' + props.path}>{'⟵ All Contacts'}</Link>
|
||||
</div>
|
||||
<div className='w-100 w-70-l w-70-xl mb4 pr6 pr0-l pr0-xl'>
|
||||
<h2 className='f8 pl4 pt4'>Add Group Members</h2>
|
||||
<p className='f9 pl4 gray2 lh-copy'>Invite ships to your group</p>
|
||||
<div className='relative pl4 mt2 pb6'>
|
||||
<InviteSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
groupResults={false}
|
||||
shipResults={true}
|
||||
invites={this.state.invites}
|
||||
setInvite={this.invChange}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={this.onClickAdd.bind(this)}
|
||||
className='ml4 f8 ba pa2 b--green2 green2 pointer bg-transparent'
|
||||
>
|
||||
Add Members
|
||||
</button>
|
||||
<Link to='/~groups'>
|
||||
<button className='f8 ml4 ba pa2 b--black pointer bg-transparent b--white-d white-d'>
|
||||
Cancel
|
||||
</button>
|
||||
</Link>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes='mt4 pl4'
|
||||
text='Inviting to group...'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddScreen;
|
@ -1,755 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { EditElement } from './edit-element';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { S3Upload } from '~/views/components/s3-upload';
|
||||
|
||||
export class ContactCard extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
edit: props.share,
|
||||
colorToSet: null,
|
||||
nickNameToSet: null,
|
||||
emailToSet: null,
|
||||
phoneToSet: null,
|
||||
websiteToSet: null,
|
||||
avatarToSet: null,
|
||||
notesToSet: null,
|
||||
awaiting: false,
|
||||
type: 'Saving to group'
|
||||
};
|
||||
this.editToggle = this.editToggle.bind(this);
|
||||
this.sigilColorSet = this.sigilColorSet.bind(this);
|
||||
this.nickNameToSet = this.nickNameToSet.bind(this);
|
||||
this.emailToSet = this.emailToSet.bind(this);
|
||||
this.phoneToSet = this.phoneToSet.bind(this);
|
||||
this.websiteToSet = this.websiteToSet.bind(this);
|
||||
this.avatarToSet = this.avatarToSet.bind(this);
|
||||
this.notesToSet = this.notesToSet.bind(this);
|
||||
this.setField = this.setField.bind(this);
|
||||
this.shareWithGroup = this.shareWithGroup.bind(this);
|
||||
this.removeSelfFromGroup = this.removeSelfFromGroup.bind(this);
|
||||
this.removeOtherFromGroup = this.removeOtherFromGroup.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props } = this;
|
||||
if (props.ship !== prevProps.ship) {
|
||||
this.setState({
|
||||
edit: props.share,
|
||||
colorToSet: null,
|
||||
nickNameToSet: null,
|
||||
emailToSet: null,
|
||||
phoneToSet: null,
|
||||
websiteToSet: null,
|
||||
avatarToSet: null,
|
||||
notesToSet: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
editToggle() {
|
||||
let editSwitch = this.state.edit;
|
||||
editSwitch = !editSwitch;
|
||||
this.setState({ edit: editSwitch });
|
||||
}
|
||||
|
||||
emailToSet(value) {
|
||||
this.setState({ emailToSet: value });
|
||||
}
|
||||
|
||||
nickNameToSet(value) {
|
||||
this.setState({ nickNameToSet: value });
|
||||
}
|
||||
|
||||
notesToSet(value) {
|
||||
this.setState({ notesToSet: value });
|
||||
}
|
||||
|
||||
phoneToSet(value) {
|
||||
this.setState({ phoneToSet: value });
|
||||
}
|
||||
|
||||
websiteToSet(value) {
|
||||
this.setState({ websiteToSet: value });
|
||||
}
|
||||
|
||||
avatarToSet(value) {
|
||||
this.setState({ avatarToSet: value });
|
||||
}
|
||||
|
||||
sigilColorSet(event) {
|
||||
this.setState({ colorToSet: event.target.value });
|
||||
}
|
||||
|
||||
shipParser(ship) {
|
||||
switch (ship.length) {
|
||||
case 3:
|
||||
return 'Galaxy';
|
||||
case 6:
|
||||
return 'Star';
|
||||
case 13:
|
||||
return 'Planet';
|
||||
case 56:
|
||||
return 'Comet';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
setField(field) {
|
||||
const { props, state } = this;
|
||||
const ship = '~' + props.ship;
|
||||
const emailTest = new RegExp(
|
||||
String(
|
||||
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*/.source
|
||||
) +
|
||||
/@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/
|
||||
.source
|
||||
);
|
||||
|
||||
const phoneTest = new RegExp(
|
||||
String(/^\s*(?:\+?(\d{1,3}))?/.source) +
|
||||
/([-. (]*(\d{3})[-. )]*)?((\d{3})[-. ]*(\d{2,4})(?:[-.x ]*(\d+))?)\s*$/
|
||||
.source
|
||||
);
|
||||
|
||||
const websiteTest = new RegExp(
|
||||
String(/[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}/.source) +
|
||||
/\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/.source
|
||||
);
|
||||
|
||||
switch (field) {
|
||||
case 'avatar': {
|
||||
if (
|
||||
state.avatarToSet === '' ||
|
||||
(Boolean(props.contact.avatar) &&
|
||||
state.avatarToSet === props.contact.avatar)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const avatarTestResult = websiteTest.exec(state.avatarToSet);
|
||||
if (avatarTestResult) {
|
||||
this.setState(
|
||||
{
|
||||
awaiting: true,
|
||||
type: 'Saving to group'
|
||||
},
|
||||
() => {
|
||||
props.api.contacts.edit(props.path, ship, {
|
||||
avatar: {
|
||||
url: state.avatarToSet
|
||||
}})
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'color': {
|
||||
let currentColor = props.contact.color ? props.contact.color : '000000';
|
||||
currentColor = uxToHex(currentColor);
|
||||
const hexExp = /([0-9A-Fa-f]{6})/;
|
||||
const hexTest = hexExp.exec(this.state.colorToSet);
|
||||
|
||||
if (hexTest && hexTest[1] !== currentColor && !props.share) {
|
||||
this.setState({ awaiting: true, type: 'Saving to group' }, () => {
|
||||
props.api.contacts.edit(
|
||||
props.path, `~${props.ship}`, { color: hexTest[1] })
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'email': {
|
||||
if (
|
||||
state.emailToSet === '' ||
|
||||
state.emailToSet === props.contact.email
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const emailTestResult = emailTest.exec(state.emailToSet);
|
||||
if (emailTestResult) {
|
||||
this.setState({ awaiting: true, type: 'Saving to group' }, () => {
|
||||
props.api.contacts.edit(
|
||||
props.path, ship, { email: state.emailToSet })
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'nickname': {
|
||||
if (
|
||||
state.nickNameToSet === '' ||
|
||||
state.nickNameToSet === props.contact.nickname
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.setState({ awaiting: true, type: 'Saving to group' }, () => {
|
||||
props.api.contacts.edit(
|
||||
props.path, ship, { nickname: state.nickNameToSet })
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case 'notes': {
|
||||
if (
|
||||
state.notesToSet === '' ||
|
||||
state.notesToSet === props.contact.notes
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.setState({ awaiting: true, type: 'Saving to group' }, () => {
|
||||
props.api.contacts.edit(
|
||||
props.path, ship, { notes: state.notesToSet })
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'phone': {
|
||||
if (
|
||||
state.phoneToSet === '' ||
|
||||
state.phoneToSet === props.contact.phone
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const phoneTestResult = phoneTest.exec(state.phoneToSet);
|
||||
if (phoneTestResult) {
|
||||
this.setState({ awaiting: true, type: 'Saving to group' }, () => {
|
||||
props.api.contacts.edit(
|
||||
props.path, ship, { phone: state.phoneToSet })
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'website': {
|
||||
if (
|
||||
state.websiteToSet === '' ||
|
||||
state.websiteToSet === props.contact.website
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const websiteTestResult = websiteTest.exec(state.websiteToSet);
|
||||
if (websiteTestResult) {
|
||||
this.setState({ awaiting: true, type: 'Saving to group' }, () => {
|
||||
props.api.contacts.edit(
|
||||
props.path, ship, { website: state.websiteToSet })
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'removeEmail': {
|
||||
this.setState(
|
||||
{ emailToSet: '', awaiting: true, type: 'Removing from group' },
|
||||
() => {
|
||||
props.api.contacts.edit(props.path, ship, { email: '' })
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'removeNickname': {
|
||||
this.setState(
|
||||
{ nicknameToSet: '', awaiting: true, type: 'Removing from group' },
|
||||
() => {
|
||||
props.api.contacts.edit(props.path, ship, { nickname: '' })
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'removePhone': {
|
||||
this.setState(
|
||||
{ phoneToSet: '', awaiting: true, type: 'Removing from group' },
|
||||
() => {
|
||||
props.api.contacts.edit(props.path, ship, { phone: '' }).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'removeWebsite': {
|
||||
this.setState(
|
||||
{ websiteToSet: '', awaiting: true, type: 'Removing from group' },
|
||||
() => {
|
||||
props.api.contacts.edit(props.path, ship, { website: '' }).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'removeAvatar': {
|
||||
this.setState(
|
||||
{
|
||||
avatarToSet: null,
|
||||
awaiting: true,
|
||||
type: 'Removing from group'
|
||||
},
|
||||
() => {
|
||||
props.api.contacts.edit(props.path, ship, { avatar: null }).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'removeNotes': {
|
||||
this.setState(
|
||||
{ notesToSet: '', awaiting: true, type: 'Removing from group' },
|
||||
() => {
|
||||
props.api.contacts.edit(props.path, ship, { notes: '' }).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pickFunction(val, def) {
|
||||
if (val !== null) {
|
||||
return val;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
shareWithGroup() {
|
||||
const { props, state } = this;
|
||||
const defaultVal = props.share
|
||||
? {
|
||||
nickname: props.rootIdentity.nickname,
|
||||
email: props.rootIdentity.email,
|
||||
phone: props.rootIdentity.phone,
|
||||
website: props.rootIdentity.website,
|
||||
avatar: props.rootIdentity.avatar
|
||||
? { url: props.rootIdentity.avatar }
|
||||
: null,
|
||||
notes: props.rootIdentity.notes,
|
||||
color: uxToHex(props.rootIdentity.color)
|
||||
}
|
||||
: {
|
||||
nickname: props.contact.nickname,
|
||||
email: props.contact.email,
|
||||
phone: props.contact.phone,
|
||||
website: props.contact.website,
|
||||
avatar: props.contact.avatar ? { url: props.contact.avatar } : null,
|
||||
notes: props.contact.notes,
|
||||
color: props.contact.color
|
||||
};
|
||||
|
||||
const contact = {
|
||||
nickname: this.pickFunction(state.nickNameToSet, defaultVal.nickname),
|
||||
email: this.pickFunction(state.emailToSet, defaultVal.email),
|
||||
phone: this.pickFunction(state.phoneToSet, defaultVal.phone),
|
||||
website: this.pickFunction(state.websiteToSet, defaultVal.website),
|
||||
notes: this.pickFunction(state.notesToSet, defaultVal.notes),
|
||||
color: this.pickFunction(state.colorToSet, defaultVal.color),
|
||||
avatar: this.pickFunction(
|
||||
state.avatarToSet ? { url: state.avatarToSet } : null,
|
||||
defaultVal.avatar
|
||||
)
|
||||
};
|
||||
|
||||
this.setState({ awaiting: true, type: 'Sharing with group' }, () => {
|
||||
props.api.contacts
|
||||
.share(`~${props.ship}`, props.path, `~${window.ship}`, contact)
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push(`/~groups/view${props.path}/${window.ship}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
removeSelfFromGroup() {
|
||||
const { props } = this;
|
||||
// share empty contact so that we can remove ourselves from group
|
||||
// if we haven't shared yet
|
||||
const contact = {
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
notes: '',
|
||||
color: '000000',
|
||||
avatar: null
|
||||
};
|
||||
|
||||
props.api.contacts.share(
|
||||
`~${props.ship}`,
|
||||
props.path,
|
||||
`~${window.ship}`,
|
||||
contact
|
||||
);
|
||||
|
||||
this.setState({ awaiting: true, type: 'Removing from group' }, () => {
|
||||
props.api.contacts.delete(props.path).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push('/~groups');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
removeOtherFromGroup() {
|
||||
const { props } = this;
|
||||
|
||||
this.setState({ awaiting: true, type: 'Removing from group' }, () => {
|
||||
props.api.contacts.remove(props.path, `~${props.ship}`).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push(`/~groups${props.path}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
uploadSuccess(url) {
|
||||
this.setState(
|
||||
{
|
||||
avatarToSet: url
|
||||
},
|
||||
() => {
|
||||
this.setField('avatar');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
uploadError(error) {
|
||||
// no-op for now
|
||||
}
|
||||
|
||||
renderEditCard() {
|
||||
const { props, state } = this;
|
||||
// if this is our first edit in a new group, propagate from root identity
|
||||
const defaultValue = props.share
|
||||
? {
|
||||
nickname: props.rootIdentity.nickname,
|
||||
email: props.rootIdentity.email,
|
||||
phone: props.rootIdentity.phone,
|
||||
website: props.rootIdentity.website,
|
||||
avatar: props.rootIdentity.avatar,
|
||||
notes: props.rootIdentity.notes,
|
||||
color: props.rootIdentity.color
|
||||
}
|
||||
: {
|
||||
nickname: props.contact.nickname,
|
||||
email: props.contact.email,
|
||||
phone: props.contact.phone,
|
||||
website: props.contact.website,
|
||||
avatar: props.contact.avatar,
|
||||
notes: props.contact.notes,
|
||||
color: props.contact.color
|
||||
};
|
||||
|
||||
const shipType = this.shipParser(props.ship);
|
||||
|
||||
let defaultColor = defaultValue.color ? defaultValue.color : '000000';
|
||||
defaultColor = uxToHex(defaultColor);
|
||||
let currentColor = state.colorToSet ? state.colorToSet : defaultColor;
|
||||
currentColor = uxToHex(currentColor);
|
||||
|
||||
const avatar =
|
||||
'avatar' in props.contact && props.contact.avatar !== null ? (
|
||||
<img className="dib h-auto" width={128} src={props.contact.avatar} />
|
||||
) : (
|
||||
<span className="dn"></span>
|
||||
);
|
||||
|
||||
const imageSetter = !props.share ? (
|
||||
<span className="db">
|
||||
<p className="f9 gray2 db pb1">Avatar image url</p>
|
||||
<span className="cf db">
|
||||
<span className="w-20 fl pt1">
|
||||
<S3Upload
|
||||
className="fr pr3"
|
||||
configuration={props.s3.configuration}
|
||||
credentials={props.s3.credentials}
|
||||
uploadSuccess={this.uploadSuccess.bind(this)}
|
||||
uploadError={this.uploadError.bind(this)}
|
||||
accept="image/*"
|
||||
>
|
||||
<img
|
||||
className="invert-d"
|
||||
src="/~chat/img/ImageUpload.png"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
</S3Upload>
|
||||
</span>
|
||||
<EditElement
|
||||
className="fr w-80"
|
||||
defaultValue={defaultValue.avatar}
|
||||
onChange={this.avatarToSet}
|
||||
onDeleteClick={() => this.setField('removeAvatar')}
|
||||
onSaveClick={() => this.setField('avatar')}
|
||||
showButtons={!props.share}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="dn"></span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-100 mt8 flex justify-center pa4 pt8 pt0-l pa0-xl pt4-xl pb8">
|
||||
<div className="w-100 mw6 tc">
|
||||
{avatar}
|
||||
{imageSetter}
|
||||
<Sigil
|
||||
ship={props.ship}
|
||||
size={128}
|
||||
color={'#' + currentColor}
|
||||
key={'avatar' + currentColor}
|
||||
/>
|
||||
<div
|
||||
className="tc mt4 mb4 w-auto ml-auto mr-auto"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
<div className="tl dib">
|
||||
<p className="f9 gray2 lh-copy">Sigil Color</p>
|
||||
<textarea
|
||||
className={
|
||||
'b--gray4 b--gray2-d black white-d bg-gray0-d f7 ba db pl2 ' +
|
||||
'focus-b--black focus-b--white-d'
|
||||
}
|
||||
onChange={this.sigilColorSet}
|
||||
defaultValue={defaultColor}
|
||||
key={'default' + defaultColor}
|
||||
onKeyPress={e =>
|
||||
!e.key.match(/[0-9a-f]/i) ? e.preventDefault() : null
|
||||
}
|
||||
onBlur={() => this.setField('color')}
|
||||
maxLength={6}
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 40,
|
||||
paddingTop: 10,
|
||||
width: 114
|
||||
}}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-100 pt8 pb8 lh-copy tl">
|
||||
<p className="f9 gray2">Ship Name</p>
|
||||
<p className="f8 mono">~{props.ship}</p>
|
||||
<p className="f9 gray2 mt3">Ship Type</p>
|
||||
<p className="f8">{shipType}</p>
|
||||
<hr className="mv8 gray4 b--gray4 bb-0 b--solid" />
|
||||
<EditElement
|
||||
title="Nickname"
|
||||
defaultValue={defaultValue.nickname}
|
||||
onChange={this.nickNameToSet}
|
||||
onDeleteClick={() => this.setField('removeNickname')}
|
||||
onSaveClick={() => this.setField('nickname')}
|
||||
showButtons={!props.share}
|
||||
/>
|
||||
<EditElement
|
||||
title="Email"
|
||||
defaultValue={defaultValue.email}
|
||||
onChange={this.emailToSet}
|
||||
onDeleteClick={() => this.setField('removeEmail')}
|
||||
onSaveClick={() => this.setField('email')}
|
||||
showButtons={!props.share}
|
||||
/>
|
||||
<EditElement
|
||||
title="Phone"
|
||||
defaultValue={defaultValue.phone}
|
||||
onChange={this.phoneToSet}
|
||||
onDeleteClick={() => this.setField('removePhone')}
|
||||
onSaveClick={() => this.setField('phone')}
|
||||
showButtons={!props.share}
|
||||
/>
|
||||
<EditElement
|
||||
title="Website"
|
||||
defaultValue={defaultValue.website}
|
||||
onChange={this.websiteToSet}
|
||||
onDeleteClick={() => this.setField('removeWebsite')}
|
||||
onSaveClick={() => this.setField('website')}
|
||||
showButtons={!props.share}
|
||||
/>
|
||||
<EditElement
|
||||
title="Notes"
|
||||
defaultValue={defaultValue.notes}
|
||||
onChange={this.notesToSet}
|
||||
onDeleteClick={() => this.setField('removeNotes')}
|
||||
onSaveClick={() => this.setField('notes')}
|
||||
resizable={true}
|
||||
showButtons={!props.share}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderCard() {
|
||||
const { props } = this;
|
||||
const shipType = this.shipParser(props.ship);
|
||||
const currentColor = props.contact.color ? props.contact.color : '0x0';
|
||||
const hexColor = uxToHex(currentColor);
|
||||
|
||||
const avatar =
|
||||
'avatar' in props.contact && props.contact.avatar !== null ? (
|
||||
<img className="dib h-auto" width={128} src={props.contact.avatar} />
|
||||
) : (
|
||||
<Sigil
|
||||
ship={props.ship}
|
||||
size={128}
|
||||
color={'#' + hexColor}
|
||||
key={hexColor}
|
||||
/>
|
||||
);
|
||||
|
||||
const websiteHref =
|
||||
props.contact.website && props.contact.website.includes('://')
|
||||
? props.contact.website
|
||||
: 'http://' + props.contact.website;
|
||||
|
||||
return (
|
||||
<div className="w-100 mt8 flex justify-center pa4 pt8 pt0-l pa0-xl pt4-xl">
|
||||
<div className="w-100 mw6 tc">
|
||||
{avatar}
|
||||
<div className="w-100 pv8 lh-copy tl">
|
||||
<p className="f9 gray2">Ship Name</p>
|
||||
<p className="f8 mono">~{props.ship}</p>
|
||||
<p className="f9 gray2 mt3">Ship Type</p>
|
||||
<p className="f8">{shipType}</p>
|
||||
<hr className="mv8 gray4 b--gray4 bb-0 b--solid" />
|
||||
<div>
|
||||
{props.contact.nickname ? (
|
||||
<div>
|
||||
<p className="f9 gray2">Nickname</p>
|
||||
<p className="f8">{props.contact.nickname}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{props.contact.email ? (
|
||||
<div>
|
||||
<p className="f9 mt6 gray2">Email</p>
|
||||
<p className="f8">{props.contact.email}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{props.contact.phone ? (
|
||||
<div>
|
||||
<p className="f9 mt6 gray2">Phone</p>
|
||||
<p className="f8">{props.contact.phone}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{props.contact.website ? (
|
||||
<div>
|
||||
<p className="f9 mt6 gray2">website</p>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bb b--black f8"
|
||||
href={websiteHref}
|
||||
>
|
||||
{props.contact.website}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{props.contact.notes ? (
|
||||
<div>
|
||||
<p className="f9 mt6 gray2">notes</p>
|
||||
<p className="f8">{props.contact.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
let editInfoText = state.edit ? 'Finish' : 'Edit';
|
||||
if (props.share && state.edit) {
|
||||
editInfoText = 'Share';
|
||||
}
|
||||
|
||||
const ourOpt = props.ship === window.ship ? 'dib' : 'dn';
|
||||
|
||||
const adminOpt =
|
||||
props.path.includes(`~${window.ship}/`) ||
|
||||
(props.ship === window.ship && !props.path.includes('/~/default'))
|
||||
? 'dib'
|
||||
: 'dn';
|
||||
|
||||
const meLink =
|
||||
props.path === '/~/default' ? '/~groups' : `/~groups/detail${props.path}`;
|
||||
|
||||
const card = state.edit ? this.renderEditCard() : this.renderCard();
|
||||
return (
|
||||
<div className="w-100 h-100 overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
'flex justify-between w-100 bg-white bg-gray0-d ' +
|
||||
'bb b--gray4 b--gray1-d '
|
||||
}
|
||||
>
|
||||
<div className="f9 mv4 mh3 pt1 dib w-100">
|
||||
<Link to={meLink}>{'⟵'}</Link>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (props.share) {
|
||||
this.shareWithGroup();
|
||||
} else {
|
||||
this.editToggle();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
'white-d bg-gray0-d mv4 mh3 f9 pa1 pointer flex-shrink-0 ' +
|
||||
ourOpt
|
||||
}
|
||||
>
|
||||
{editInfoText}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={
|
||||
'bg-gray0-d mv4 mh3 pa1 f9 red2 pointer flex-shrink-0 ' + adminOpt
|
||||
}
|
||||
onClick={props.ship === window.ship ? this.removeSelfFromGroup : this.removeOtherFromGroup}
|
||||
>
|
||||
{props.ship === window.ship ? 'Leave Group' : 'Remove from Group'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-100 w-100 overflow-x-hidden pb8 white-d">{card}</div>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
text={`${this.state.type}...`}
|
||||
classes="absolute right-1 bottom-1 ba pa2 b--gray1-d"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { uxToHex, cite } from '~/logic/lib/util';
|
||||
|
||||
export class ContactItem extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const selectedClass = (props.selected) ? 'bg-gray4 bg-gray1-d' : '';
|
||||
const hexColor = uxToHex(props.color);
|
||||
const name = (props.nickname) ? props.nickname : cite(props.ship);
|
||||
|
||||
const prefix = props.share ? 'share' : 'view';
|
||||
const suffix = !props.share ? `/${props.ship}` : '';
|
||||
|
||||
const img = (props.avatar !== null)
|
||||
? <img className="dib" src={props.avatar} height={32} width={32} />
|
||||
: <Sigil
|
||||
ship={props.ship}
|
||||
color={'#' + hexColor}
|
||||
size={32}
|
||||
key={`${props.ship}.sidebar.${hexColor}`}
|
||||
/>;
|
||||
|
||||
return (
|
||||
<Link to={`/~groups/${prefix}` + props.path + suffix}>
|
||||
<div className=
|
||||
{'pl4 pt1 pb1 f9 flex justify-start content-center ' + selectedClass}
|
||||
>
|
||||
{img}
|
||||
<p
|
||||
className={
|
||||
'f9 w-70 dib v-mid ml2 nowrap ' +
|
||||
((props.nickname) ? '' : 'mono')}
|
||||
style={{ paddingTop: 6 }}
|
||||
title={props.ship}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,201 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Virtuoso as VirtualList } from 'react-virtuoso';
|
||||
|
||||
import { ContactItem } from './contact-item';
|
||||
import { ShareSheet } from './share-sheet';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import { roleForShip, resourceFromPath } from '~/logic/lib/group';
|
||||
import { Path, PatpNoSig } from '~/types/noun';
|
||||
import { Rolodex, Contacts, Contact } from '~/types/contact-update';
|
||||
import { Groups, Group } from '~/types/group-update';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
|
||||
interface ContactSidebarProps {
|
||||
activeDrawer: 'contacts' | 'detail' | 'rightPanel';
|
||||
groups: Groups;
|
||||
group: Group
|
||||
contacts: Contacts;
|
||||
path: Path;
|
||||
api: GlobalApi;
|
||||
defaultContacts: Contacts;
|
||||
selectedContact?: PatpNoSig;
|
||||
}
|
||||
interface ContactSidebarState {
|
||||
awaiting: boolean;
|
||||
memberboxHeight: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class ContactSidebar extends Component<ContactSidebarProps, ContactSidebarState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
awaiting: false,
|
||||
memberboxHeight: 0
|
||||
};
|
||||
this.memberbox = this.memberbox.bind(this);
|
||||
}
|
||||
|
||||
memberbox(element) {
|
||||
if (element) {
|
||||
this.setState({
|
||||
memberboxHeight: element.getBoundingClientRect().height
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const responsiveClasses =
|
||||
props.activeDrawer === 'contacts' ? 'db' : 'dn db-ns';
|
||||
|
||||
|
||||
const group = props.groups[props.path];
|
||||
|
||||
const members = new Set(group.members || []);
|
||||
|
||||
const me = (window.ship in props.contacts)
|
||||
? props.contacts[window.ship]
|
||||
: (window.ship in props.defaultContacts)
|
||||
? props.defaultContacts[window.ship]
|
||||
: { color: '0x0', nickname: null, avatar: null };
|
||||
|
||||
const shareSheet =
|
||||
!(window.ship in props.contacts) ?
|
||||
(<ShareSheet
|
||||
ship={window.ship}
|
||||
nickname={me.nickname}
|
||||
avatar={me.avatar}
|
||||
color={me.color}
|
||||
path={props.path}
|
||||
selected={props.path + '/' + window.ship === props.selectedContact}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">You</h2>
|
||||
<ContactItem
|
||||
ship={window.ship}
|
||||
nickname={me.nickname}
|
||||
avatar={me.avatar}
|
||||
color={me.color}
|
||||
path={props.path}
|
||||
selected={props.path + '/' + window.ship === props.selectedContact}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
members.delete(window.ship);
|
||||
|
||||
const contactItems =
|
||||
Object.keys(props.contacts)
|
||||
.filter(c => c !== window.ship)
|
||||
.map((contact) => {
|
||||
members.delete(contact);
|
||||
const path = props.path + '/' + contact;
|
||||
const obj = props.contacts[contact];
|
||||
return (
|
||||
<ContactItem
|
||||
key={contact}
|
||||
ship={contact}
|
||||
nickname={obj.nickname}
|
||||
color={obj.color}
|
||||
avatar={obj.avatar}
|
||||
path={props.path}
|
||||
selected={path === props.selectedContact}
|
||||
share={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const role = roleForShip(group, window.ship);
|
||||
|
||||
const resource = resourceFromPath(props.path);
|
||||
const groupItems =
|
||||
Array.from(members).map((member) => {
|
||||
const memberRole = roleForShip(group, member);
|
||||
const adminOpt = (role === 'admin' && memberRole !== 'admin')
|
||||
|| (role === 'moderator' &&
|
||||
(memberRole !== 'admin' && memberRole !== 'moderator'))
|
||||
? 'dib' : 'dn';
|
||||
return (
|
||||
<div
|
||||
key={member}
|
||||
className={'pl4 pt1 pb1 f9 flex justify-start content-center ' +
|
||||
'bg-white bg-gray0-d relative'}
|
||||
>
|
||||
<Sigil
|
||||
ship={member}
|
||||
color="#000000"
|
||||
size={32}
|
||||
classes="mix-blend-diff"
|
||||
/>
|
||||
<p className="f9 w-70 dib v-mid ml2 nowrap mono truncate"
|
||||
style={{ paddingTop: 6, color: '#aaaaaa' }}
|
||||
title={member}
|
||||
>
|
||||
{cite(member)}
|
||||
</p>
|
||||
<p className={'v-mid f9 mh3 red2 pointer ' + adminOpt}
|
||||
style={{ paddingTop: 6 }}
|
||||
onClick={() => {
|
||||
this.setState({ awaiting: true }, (() => {
|
||||
props.api.groups.remove(resource, [`~${member}`])
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const detailHref = `/~groups/detail${props.path}`;
|
||||
|
||||
return (
|
||||
<div ref={this.memberbox} className={'bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100 ' +
|
||||
'flex-basis-100-s flex-basis-30-ns mw5-m mw5-l mw5-xl relative ' +
|
||||
'overflow-hidden flex-shrink-0 ' + responsiveClasses}
|
||||
>
|
||||
<div className="pt3 pb5 pl3 f8 db dn-m dn-l dn-xl">
|
||||
<Link to="/~groups/">{'⟵ All Groups'}</Link>
|
||||
</div>
|
||||
<div className="overflow-auto h-100 flex flex-column">
|
||||
<Link
|
||||
to={'/~groups/add' + props.path}
|
||||
className={((role === "admin" || role === "moderator")
|
||||
? 'dib'
|
||||
: 'dn')}
|
||||
>
|
||||
<p className="f9 pl4 pt0 pt4-m pt4-l pt4-xl green2 bn">Add to Group</p>
|
||||
</Link>
|
||||
<Link to={detailHref}
|
||||
className="dib dn-m dn-l dn-xl f9 pl4 pt0 pt4-m pt4-l pt4-xl gray2 bn"
|
||||
>Channels</Link>
|
||||
{shareSheet}
|
||||
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">Members</h2>
|
||||
<VirtualList
|
||||
style={{ height: this.state.memberboxHeight, width: '100%' }}
|
||||
className="flex-auto"
|
||||
totalCount={contactItems.length + groupItems.length}
|
||||
itemHeight={44} // We happen to know this
|
||||
item={
|
||||
(index) => index <= (contactItems.length - 1) // If the index is within the length of contact items,
|
||||
? contactItems[index] // show a contact item
|
||||
: groupItems[index - contactItems.length] // Otherwise show a group item
|
||||
}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<Spinner awaiting={this.state.awaiting} text="Removing from group..." classes="pa2 ba absolute right-1 bottom-1 b--gray1-d" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,80 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class EditElement extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
currentValue: ''
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
// TODO: make ref clear function and make it work
|
||||
const showDelete = props.defaultValue === '';
|
||||
const allowSave = (
|
||||
props.defaultValue !== state.currentValue &&
|
||||
state.currentValue !== ''
|
||||
);
|
||||
|
||||
const inputStyles = (props.resizable)
|
||||
? { resize: 'vertical', height: 40, paddingTop: 10 }
|
||||
: { resize: 'none', height: 40, paddingTop: 10 };
|
||||
|
||||
let classes = !!props.className ? "pb4 " + props.className : "pb4";
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<p className="f9 gray2">{props.title}</p>
|
||||
<div className="w-100 flex">
|
||||
<textarea
|
||||
ref={props.title}
|
||||
className={'w-100 ba pl3 white-d bg-gray0-d b--gray4 b--gray2-d ' +
|
||||
'focus-b--black focus-b--white-d'}
|
||||
style={ inputStyles }
|
||||
onChange={(e) => {
|
||||
const val = (' ' + e.target.value).slice(1);
|
||||
this.setState({
|
||||
currentValue: val
|
||||
}, () => {
|
||||
props.onChange(val);
|
||||
});
|
||||
}}
|
||||
defaultValue={props.defaultValue}
|
||||
/>
|
||||
{props.showButtons ? (
|
||||
<button
|
||||
className={
|
||||
'bg-gray0-d f9 pointer ml3 ba pa2 pl3 pr3 b--red2 red2 ' +
|
||||
(showDelete ? 'dn' : 'dib')
|
||||
}
|
||||
onClick={() => {
|
||||
this.refs[props.title].value = '';
|
||||
props.onDeleteClick();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{props.showButtons ? (
|
||||
<button
|
||||
className={
|
||||
'bg-gray0-d white-d pointer db mv2 f9 ba pa2 pl3 pr3 ' +
|
||||
(allowSave ? 'b--black b--white-d' : 'b--gray4 gray4 b--gray2-d gray2-d')
|
||||
}
|
||||
onClick={() => {
|
||||
if (!allowSave) {
|
||||
return;
|
||||
}
|
||||
props.onSaveClick();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,365 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Toggle } from '~/views/components/toggle';
|
||||
import { GroupView } from '~/views/components/Group';
|
||||
|
||||
import { deSig, uxToHex, writeText } from '~/logic/lib/util';
|
||||
import { roleForShip, resourceFromPath } from '~/logic/lib/group';
|
||||
|
||||
export class GroupDetail extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
title: '',
|
||||
description: '',
|
||||
awaiting: false,
|
||||
type: 'Editing'
|
||||
};
|
||||
this.changeTitle = this.changeTitle.bind(this);
|
||||
this.changeDescription = this.changeDescription.bind(this);
|
||||
this.changePolicy = this.changePolicy.bind(this);
|
||||
this.getShortcode = this.getShortcode.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { props } = this;
|
||||
if (props.association.metadata) {
|
||||
this.setState({
|
||||
title: props.association.metadata.title,
|
||||
description: props.association.metadata.description
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props } = this;
|
||||
if (prevProps !== this.props) {
|
||||
if (props.association.metadata) {
|
||||
this.setState({
|
||||
title: props.association.metadata.title,
|
||||
description: props.association.metadata.description
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changeTitle(event) {
|
||||
this.setState({ title: event.target.value });
|
||||
}
|
||||
|
||||
changeDescription(event) {
|
||||
this.setState({ description: event.target.value });
|
||||
}
|
||||
|
||||
changePolicy() {
|
||||
this.setState({ awaiting: true }, () => {
|
||||
this.props.api.groups.changePolicy(resourceFromPath(this.props.path),
|
||||
Boolean(this.props.group?.policy?.open)
|
||||
? { replace: { invite: { pending: [] } } }
|
||||
: { replace: { open: { banned: [], banRanks: [] } } }
|
||||
).then(() => this.setState({ awaiting: false }));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
renderDetail() {
|
||||
const { props } = this;
|
||||
|
||||
const responsiveClass =
|
||||
props.activeDrawer === 'detail' ? 'db ' : 'dn db-ns ';
|
||||
|
||||
let channelList = [];
|
||||
|
||||
Object.keys(props.associations).filter((app) => {
|
||||
return app !== 'contacts';
|
||||
}).map((app) => {
|
||||
Object.keys(props.associations[app]).filter((channel) => {
|
||||
return props.associations[app][channel]['group-path'] ===
|
||||
props.association['group-path'];
|
||||
})
|
||||
.map((channel) => {
|
||||
const channelObj = props.associations[app][channel];
|
||||
const title =
|
||||
channelObj.metadata?.title || channelObj['app-path'] || '';
|
||||
|
||||
const color = uxToHex(channelObj.metadata?.color) || '000000';
|
||||
const link = `/~${app}/join${channelObj['app-path']}`;
|
||||
const module = channelObj.metadata?.module || '';
|
||||
|
||||
if (app === 'graph' && module) {
|
||||
return (
|
||||
channelList.push({
|
||||
title: title,
|
||||
color: color,
|
||||
app: module.charAt(0).toUpperCase() + module.slice(1),
|
||||
link: `${link}/${module}`
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
channelList.push({
|
||||
title: title,
|
||||
color: color,
|
||||
app: app.charAt(0).toUpperCase() + app.slice(1),
|
||||
link: link
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const isEmpty = (Boolean(channelList.length === 0));
|
||||
|
||||
if (channelList.length === 0) {
|
||||
channelList = <div />;
|
||||
} else {
|
||||
channelList = channelList.sort((a, b) => {
|
||||
return a.title.toLowerCase().localeCompare(b.title.toLowerCase());
|
||||
}).map((each) => {
|
||||
const overlay = {
|
||||
r: parseInt(each.color.slice(0, 2), 16),
|
||||
g: parseInt(each.color.slice(2, 4), 16),
|
||||
b: parseInt(each.color.slice(4, 6), 16)
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={each.link} className="f9 list flex pv1 w-100">
|
||||
<div className="ba" style={{
|
||||
borderColor: `#${each.color}`,
|
||||
backgroundColor: `rgba(${overlay.r}, ${overlay.g}, ${overlay.b}, 0.25)`
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/~landscape/img/${each.app}.png`}
|
||||
className="dib invert-d pa1 v-mid"
|
||||
style={{ height: 26, width: 26 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-column flex-auto">
|
||||
<p className="f9 inter ml2 w-100">{each.title}</p>
|
||||
<p className="f9 inter mt2 ml2 w-100">
|
||||
<span className="f9 di mr2 inter">{each.app}</span>
|
||||
<Link className="f9 di green2" to={each.link}>
|
||||
Open
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let backLink = props.location.pathname;
|
||||
backLink = backLink.slice(0, props.location.pathname.indexOf('/detail'));
|
||||
|
||||
const emptyGroup = (
|
||||
<div className={isEmpty ? 'dt w-100 h-100' : 'dn'}>
|
||||
<p className="gray2 f9 tc v-mid dtc">
|
||||
This group has no channels. To add a channel, invite this group using any application.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
let title = props.path.substr(1);
|
||||
let description = '';
|
||||
if (props.association?.metadata) {
|
||||
title = (props.association.metadata.title !== '')
|
||||
? props.association.metadata.title
|
||||
: props.path.substr(1);
|
||||
description = (props.association.metadata.description !== '')
|
||||
? props.association.metadata.description
|
||||
: '';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'relative h-100 w-100 bg-white bg-gray0-d white-d pa4 '
|
||||
+ responsiveClass +
|
||||
((isEmpty) ? 'overflow-hidden' : 'overflow-x-hidden')}
|
||||
>
|
||||
<div className="pb4 f8 db dn-m dn-l dn-xl">
|
||||
<Link to={backLink}>⟵ Contacts</Link>
|
||||
</div>
|
||||
<div className="w-100 lh-copy">
|
||||
<Link
|
||||
className="absolute right-1 f9"
|
||||
to={'/~groups/settings' + props.path}
|
||||
>Group Settings</Link>
|
||||
<p className="f9 mw5 mw3-m mw4-l">{title}</p>
|
||||
<p className="f9 gray2">{description}</p>
|
||||
<p className="f9">
|
||||
{props.group.members.size + ' participant' +
|
||||
((props.group.members.size === 1) ? '' : 's')}
|
||||
</p>
|
||||
</div>
|
||||
<p className={'gray2 f9 mb2 pt6 ' + (isEmpty ? 'dn' : '')}>Group Channels</p>
|
||||
{emptyGroup}
|
||||
{channelList}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getShortcode(group, path) {
|
||||
if (group?.policy?.open) {
|
||||
return (
|
||||
<div className='mt4'>
|
||||
<p className='f9 mt4 lh-copy'>Share</p>
|
||||
<p className='f9 gray2 mb2'>
|
||||
Share a shortcode to join this group
|
||||
</p>
|
||||
<div
|
||||
className='relative w-100 flex'
|
||||
style={{ maxWidth: '29rem' }}>
|
||||
<input
|
||||
className={'f8 mono ba b--gray3 b--gray2-d bg-gray0-d ' +
|
||||
'white-d pa3 db w-100 flex-auto mr3 pr9'}
|
||||
disabled={true}
|
||||
value={path.substr(6)}
|
||||
/>
|
||||
<span
|
||||
className='lh-solid f8 pointer absolute pa3 inter'
|
||||
style={{ right: 12, top: 1 }}
|
||||
ref='copy'
|
||||
onClick={() => {
|
||||
writeText(path.substr(6));
|
||||
this.refs.copy.innerText = 'Copied';
|
||||
}}>
|
||||
Copy
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <div />;
|
||||
};
|
||||
}
|
||||
|
||||
renderSettings() {
|
||||
const { props } = this;
|
||||
|
||||
const { group, association } = props;
|
||||
|
||||
const ourRole = roleForShip(group, window.ship);
|
||||
const groupOwner = (ourRole === 'admin');
|
||||
|
||||
const deleteButtonClasses = (groupOwner) ? 'b--red2 red2 pointer bg-gray0-d' : 'b--gray3 gray3 bg-gray0-d c-default';
|
||||
|
||||
const tags = [
|
||||
{ description: 'Admin', tag: 'admin', addDescription: 'Make Admin' },
|
||||
{ description: 'Moderator', tag: 'moderator', addDescription: 'Make Moderator' },
|
||||
{ description: 'Janitor', tag: 'janitor', addDescription: 'Make Janitor' }
|
||||
];
|
||||
|
||||
const shortcode = this.getShortcode(group, props.path);
|
||||
|
||||
return (
|
||||
<div className="pa4 w-100 h-100 white-d overflow-y-auto">
|
||||
<div className="f8 f9-m f9-l f9-xl w-100">
|
||||
<Link to={'/~groups/detail' + props.path}>{'⟵ Channels'}</Link>
|
||||
</div>
|
||||
{shortcode}
|
||||
{ group && <GroupView permissions className="mt6" resourcePath={props.path} group={group} tags={tags} api={props.api} /> }
|
||||
<div className={(groupOwner) ? '' : 'o-30'}>
|
||||
<p className="f9 mt3 lh-copy">Rename</p>
|
||||
<p className="f9 gray2 mb2">Change the name of this group</p>
|
||||
<div className="relative w-100 flex"
|
||||
style={{ maxWidth: '29rem' }}
|
||||
>
|
||||
<input
|
||||
className={'f9 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
|
||||
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
|
||||
value={this.state.title}
|
||||
disabled={!groupOwner}
|
||||
onChange={this.changeTitle}
|
||||
onBlur={() => {
|
||||
if (groupOwner) {
|
||||
this.setState({ awaiting: true }, (() => {
|
||||
props.api.metadata.metadataAdd(
|
||||
'contacts',
|
||||
association['app-path'],
|
||||
association['group-path'],
|
||||
this.state.title,
|
||||
association.metadata.description,
|
||||
association.metadata['date-created'],
|
||||
uxToHex(association.metadata.color),
|
||||
''
|
||||
).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="f9 mt3 lh-copy">Change description</p>
|
||||
<p className="f9 gray2 mb2">Change the description of this group</p>
|
||||
<div className="relative w-100 flex"
|
||||
style={{ maxWidth: '29rem' }}
|
||||
>
|
||||
<input
|
||||
className={'f9 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
|
||||
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
|
||||
value={this.state.description}
|
||||
disabled={!groupOwner}
|
||||
onChange={this.changeDescription}
|
||||
onBlur={() => {
|
||||
if (groupOwner) {
|
||||
this.setState({ awaiting: true }, (() => {
|
||||
props.api.metadata.metadataAdd(
|
||||
'contacts',
|
||||
association['app-path'],
|
||||
association['group-path'],
|
||||
association.metadata.title,
|
||||
this.state.description,
|
||||
association.metadata['date-created'],
|
||||
uxToHex(association.metadata.color)
|
||||
).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-100 mt6" style={{ maxWidth: '29rem' }}>
|
||||
<Toggle
|
||||
boolean={(Boolean(group?.policy?.invite))}
|
||||
change={this.changePolicy}
|
||||
/>
|
||||
<span className="dib f9 white-d inter ml3">Private Group</span>
|
||||
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
|
||||
If private, members must be invited
|
||||
</p>
|
||||
</div>
|
||||
<p className="f9 mt6 lh-copy">Delete Group</p>
|
||||
<p className="f9 gray2 mb2">
|
||||
Permanently delete this group. All current members will no longer see this group.
|
||||
</p>
|
||||
<a className={'dib f9 ba pa2 ' + deleteButtonClasses}
|
||||
onClick={() => {
|
||||
if (groupOwner) {
|
||||
if (prompt(`To confirm deleting this group, type ${props.path.substr(6)}`) === props.path.substr(6)) {
|
||||
this.setState({ awaiting: true, type: 'Deleting' }, (() => {
|
||||
props.api.contacts.delete(props.path).then(() => {
|
||||
props.history.push('/~groups');
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>Delete this group</a>
|
||||
</div>
|
||||
<Spinner awaiting={this.state.awaiting} text={`${this.state.type} group...`} classes="pa2 ba absolute right-1 bottom-1 b--gray1-d" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const render = (this.props.settings)
|
||||
? this.renderSettings() : this.renderDetail();
|
||||
|
||||
return render;
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupDetail;
|
@ -1,27 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export class GroupItem extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const selectedClass = (props.selected) ? 'bg-gray4 bg-gray1-d' : '';
|
||||
const memberCount = Math.max(
|
||||
props.group.members.size,
|
||||
Object.keys(props.contacts).length
|
||||
);
|
||||
|
||||
return (
|
||||
<Link to={'/~groups' + props.link}>
|
||||
<div className={'w-100 v-mid f9 pl4 ' + selectedClass}>
|
||||
<p className="f9 pt1">{props.name}</p>
|
||||
<p className="f9 pb1 gray2">
|
||||
{ memberCount + ' Member' + ((memberCount === 1) ? '' : 's') }
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,114 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GroupItem } from './group-item';
|
||||
import SidebarInvite from '~/views/components/SidebarInvite';
|
||||
import { Welcome } from './welcome';
|
||||
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
|
||||
|
||||
export class GroupSidebar extends Component {
|
||||
// drawer to the left
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const { api } = props;
|
||||
|
||||
const selectedClass = (props.selected === 'me') ? 'bg-gray4 bg-gray1-d' : 'bg-white bg-gray0-d';
|
||||
|
||||
const inviteItems =
|
||||
Object.keys(props.invites)
|
||||
.map((uid) => {
|
||||
const invite = props.invites[uid];
|
||||
return (
|
||||
<SidebarInvite
|
||||
key={uid}
|
||||
invite={invite}
|
||||
onAccept={() => {
|
||||
const [,,ship, name] = invite.path.split('/');
|
||||
const resource = { ship, name };
|
||||
api.contacts.join(resource).then(() => {
|
||||
api.invite.accept('/contacts', uid);
|
||||
});
|
||||
props.history.push(`/~groups${invite.path}`);
|
||||
}}
|
||||
onDecline={() => {
|
||||
api.invite.decline('/contacts', uid);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const groupItems =
|
||||
Object.keys(props.contacts)
|
||||
.filter((path) => {
|
||||
return (
|
||||
(!path.startsWith('/~/')) &&
|
||||
(path in props.groups)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let aName = a.substr(1);
|
||||
let bName = b.substr(1);
|
||||
if (props.associations.contacts?.[a]?.metadata) {
|
||||
aName =
|
||||
props.associations.contacts[a].metadata.title !== ''
|
||||
? props.associations.contacts[a].metadata.title
|
||||
: a.substr(1);
|
||||
}
|
||||
if (props.associations.contacts?.[b]?.metadata) {
|
||||
bName =
|
||||
props.associations.contacts[b].metadata.title !== ''
|
||||
? props.associations.contacts[b].metadata.title
|
||||
: b.substr(1);
|
||||
}
|
||||
|
||||
return aName.toLowerCase().localeCompare(bName.toLowerCase());
|
||||
})
|
||||
.map((path) => {
|
||||
let name = path.substr(1);
|
||||
const selected = props.selected === path;
|
||||
if (props.associations.contacts?.[path]?.metadata) {
|
||||
name =
|
||||
props.associations.contacts[path].metadata.title !== ''
|
||||
? props.associations.contacts[path].metadata.title
|
||||
: path.substr(1);
|
||||
}
|
||||
return (
|
||||
<GroupItem
|
||||
key={path}
|
||||
link={path}
|
||||
selected={selected}
|
||||
name={name}
|
||||
group={props.groups[path]}
|
||||
contacts={props.contacts[path]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const activeClasses = (this.props.activeDrawer === 'groups') ? '' : 'dn-s';
|
||||
|
||||
return (
|
||||
<div className={'bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100 ' +
|
||||
'flex-basis-30-ns flex-shrink-0 mw5-m mw5-l mw5-xl flex-basis-100-s ' +
|
||||
'relative overflow-hidden ' + activeClasses}
|
||||
>
|
||||
<div className="overflow-auto pb8 h-100">
|
||||
<Link to="/~groups/new" className="dib">
|
||||
<p className="f9 pt4 pl4 green2 bn">Create Group</p>
|
||||
</Link>
|
||||
<Link to="/~groups/join" className="dib">
|
||||
<p className="f9 pt4 pl4 green2 bn">Join Group</p>
|
||||
</Link>
|
||||
<Welcome contacts={props.contacts} />
|
||||
{inviteItems}
|
||||
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">Groups</h2>
|
||||
{groupItems}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,32 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { ContactItem } from './contact-item';
|
||||
|
||||
export class ShareSheet extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="pt4 pb2 pl4 pr4 f8 gray2 f9">Group Identity</p>
|
||||
<ContactItem
|
||||
key={props.ship}
|
||||
avatar={props.avatar}
|
||||
ship={props.ship}
|
||||
nickname={props.nickname}
|
||||
color={props.color}
|
||||
path={props.path}
|
||||
selected={props.selected}
|
||||
share={true}
|
||||
/>
|
||||
<p className="pt2 pb3 pl4 pr4 f9 white-d">
|
||||
Your personal information is hidden to others in this group
|
||||
by default.
|
||||
</p>
|
||||
<p className="pl4 pr4 f9 white-d">
|
||||
Share whenever you are ready, or edit its contents for this group.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,41 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class Welcome extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
show: true
|
||||
};
|
||||
this.disableWelcome = this.disableWelcome.bind(this);
|
||||
}
|
||||
|
||||
disableWelcome() {
|
||||
this.setState({ show: false });
|
||||
localStorage.setItem('urbit-groups:wasWelcomed', JSON.stringify(true));
|
||||
}
|
||||
|
||||
render() {
|
||||
let wasWelcomed = localStorage.getItem('urbit-groups:wasWelcomed');
|
||||
if (wasWelcomed === null) {
|
||||
localStorage.setItem('urbit-groups:wasWelcomed', JSON.stringify(false));
|
||||
return wasWelcomed = false;
|
||||
} else {
|
||||
wasWelcomed = JSON.parse(wasWelcomed);
|
||||
}
|
||||
|
||||
const contacts = this.props.contacts ? this.props.contacts : {};
|
||||
|
||||
return ((!wasWelcomed && this.state.show) && (contacts.length !== 0)) ? (
|
||||
<div className="ma4 pa2 bg-welcome-green bg-gray1-d white-d">
|
||||
<p className="f8 lh-copy">Each Group is a list of other Urbit IDs that share some set of modules: chats, links, and notebooks.</p>
|
||||
<p className="f8 pt2 dib pointer bb"
|
||||
onClick={(() => this.disableWelcome())}
|
||||
>
|
||||
Close this
|
||||
</p>
|
||||
</div>
|
||||
) : <div />;
|
||||
}
|
||||
}
|
||||
|
||||
export default Welcome;
|
@ -1,225 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { InviteSearch, Invites } from '~/views/components/InviteSearch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Toggle } from '~/views/components/toggle';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import { Groups, GroupPolicy, Resource } from '~/types/group-update';
|
||||
import { Contacts, Rolodex } from '~/types/contact-update';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { Patp, PatpNoSig, Enc } from '~/types/noun';
|
||||
|
||||
type NewScreenProps = Pick<RouteComponentProps, 'history'> & {
|
||||
groups: Groups;
|
||||
contacts: Rolodex;
|
||||
api: GlobalApi;
|
||||
};
|
||||
|
||||
type TextChange = React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>;
|
||||
type BooleanChange = React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
|
||||
interface NewScreenState {
|
||||
groupName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
invites: Invites;
|
||||
privacy: boolean;
|
||||
groupNameError: boolean;
|
||||
awaiting: boolean;
|
||||
}
|
||||
|
||||
export class NewScreen extends Component<NewScreenProps, NewScreenState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
groupName: '',
|
||||
title: '',
|
||||
description: '',
|
||||
invites: { ships: [], groups: [] },
|
||||
privacy: false,
|
||||
// color: '',
|
||||
groupNameError: false,
|
||||
awaiting: false,
|
||||
};
|
||||
|
||||
this.groupNameChange = this.groupNameChange.bind(this);
|
||||
this.descriptionChange = this.descriptionChange.bind(this);
|
||||
this.invChange = this.invChange.bind(this);
|
||||
this.groupPrivacyChange = this.groupPrivacyChange.bind(this);
|
||||
}
|
||||
|
||||
groupNameChange(event: TextChange) {
|
||||
const asciiSafe = event.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9~_.-]/g, '-');
|
||||
this.setState({
|
||||
groupName: asciiSafe,
|
||||
title: event.target.value,
|
||||
});
|
||||
}
|
||||
|
||||
descriptionChange(event: TextChange) {
|
||||
this.setState({ description: event.target.value });
|
||||
}
|
||||
|
||||
invChange(value: Invites) {
|
||||
this.setState({
|
||||
invites: value,
|
||||
});
|
||||
}
|
||||
|
||||
groupPrivacyChange(event: BooleanChange) {
|
||||
this.setState({
|
||||
privacy: event.target.checked,
|
||||
});
|
||||
}
|
||||
|
||||
onClickCreate() {
|
||||
const { props, state } = this;
|
||||
|
||||
if (!state.groupName) {
|
||||
this.setState({
|
||||
groupNameError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const aud = state.invites.ships.map((ship) => `~${ship}`);
|
||||
|
||||
const policy: Enc<GroupPolicy> = state.privacy
|
||||
? {
|
||||
invite: {
|
||||
pending: aud,
|
||||
},
|
||||
}
|
||||
: {
|
||||
open: {
|
||||
banRanks: [],
|
||||
banned: [],
|
||||
},
|
||||
};
|
||||
|
||||
const groupName = this.state.groupName.trim();
|
||||
this.setState(
|
||||
{
|
||||
invites: { ships: [], groups: [] },
|
||||
awaiting: true,
|
||||
},
|
||||
() => {
|
||||
props.api.contacts
|
||||
.create(groupName, policy, this.state.title, this.state.description)
|
||||
.then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
props.history.push(
|
||||
`/~groups/ship/~${window.ship}/${groupName}`
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
let groupNameErrElem = <span />;
|
||||
if (this.state.groupNameError) {
|
||||
groupNameErrElem = (
|
||||
<span className='f9 inter red2 ml3 mt1 db'>
|
||||
Group must have a name.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-100 w-100 mw6 pa3 pt4 overflow-x-hidden bg-gray0-d white-d flex flex-column'>
|
||||
<div className='w-100 dn-m dn-l dn-xl inter pt1 pb6 f8'>
|
||||
<Link to='/~groups/'>{'⟵ All Groups'}</Link>
|
||||
</div>
|
||||
<div className='w-100 mb4 pr6 pr0-l pr0-xl'>
|
||||
<h2 className='f8'>Create New Group</h2>
|
||||
<h2 className='f8 pt6'>Group Name</h2>
|
||||
<textarea
|
||||
className={
|
||||
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 mt2 ' +
|
||||
'focus-b--black focus-b--white-d'
|
||||
}
|
||||
rows={1}
|
||||
placeholder='Jazz Maximalists Research Unit'
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 48,
|
||||
paddingTop: 14,
|
||||
}}
|
||||
onChange={this.groupNameChange}
|
||||
/>
|
||||
{groupNameErrElem}
|
||||
<h2 className='f8 pt6'>
|
||||
Description <span className='gray2'>(Optional)</span>
|
||||
</h2>
|
||||
<textarea
|
||||
className={
|
||||
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 mt2 ' +
|
||||
'focus-b--black focus-b--white-d'
|
||||
}
|
||||
rows={1}
|
||||
placeholder='Two trumpeters and a microphone'
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 48,
|
||||
paddingTop: 14,
|
||||
}}
|
||||
onChange={this.descriptionChange}
|
||||
/>
|
||||
<div className='mv7'>
|
||||
<Toggle
|
||||
boolean={this.state.privacy}
|
||||
change={this.groupPrivacyChange}
|
||||
/>
|
||||
<span className='dib f9 white-d inter ml3'>Private Group</span>
|
||||
<p className='f9 gray2 pt1' style={{ paddingLeft: 40 }}>
|
||||
If private, new members must be invited
|
||||
</p>
|
||||
</div>
|
||||
{this.state.privacy && (
|
||||
<>
|
||||
<h2 className='f8 pt6'>
|
||||
Invite <span className='gray2'>(Optional)</span>
|
||||
</h2>
|
||||
<p className='f9 gray2 lh-copy'>
|
||||
Selected ships will be invited to your group
|
||||
</p>
|
||||
<div className='relative pb6 mt2'>
|
||||
<InviteSearch
|
||||
groups={{}}
|
||||
contacts={this.props.contacts}
|
||||
groupResults={false}
|
||||
shipResults={true}
|
||||
invites={this.state.invites}
|
||||
setInvite={this.invChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={this.onClickCreate.bind(this)}
|
||||
className='f9 ba pa2 b--green2 green2 pointer bg-transparent'
|
||||
>
|
||||
Start Group
|
||||
</button>
|
||||
<Link to='/~groups'>
|
||||
<button className='f9 ml3 ba pa2 b--black pointer bg-transparent b--white-d white-d'>
|
||||
Cancel
|
||||
</button>
|
||||
</Link>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes='mt4'
|
||||
text='Creating group...'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { GroupSidebar } from './lib/group-sidebar';
|
||||
import ErrorBoundary from '~/views/components/ErrorBoundary';
|
||||
|
||||
export class Skeleton extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
const rightPanelClasses =
|
||||
props.activeDrawer === 'groups' ? 'dn flex-m flex-l flex-xl' : 'flex';
|
||||
|
||||
return (
|
||||
<div className="h-100 w-100 ph4-m ph4-l ph4-xl pb4-m pb4-l pb4-xl">
|
||||
<div className="bg-white bg-gray0-d cf w-100 h-100 flex ba-m ba-l ba-xl b--gray4 b--gray1-d br1">
|
||||
<GroupSidebar
|
||||
contacts={props.contacts}
|
||||
groups={props.groups}
|
||||
invites={props.invites}
|
||||
activeDrawer={props.activeDrawer}
|
||||
selected={props.selected}
|
||||
history={props.history}
|
||||
api={props.api}
|
||||
associations={props.associations}
|
||||
/>
|
||||
<div
|
||||
className={'h-100 w-100 relative ' + rightPanelClasses}
|
||||
style={{ flexGrow: 1 }}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,12 +1,17 @@
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
import { Box, Row, Icon, Text, Center } from '@tlon/indigo-react';
|
||||
import { uxToHex, adjustHex } from "~/logic/lib/util";
|
||||
|
||||
import './css/custom.css';
|
||||
|
||||
import { Sigil } from "~/logic/lib/sigil";
|
||||
import Tiles from './components/tiles';
|
||||
import Tile from './components/tiles/tile';
|
||||
import Welcome from './components/welcome';
|
||||
import Groups from './components/Groups';
|
||||
|
||||
export default class LaunchApp extends React.Component {
|
||||
|
||||
@ -18,15 +23,57 @@ export default class LaunchApp extends React.Component {
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const contact = props.contacts?.['/~/default']?.[window.ship];
|
||||
const sigilColor = contact?.color
|
||||
? `#${uxToHex(contact.color)}`
|
||||
: props.dark
|
||||
? "#FFFFFF"
|
||||
: "#000000";
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>OS1 - Home</title>
|
||||
</Helmet>
|
||||
<div className="h-100 flex flex-column h-100">
|
||||
<div className='v-mid ph2 dtc-m dtc-l dtc-xl flex justify-between flex-wrap' style={{ maxWidth: '40rem' }}>
|
||||
<Welcome firstTime={props.launch.firstTime} api={props.api} />
|
||||
<div className="h-100 overflow-y-scroll">
|
||||
<Welcome firstTime={props.launch.firstTime} api={props.api} />
|
||||
<Box
|
||||
ml='2'
|
||||
display='grid'
|
||||
gridAutoRows='124px'
|
||||
gridTemplateColumns='repeat(auto-fit, 124px)'
|
||||
gridGap={3}
|
||||
p={2}
|
||||
>
|
||||
<Tile
|
||||
border={1}
|
||||
bg="#fff"
|
||||
borderColor="green"
|
||||
to="/~landscape/home"
|
||||
boxShadow='none'
|
||||
p={0}
|
||||
>
|
||||
<Box p={2} height='100%' width='100%' bg='washedGreen'>
|
||||
<Row alignItems='center'>
|
||||
<Icon
|
||||
color="green"
|
||||
fill="rgba(0,0,0,0)"
|
||||
icon="Circle"
|
||||
/>
|
||||
<Text ml="1" color="green">Home</Text>
|
||||
</Row>
|
||||
</Box>
|
||||
</Tile>
|
||||
<Tile
|
||||
borderColor={adjustHex(sigilColor, -40)}
|
||||
bg={sigilColor}
|
||||
to="/~profile"
|
||||
>
|
||||
<Center height="100%">
|
||||
<Sigil ship={`~${window.ship}`} size={80} color={sigilColor} />
|
||||
</Center>
|
||||
</Tile>
|
||||
<Tiles
|
||||
tiles={props.launch.tiles}
|
||||
tileOrdering={props.launch.tileOrdering}
|
||||
@ -34,8 +81,9 @@ export default class LaunchApp extends React.Component {
|
||||
location={props.userLocation}
|
||||
weather={props.weather}
|
||||
/>
|
||||
</div>
|
||||
<Box
|
||||
</Box>
|
||||
<Groups associations={props.associations} invites={props.invites} api={props.api}/>
|
||||
<Box
|
||||
position="absolute"
|
||||
fontFamily="mono"
|
||||
left="0"
|
||||
|
111
pkg/interface/src/views/apps/launch/components/Groups.tsx
Normal file
111
pkg/interface/src/views/apps/launch/components/Groups.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "@tlon/indigo-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
|
||||
import { Associations, Association } from "~/types";
|
||||
import { alphabeticalOrder } from "~/logic/lib/util";
|
||||
import Tile from '../components/tiles/tile';
|
||||
|
||||
interface GroupsProps {
|
||||
associations: Associations;
|
||||
}
|
||||
|
||||
// Sort by recent, then by channel size? Should probably sort
|
||||
// by num unreads when notif-store drops
|
||||
const sortGroupsRecent = (recent: string[]) => (
|
||||
a: Association,
|
||||
b: Association
|
||||
) => {
|
||||
//
|
||||
const aRecency = recent.findIndex((r) => a["group-path"] === r);
|
||||
const bRecency = recent.findIndex((r) => b["group-path"] === r);
|
||||
if(aRecency === -1) {
|
||||
if(bRecency === -1) {
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if(bRecency === -1) {
|
||||
return -1;
|
||||
}
|
||||
return Math.max(0, aRecency) - Math.max(0,bRecency);
|
||||
};
|
||||
|
||||
const sortGroupsAlph = (a: Association, b: Association) =>
|
||||
alphabeticalOrder(a.metadata.title, b.metadata.title);
|
||||
|
||||
export default function Groups(props: GroupsProps & Parameters<typeof Box>[0]) {
|
||||
const { associations, invites, api, ...boxProps } = props;
|
||||
const [recentGroups, setRecentGroups] = useLocalStorageState<string[]>(
|
||||
"recent-groups",
|
||||
[]
|
||||
);
|
||||
|
||||
const incomingGroups = Object.values(invites?.['/contacts'] || {});
|
||||
const getKeyByValue = (object, value) => {
|
||||
return Object.keys(object).find(key => object[key] === value);
|
||||
}
|
||||
|
||||
const groups = Object.values(associations?.contacts || {})
|
||||
.sort(sortGroupsAlph)
|
||||
.sort(sortGroupsRecent(recentGroups))
|
||||
|
||||
const acceptInvite = (invite) => {
|
||||
const [, , ship, name] = invite.path.split('/');
|
||||
const resource = { ship, name };
|
||||
return api.contacts.join(resource).then(() => {
|
||||
api.invite.accept('/contacts', getKeyByValue(invites['/contacts'], invite));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
ml='2'
|
||||
display="grid"
|
||||
gridAutoRows="124px"
|
||||
gridTemplateColumns="repeat(auto-fit, 124px)"
|
||||
gridGap={3}
|
||||
px={2}
|
||||
pt={2}
|
||||
pb="7"
|
||||
>
|
||||
{incomingGroups.map((invite) => (
|
||||
<Box
|
||||
height='100%'
|
||||
width='100%'
|
||||
bg='white'
|
||||
border='1'
|
||||
borderRadius='2'
|
||||
borderColor='lightGray'
|
||||
p='2'
|
||||
fontSize='0'
|
||||
>
|
||||
<Text display='block' pb='2' gray>You have been invited to:</Text>
|
||||
<Text display='inline-block' overflow='hidden' maxWidth='100%' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre' }} title={invite.path.slice(6)}>{invite.path.slice(6)}</Text>
|
||||
<Box pt='5'>
|
||||
<Text
|
||||
onClick={() => acceptInvite(invite)}
|
||||
color='blue'
|
||||
mr='2'
|
||||
cursor='pointer'>
|
||||
Accept
|
||||
</Text>
|
||||
<Text
|
||||
color='red'
|
||||
onClick={() => api.invite.decline('/contacts', getKeyByValue(invites['/contacts'], invite))}
|
||||
cursor='pointer'>
|
||||
Reject
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
{groups.map((group) => (
|
||||
<Tile to={`/~landscape${group["group-path"]}`}>
|
||||
<Text>{group.metadata.title}</Text>
|
||||
</Tile>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -10,7 +10,9 @@ export default class Tiles extends React.PureComponent {
|
||||
const { props } = this;
|
||||
|
||||
const tiles = props.tileOrdering.filter((key) => {
|
||||
return props.tiles[key].isShown;
|
||||
const tile = props.tiles[key];
|
||||
|
||||
return tile.isShown;
|
||||
}).map((key) => {
|
||||
const tile = props.tiles[key];
|
||||
if ('basic' in tile.type) {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user