Merge branch 'release/next-userspace' into lf/graph-publish-fe

This commit is contained in:
Liam Fitzgerald 2020-10-13 13:38:56 +10:00
commit 4551e16976
224 changed files with 5535 additions and 8399 deletions

View File

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

View File

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

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eab360913b845f8775002cfe1830defcd252b490ac90e8dfa093297b56531392
size 19090656
oid sha256:bfdea906d8d0493e0989faf4ef9f70580e1a797a113b2f89f45fffc2bbbab061
size 6254938

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1,5 @@
:: eyre: allow cors requests from origin
::
:- %say
|= [^ [=origin:eyre ~] ~]
[%helm-cors-approve origin]

View File

@ -0,0 +1,5 @@
:: eyre: disallow cors requests from origin
::
:- %say
|= [^ [=origin:eyre ~] ~]
[%helm-cors-reject origin]

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

View File

@ -100,5 +100,6 @@ export default class ChatReducer<S extends ChatState> {
mailbox.splice(index, 1);
}
}
state.pendingMessages.set(msg.path, mailbox);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,3 +17,4 @@ export * from './permission-update';
export * from './publish-response';
export * from './publish-update';
export * from './s3-update';
export * from './workspace';

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
interface GroupWorkspace {
type: 'group';
group: string;
}
interface HomeWorkspace {
type: 'home'
}
export type Workspace = HomeWorkspace | GroupWorkspace;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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