Merge branch 'master' into lf/global-skeleton

This commit is contained in:
Liam Fitzgerald 2020-09-17 12:17:53 +10:00
commit 94050f150e
150 changed files with 3900 additions and 2716 deletions

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- 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.

View File

@ -1,6 +1,6 @@
---
name: OS1 Bug report
about: 'Use this template to file a bug for any OS1 app: Chat, Publish, Links, Groups,
name: Landscape bug report
about: 'Use this template to file a bug for any Landscape app: Chat, Publish, Links, Groups,
Weather or Clock'
title: ''
labels: landscape

View File

@ -2,12 +2,14 @@
Thank you for your interest in contributing to Urbit.
See [urbit.org/docs/getting-started][start] for basic orientation and usage
See [urbit.org/using/install][start] for basic orientation and usage
instructions. You may also want to subscribe to [urbit-dev][list], the Urbit
development mailing list. For specific information on contributing to the Urbit
interface, see its [contribution guidelines][interface].
[start]: https://urbit.org/docs/getting-started/#arvo
For information on Arvo's maintainers, see [pkg/arvo][main].
[start]: https://urbit.org/using/install
[interface]: /pkg/interface/CONTRIBUTING.md
## Fake ships
@ -45,11 +47,10 @@ The canonical source tree is the `master` branch of
`master` when commencing new work; similarly, when we pull in your
contribution, we'll do so by merging it to `master`.
Since we use GitHub, it's helpful (though not required) to contribute via a
GitHub pull request. You can also post patches to the [mailing list][list],
email them to maintainers, or request a maintainer pull from your tree directly
-- but note that some maintainers will be more receptive to these methods than
others.
Since we use GitHub, we request you contribute via a GitHub pull request. Tag
the [maintainer][main] for the component. If you have a question for the
maintainer, you can direct message them from your Urbit ship using that
information.
When contributing changes, via whatever means, make sure you describe them
appropriately. You should attach a reasonably high-level summary of what the
@ -58,8 +59,8 @@ exist, e.g. a GitHub issue, a mailing list discussion, a UP, etc. [Here][jbpr]
is a good example of a pull request with a useful, concise description.
If your changes replace significant extant functionality, be sure to compare
them with the thing you're replacing. You may also want to cc maintainers,
reviewers, or other parties who might have a particular interest in what you're
them with the thing you're replacing. You may also want to cc reviewers,
or other parties who might have a particular interest in what you're
contributing.
[jbpr]: https://github.com/urbit/urbit/pull/1782
@ -283,3 +284,4 @@ Questions or other communications about contributing to Urbit can go to
[reba]: https://git-rebase.io/
[issu]: https://github.com/urbit/urbit/issues
[hoon]: https://urbit.org/docs/learn/hoon/style/
[main]: https://github.com/urbit/urbit/tree/master/pkg/arvo#maintainers

View File

@ -1,27 +1,38 @@
# Urbit
A personal server operating function.
[Urbit](https://urbit.org) is a personal server stack built from scratch. It
has an identity layer (Azimuth), virtual machine (Vere), and operating system
(Arvo).
> The Urbit address space, Azimuth, is now live on the Ethereum blockchain. You
> can find it at [`0x223c067f8cf28ae173ee5cafea60ca44c335fecb`][azim] or
> [`azimuth.eth`][aens]. Owners of Azimuth points (galaxies, stars, or planets)
> can view or manage them using [Bridge][brid], and can also use them to boot
> [Arvo][arvo], the Urbit OS.
A running Urbit "ship" is designed to operate with other ships peer-to-peer.
Urbit is a general-purpose, peer-to-peer computer and network.
This repository contains:
- The [Arvo OS][arvo]
- [herb][herb], a tool for Unix control of an Urbit ship
- Source code for [Landscape's web interface][land]
- Source code for the [vere][vere] virtual machine.
For more on the identity layer, see [Azimuth][azim]. To manage your Urbit
identity, use [Bridge][brid].
[azim]: https://etherscan.io/address/0x223c067f8cf28ae173ee5cafea60ca44c335fecb
[aens]: https://etherscan.io/address/azimuth.eth
[brid]: https://github.com/urbit/bridge
[arvo]: https://github.com/urbit/urbit/tree/master/pkg/arvo
[azim]: https://github.com/urbit/azimuth
[brid]: https://github.com/urbit/bridge
[herb]: https://github.com/urbit/urbit/tree/master/pkg/herb
[land]: https://github.com/urbit/urbit/tree/master/pkg/interface
[vere]: https://github.com/urbit/urbit/tree/master/pkg/urbit
## Install
To install and run Urbit, please follow the instructions at
[urbit.org/docs/getting-started/][start]. You'll be on the live network in a
[urbit.org/using/install][start]. You'll be on the live network in a
few minutes.
If you're interested in Urbit development, keep reading.
[start]: https://urbit.org/docs/getting-started/
[start]: https://urbit.org/using/install/
## Development
@ -38,7 +49,7 @@ The Makefile in the project's root directory contains useful phony targets for
building, installing, testing, and so on. You can use it to avoid dealing with
Nix explicitly.
To build Urbit, for example, use:
To build the Urbit virtual machine binary, for example, use:
```
make build
@ -68,12 +79,10 @@ Contributions of any form are more than welcome! Please take a look at our
[contributing guidelines][cont] for details on our git practices, coding
styles, how we manage issues, and so on.
You might also be interested in:
For instructions on contributing to Landscape, see [its][lcont] guidelines.
- joining the [urbit-dev][list] mailing list.
- [applying to Hoon School][mail], a course we run to teach the Hoon
programming language and Urbit application development.
You might also be interested in joining the [urbit-dev][list] mailing list.
[list]: https://groups.google.com/a/urbit.org/forum/#!forum/dev
[mail]: mailto:support@urbit.org
[cont]: https://github.com/urbit/urbit/blob/master/CONTRIBUTING.md
[lcont]: https://github.com/urbit/urbit/blob/master/pkg/interface/CONTRIBUTING.md

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cfb556a9e6b473f6cf6c75b30a3b12cb986e57df1600dad4383b9d3380cffdb6
size 6263010
oid sha256:06808af2c089441d2cb497fc95e3292b6229b3dfa034272d46c7c41f34eb6a3b
size 6268465

View File

@ -41,20 +41,20 @@ Most parts of Arvo have dedicated maintainers.
* `/sys/hoon`: @pilfer-pandex (~pilfer-pandex)
* `/sys/zuse`: @pilfer-pandex (~pilfer-pandex)
* `/sys/arvo`: @jtobin (~nidsut-tomdun)
* `/sys/vane/ames`: @belisarius222 (~rovnys-ricfer) & @joemfb (~master-morzod)
* `/sys/arvo`: @joemfb (~master-morzod)
* `/sys/vane/ames`: @belisarius222 (~rovnys-ricfer) & @philipcmonk (~wicdev-wisryt)
* `/sys/vane/behn`: @belisarius222 (~rovnys-ricfer)
* `/sys/vane/clay`: @philipcmonk (~wicdev-wisryt)
* `/sys/vane/dill`: @bernardodelaplaz (~rigdyn-sondur)
* `/sys/vane/clay`: @philipcmonk (~wicdev-wisryt) & @belisarius222 (~rovnys-ricfer)
* `/sys/vane/dill`: @joemfb (~master-morzod)
* `/sys/vane/eyre`: @eglaysher (~littel-ponnys)
* `/sys/vane/ford`: @belisarius222 (~rovnys-ricfer) & @eglaysher (~littel-ponnys)
* `/sys/vane/gall`: @jtobin (~nidsut-tomdun)
* `/sys/vane/jael`: @fang- (~palfun-foslup) & @joemfb (~master-morzod)
* `/sys/vane/gall`: @philipcmonk (~wicdev-wisryt)
* `/sys/vane/jael`: @fang- (~palfun-foslup) & @philipcmonk (~wicdev-wisryt)
* `/app/acme`: @joemfb (~master-morzod)
* `/app/dns`: @joemfb (~master-morzod)
* `/app/hall`: @fang- (~palfun-foslup)
* `/app/talk`: @fang- (~palfun-foslup)
* `/app/aqua`: @philipcmonk (~wicdev-wisryt)
* `/app/hood`: @belisarius222 (~rovnys-ricfer)
* `/lib/hood/drum`: @philipcmonk (~wicdev-wisryt)
* `/lib/hood/kiln`: @philipcmonk (~wicdev-wisryt)
* `/lib/test`: @eglaysher (~littel-ponnys)
## Contributing

View File

@ -1,4 +1,4 @@
:: chat-hook:
:: chat-hook [landscape]:
:: mirror chat data from foreign to local based on read permissions
:: allow sending chat messages to foreign paths based on write perms
::

View File

@ -1,4 +1,6 @@
:: chat-store: data store that holds linear sequences of chat messages
:: chat-store [landscape]:
::
:: data store that holds linear sequences of chat messages
::
/+ store=chat-store, default-agent, verb, dbug, group-store
~% %chat-store-top ..is ~

View File

@ -1,4 +1,6 @@
:: chat-view: sets up chat JS client, paginates data, and combines commands
:: chat-view [landscape]:
::
:: sets up chat JS client, paginates data, and combines commands
:: into semantic actions for the UI
::
/- *permission-store,
@ -193,7 +195,6 @@
=/ pax t.t.t.t.site.url
=/ envelopes (envelope-scry [(scot %ud start) (scot %ud end) pax])
%- json-response:gen
%- json-to-octs
%- update:enjs:store
[%messages pax start end envelopes]
==
@ -296,6 +297,7 @@
~[(chat-hook-poke %add-synced ship.act app-path.act ask-history.act)]
=/ rid=resource
(de-path:resource ship+app-path.act)
?: =(our.bol entity.rid) ~
=/ =cage
:- %group-update
!> ^- action:group-store

View File

@ -1,4 +1,6 @@
:: clock: deprecated, should be removed
:: clock [landscape]:
::
:: deprecated, should be removed
::
/+ *server, default-agent, verb, dbug
=, format

View File

@ -1,4 +1,5 @@
:: contact-hook:
:: contact-hook [landscape]
::
::
/- group-hook,
*contact-hook,

View File

@ -1,4 +1,6 @@
:: contact-store: data store that holds group-based contact data
:: contact-store [landscape]:
::
:: data store that holds group-based contact data
::
/+ *contact-json, default-agent, dbug
|%

View File

@ -1,4 +1,6 @@
:: contact-view: sets up contact JS client and combines commands
:: contact-view [landscape]:
::
:: sets up contact JS client and combines commands
:: into semantic actions for the UI
::
/-

View File

@ -148,9 +148,7 @@
::
=; json=(unit json)
?~ json not-found:gen
%- json-response:gen
=, html
(as-octt:mimes (en-json u.json))
(json-response:gen u.json)
=, enjs:format
?+ site ~
:: /apps.json: {appname: running?}

View File

@ -1,3 +1,7 @@
:: file-server [landscape]:
::
:: mounts HTTP endpoints for Landscape (and third-party) user applications
::
/- srv=file-server, glob
/+ *server, default-agent, verb, dbug
|%

View File

@ -1,7 +1,11 @@
:: glob [landscape]:
::
:: prompts content delivery and Gall state storage for Landscape JS blob
::
/- glob
/+ default-agent, verb, dbug
|%
++ hash 0v6.8fpt6.7mcjg.nb019.df3fo.haav6
++ hash 0v3.u1ets.ipgbo.eo23m.md70h.djpj0
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states
$% state-0

View File

@ -1,3 +1,6 @@
:: graph-store [landscape]
::
::
/+ store=graph-store, sigs=signatures, res=resource, default-agent, dbug
~% %graph-store-top ..is ~
|%
@ -447,16 +450,29 @@
|^
?> (team:title our.bowl src.bowl)
?+ path (on-peek:def path)
[%x %keys ~] ``noun+!>(~(key by graphs))
[%x %tags ~] ``noun+!>(~(key by tag-queries))
[%x %tag-queries ~] ``noun+!>(tag-queries)
[%x %keys ~]
:- ~ :- ~ :- %graph-update
!>(`update:store`[%0 now.bowl [%keys ~(key by graphs)]])
::
[%x %tags ~]
:- ~ :- ~ :- %graph-update
!>(`update:store`[%0 now.bowl [%tags ~(key by tag-queries)]])
::
[%x %tag-queries ~]
:- ~ :- ~ :- %graph-update
!>(`update:store`[%0 now.bowl [%tag-queries tag-queries]])
::
[%x %graph @ @ ~]
=/ =ship (slav %p i.t.t.path)
=/ =term i.t.t.t.path
=/ result=(unit marked-graph:store)
(~(get by graphs) [ship term])
?~ result [~ ~]
``noun+!>(u.result)
:- ~ :- ~ :- %graph-update
!> ^- update:store
:+ %0
now.bowl
[%add-graph [ship term] `graph:store`p.u.result q.u.result]
::
[%x %graph-subset @ @ @ @ ~]
=/ =ship (slav %p i.t.t.path)
@ -466,7 +482,16 @@
=/ graph=(unit marked-graph:store)
(~(get by graphs) [ship term])
?~ graph [~ ~]
``noun+!>(`graph:store`(subset:orm p.u.graph start end))
:- ~ :- ~ :- %graph-update
!> ^- update:store
:+ %0 now.bowl
:+ %add-nodes
[ship term]
%- ~(gas by *(map index:store node:store))
%+ turn (tap:orm `graph:store`(subset:orm p.u.graph start end))
|= [=atom =node:store]
^- [index:store node:store]
[~[atom] node]
::
[%x %node @ @ @ *]
=/ =ship (slav %p i.t.t.path)
@ -475,28 +500,13 @@
(turn t.t.t.t.path |=(=cord (slav %ud cord)))
=/ node=(unit node:store) (get-node ship term index)
?~ node [~ ~]
``noun+!>(u.node)
::
[%x %post @ @ @ *]
=/ =ship (slav %p i.t.t.path)
=/ =term i.t.t.t.path
=/ =index:store
(turn t.t.t.t.path |=(=cord (slav %ud cord)))
=/ node=(unit node:store) (get-node ship term index)
?~ node [~ ~]
``noun+!>(post.u.node)
::
[%x %node-children @ @ @ *]
=/ =ship (slav %p i.t.t.path)
=/ =term i.t.t.t.path
=/ =index:store
(turn t.t.t.t.path |=(=cord (slav %ud cord)))
=/ node=(unit node:store) (get-node ship term index)
?~ node [~ ~]
?- -.children.u.node
%empty [~ ~]
%graph ``noun+!>(p.children.u.node)
==
:- ~ :- ~ :- %graph-update
!> ^- update:store
:+ %0
now.bowl
:+ %add-nodes
[ship term]
(~(gas by *(map index:store node:store)) [index u.node] ~)
::
[%x %node-children-subset @ @ @ @ @ *]
=/ =ship (slav %p i.t.t.path)
@ -509,7 +519,18 @@
?~ node [~ ~]
?- -.children.u.node
%empty [~ ~]
%graph ``noun+!>(`graph:store`(subset:orm p.children.u.node start end))
%graph
:- ~ :- ~ :- %graph-update
!> ^- update:store
:+ %0
now.bowl
:+ %add-nodes
[ship term]
%- ~(gas by *(map index:store node:store))
%+ turn (tap:orm `graph:store`(subset:orm p.children.u.node start end))
|= [=atom =node:store]
^- [index:store node:store]
[(snoc index atom) node]
==
::
[%x %update-log @ @ ~]

View File

@ -1,4 +1,6 @@
:: group-hook: allow syncing group data from foreign paths to local paths
:: group-hook [landscape]:
::
:: allow syncing group data from foreign paths to local paths
::
/- *group, hook=group-hook, *invite-store
/+ default-agent, verb, dbug, store=group-store, grpl=group, pull-hook, push-hook, resource

View File

@ -1,5 +1,6 @@
:: group-hook: allow syncing group data from foreign paths to local paths
:: group-hook [landscape]:
::
:: allow syncing group data from foreign paths to local paths
::
/- *group, hook=group-hook, *invite-store, *resource
/+ default-agent, verb, dbug, store=group-store, grpl=group, pull-hook

View File

@ -1,5 +1,6 @@
:: group-hook: allow syncing group data from foreign paths to local paths
:: group-hook [landscape]:
::
:: allow syncing group data from foreign paths to local paths
::
/- *group, hook=group-hook, *invite-store
/+ default-agent, verb, dbug, store=group-store, grpl=group, push-hook,

View File

@ -1,4 +1,6 @@
:: group-store: Store groups of ships
:: group-store [landscape]:
::
:: Store groups of ships
::
:: group-store stores groups of ships, so that resources in other apps can be
:: associated with a group. The current model of group-store rolls

View File

@ -1,4 +1,6 @@
:: invite-hook: receive invites from any source
:: invite-hook [landscape]:
::
:: receive invites from any source
::
:: only handles %invite actions. accepts json, but only from the host team.
:: can be poked by the host team to send an invite out to someone.

View File

@ -1,3 +1,4 @@
:: invite-store [landscape]
/+ *invite-json, default-agent, dbug
|%
+$ card card:agent:gall

View File

@ -1,3 +1,7 @@
:: invite-view [landscape]:
::
:: deprecated
::
/+ default-agent
^- agent:gall
|_ =bowl:gall

View File

@ -23,7 +23,7 @@
<div id="root"></div>
<script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.js"></script>
<script src="/~landscape/js/bundle/index.5c70804296e13e8973c3.js"></script>
<script src="/~landscape/js/bundle/index.df81e597349a655b83f2.js"></script>
<script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script>
</body>
</html>

View File

@ -113,7 +113,7 @@
++ json-response
|= [eyre-id=@ta jon=json]
^- (list card)
(give-simple-payload:app eyre-id (json-response:gen (json-to-octs jon)))
(give-simple-payload:app eyre-id (json-response:gen jon))
::
++ give-rpc-notification
|= res=out:notification:lsp-sur

View File

@ -1,3 +1,7 @@
:: launch [landscape]:
::
:: registers Landscape (and third party) applications, tiles
::
/+ store=launch-store, default-agent, dbug
|%
+$ card card:agent:gall

View File

@ -136,7 +136,7 @@
::
:_ this
%+ give-simple-payload:app eyre-id.u.job.state
(json-response:gen (json-to-octs jon))
(json-response:gen jon)
::
++ take-sole-effect
|= fec=sole-effect
@ -186,7 +186,7 @@
%+ give-simple-payload:app eyre-id.u.job.state
?- -.u.out
%json
(json-response:gen (json-to-octs json.u.out))
(json-response:gen json.u.out)
::
%mime
=/ headers

View File

@ -1,4 +1,6 @@
:: link-listen-hook: get your friends' bookmarks
:: link-listen-hook [landscape]:
::
:: get your friends' bookmarks
::
:: keeps track of a listening=(set app-path). users can manually add to and
:: remove from this set.

View File

@ -1,4 +1,6 @@
:: link-proxy-hook: make local pages available to foreign ships
:: link-proxy-hook [landscape]:
::
:: make local pages available to foreign ships
::
:: this is a "proxy" style hook, relaying foreign subscriptions into local
:: stores if permission conditions are met.

View File

@ -1,4 +1,6 @@
:: link: social bookmarking
:: link [landscape]:
::
:: social bookmarking
::
:: the paths under which links are submitted are generally expected to
:: correspond to existing group paths. for strictly-local collections of

View File

@ -1,4 +1,6 @@
:: link-view: frontend endpoints
:: link-view [landscape]:
::
::frontend endpoints
::
:: endpoints, mapping onto link-store's paths. p is for page as in pagination.
:: only the /0/submissions endpoint provides updates.

View File

@ -1,4 +1,6 @@
:: metadata-hook: allow syncing foreign metadata
:: metadata-hook [landscape]:
::
:: allow syncing foreign metadata
::
:: watch paths:
:: /group/%group-path all updates related to this group

View File

@ -1,4 +1,6 @@
:: metadata-store: data store for application metadata and mappings
:: metadata-store [landscape]:
::
:: data store for application metadata and mappings
:: between groups and resources within applications
::
:: group-paths are expected to be an existing group path

View File

@ -1,4 +1,6 @@
:: permission-group-hook: groups into permissions
:: permission-group-hook [landscape]:
::
:: groups into permissions
::
:: mirror the ships in specified groups to specified permission paths
::

View File

@ -1,4 +1,6 @@
:: permission-hook: mirror remote permissions
:: permission-hook [landscape]:
::
:: mirror remote permissions
::
:: allows mirroring permissions between local and foreign ships.
:: local permission path are exposed according to the permssion paths

View File

@ -1,4 +1,6 @@
:: permission-store: track black- and whitelists of ships
:: permission-store [landscape]:
::
:: track black- and whitelists of ships
::
/- *permission-store
/+ default-agent, verb, dbug

View File

@ -1,4 +1,6 @@
:: pool-group-hook: maintain groups based on invite pool
:: pool-group-hook [landscape]:
::
:: maintain groups based on invite pool
::
:: looks at our invite tree, adds our siblings to group at +group-path
::

View File

@ -1,3 +1,7 @@
:: publish [landscape]
::
:: stores notebooks in clay, subscribes and allow subscriptions to notebooks
::
/- *publish
/- *group
/- group-hook
@ -2347,7 +2351,6 @@
:: all notebooks, short form
[[[~ %json] [%'publish-view' %notebooks ~]] ~]
%- json-response:gen
%- json-to-octs
(notebooks-map:enjs our.bol books)
::
:: notes pagination
@ -2366,7 +2369,6 @@
?~ length
not-found:gen
%- json-response:gen
%- json-to-octs
:- %o
(notes-page:enjs notes.u.book u.start u.length)
::
@ -2390,7 +2392,6 @@
?~ length
not-found:gen
%- json-response:gen
%- json-to-octs
(comments-page:enjs comments.u.note u.start u.length)
::
:: single notebook with initial 50 notes in short form, as json
@ -2409,7 +2410,7 @@
(~(put by p.notebook-json) %subscribers (get-subscribers-json book-name))
=. p.notebook-json
(~(put by p.notebook-json) %writers (get-writers-json u.host book-name))
(json-response:gen (json-to-octs (pairs notebook+notebook-json ~)))
(json-response:gen (pairs notebook+notebook-json ~))
::
:: single note, with initial 50 comments, as json
[[[~ %json] [%'publish-view' @ @ @ ~]] ~]
@ -2424,7 +2425,7 @@
?~ note not-found:gen
=/ jon=json
o+(note-presentation:enjs u.book note-name u.note)
(json-response:gen (json-to-octs jon))
(json-response:gen jon)
==
::
--

View File

@ -1,3 +1,7 @@
:: s3-store [landscape]:
::
:: stores s3 keys for uploading and sharing images and objects
::
/- *s3
/+ s3-json, default-agent, verb, dbug
~% %s3-top ..is ~

View File

@ -1,5 +1,6 @@
::
:: Soto: A Dojo relay for Urbit's Landscape interface
:: soto [landscape]: A Dojo relay for Urbit's Landscape interface
::
:: Relays sole-effects to subscribers and forwards sole-action pokes
::
/- sole

View File

@ -1,3 +1,7 @@
:: weather [landscape]:
::
:: holds latlong, gets weather data from API, passes it on to subscribers
::
/+ *server, default-agent, verb, dbug
=, format
::

View File

@ -247,20 +247,24 @@
|%
++ decode
%- of
:~ [%add-graph add-graph]
[%remove-graph remove-graph]
[%add-nodes add-nodes]
:~ [%add-nodes add-nodes]
[%remove-nodes remove-nodes]
[%add-signatures add-signatures]
[%remove-signatures remove-signatures]
::
[%add-graph add-graph]
[%remove-graph remove-graph]
::
[%add-tag add-tag]
[%remove-tag remove-tag]
::
[%archive-graph archive-graph]
[%unarchive-graph unarchive-graph]
[%run-updates run-updates]
::
[%keys keys]
[%tags tags]
[%tag-queries tag-queries]
[%run-updates run-updates]
==
::
++ add-graph

View File

@ -1,3 +1,23 @@
:: lib/pull-hook: helper for creating a push hook
::
:: lib/pull-hook is a helper for automatically pulling data from a
:: corresponding push-hook to a store.
::
:: ## Interfacing notes:
::
:: The inner door may interact with the library by producing cards.
:: Do not pass any cards on a wire beginning with /helper as these
:: wires are reserved by this library. Any watches/pokes/peeks not
:: listed below will be routed to the inner door.
::
:: ## Subscription paths
::
:: /tracking: The set of resources we are pulling
::
:: ## Pokes
::
:: %pull-hook-action: Add/remove a resource from pulling.
::
/- *pull-hook
/+ default-agent, resource
::
@ -5,6 +25,12 @@
|%
+$ card card:agent:gall
::
:: $config: configuration for the pull hook
::
:: .store-name: name of the store to send subscription updates to.
:: .update-mark: mark that updates will be tagged with
:: .push-hook-name: name of the corresponding push-hook
::
+$ config
$: store-name=term
update=mold
@ -12,6 +38,12 @@
push-hook-name=term
==
::
:: $state-0: state for the pull hook
::
:: .tracking: a map of resources we are pulling, and the ships that
:: we are pulling them from.
:: .inner-state: state given to internal door
::
+$ state-0
$: %0
tracking=(map resource ship)
@ -37,7 +69,29 @@
|* config
$_ ^|
|_ bowl:gall
:: +on-pull-nack: handle failed pull subscription
::
:: This arm is called when a pull subscription fails. lib/pull-hook
:: will automatically delete the resource from .tracking by the
:: time this arm is called.
::
++ on-pull-nack
|~ [resource tang]
*[(list card) _^|(..on-init)]
:: +on-pull-kick: produce any additional resubscribe path
::
:: If non-null, the produced path is appended to the original
:: subscription path. This should be used to encode extra
:: information onto the path in order to reduce the payload of a
:: kick and resubscribe.
::
:: If null, a resubscribe is not attempted
::
++ on-pull-kick
|~ resource
*(unit path)
::
:: from agent:gall
++ on-init
*[(list card) _^|(..on-init)]
::
@ -75,26 +129,6 @@
++ on-fail
|~ [term tang]
*[(list card) _^|(..on-init)]
:: +on-pull-nack: handle failed pull subscription
::
:: This arm is called when a pull subscription fails.
::
++ on-pull-nack
|~ [resource tang]
*[(list card) _^|(..on-init)]
:: +on-pull-kick: produce any additional resubscribe path
::
:: If non-null, the produced path is appended to the original
:: subscription path. This should be used to encode extra
:: information onto the path in order to reduce the payload of a
:: kick and resubscribe.
::
:: If null, a resubscribe is not attempted
::
++ on-pull-kick
|~ resource
*(unit path)
:: ::
--
++ agent
|* =config
@ -209,7 +243,10 @@
=^ cards pull-hook
(on-fail:og term tang)
[cards this]
++ on-peek on-peek:def
++ on-peek
|= =path
^- (unit (unit cage))
(on-peek:og path)
--
|_ =bowl:gall
+* og ~(. pull-hook bowl)
@ -225,6 +262,7 @@
++ add
|= [=ship =resource]
~| resource
?< |(=(our.bowl ship) =(our.bowl entity.resource))
?: (~(has by tracking) resource)
[~ state]
=. tracking

View File

@ -1,8 +1,41 @@
:: lib/push-hook: helper for creating a push hook
::
:: lib/push-hook is a helper for automatically pushing data from a
:: local store to the corresponding pull-hook on remote ships. It also
:: proxies remote pokes to the store.
::
:: ## Interfacing notes:
::
:: The inner door may interact with the library by producing cards.
:: Do not pass any cards on a wire beginning with /helper as these
:: wires are reserved by this library. Any watches/pokes/peeks not
:: listed below will be routed to the inner door.
::
:: ## Subscription paths
::
:: /resource/[resource]: Receive initial state and updates to
:: .resource. .resource should be encoded with en-path:resource from
:: /lib/resource. Facts on this path will be of mark
:: update-mark.config
::
:: ## Pokes
::
:: %push-hook-action: Add/remove a resource from pushing.
:: [update-mark.config]: A poke to proxy to the local store
::
/- *push-hook
/+ default-agent, resource
|%
+$ card card:agent:gall
::
:: $config: configuration for the push hook
::
:: .store-name: name of the store to proxy pokes and
:: subscriptions to
:: .store-path: subscription path to receive updates on
:: .update-mark: mark that updates will be tagged with
:: .pull-hook-name: name of the corresponding pull-hook
::
+$ config
$: store-name=term
store-path=path
@ -10,6 +43,12 @@
update-mark=term
pull-hook-name=term
==
::
:: $state-0: state for the push hook
::
:: .sharing: resources that the push hook is proxying
:: .inner-state: state given to internal door
::
+$ state-0
$: %0
sharing=(set resource)
@ -21,6 +60,48 @@
$_ ^|
|_ bowl:gall
::
:: +resource-for-update: get affected resource from an update
::
:: Given a vase of the update, the mark of which is
:: update-mark.config, produce the affected resource, if any.
::
++ resource-for-update
|~ vase
*(unit resource)
::
:: +take-update: handle update from store
::
:: Given an update from the store, do other things after proxying
:: the update
::
++ take-update
|~ vase
*[(list card) _^|(..on-init)]
:: +should-proxy-update: should forward update to store
::
:: If %.y is produced, then the update is forwarded to the local
:: store. If %.n is produced then the update is not forwarded and
:: the poke fails.
::
++ should-proxy-update
|~ vase
*?
:: +initial-watch: produce initial state for a subscription
::
:: .resource is the resource being subscribed to.
:: .path is any additional information in the subscription wire.
:: This would typically be used to encode state that the subscriber
:: already has. For example, a chat client might encode
:: the number of messages that it already has, or the date it last
:: received an update.
::
:: If +initial-watch crashes, the subscription fails.
::
++ initial-watch
|~ [path resource]
*vase
:: from agent:gall
::
++ on-init
*[(list card) _^|(..on-init)]
::
@ -58,35 +139,6 @@
++ on-fail
|~ [term tang]
*[(list card) _^|(..on-init)]
:: +resource-for-update: get affected resource from an update
++ resource-for-update
|~ vase
*(unit resource)
::
:: +on-update: handle update from store
::
:: Do extra stuff on store update
++ take-update
|~ vase
*[(list card) _^|(..on-init)]
:: +should-proxy-update: should forward update to store
::
:: If %.y is produced, then the update is forwarded to the local
:: store. If %.n is produced then the update is not forwarded and
:: the poke fails.
::
++ should-proxy-update
|~ vase
*?
:: +initial-watch: produce initial state for a subscription
::
:: .resource is the resource being subscribed to.
:: .path is any additional information in the subscription wire
::
++ initial-watch
|~ [path resource]
*vase
::
--
++ agent
|* =config

View File

@ -92,9 +92,9 @@
[[200 [['content-type' 'text/javascript'] max-1-da ~]] `octs]
::
++ json-response
|= =octs
|= =json
^- simple-payload:http
[[200 ['content-type' 'application/json']~] `octs]
[[200 ['content-type' 'application/json']~] `(json-to-octs json)]
::
++ css-response
|= =octs

30
pkg/arvo/ted/diff.hoon Normal file
View File

@ -0,0 +1,30 @@
/- spider
/+ strandio
=, strand=strand:spider
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
|^
=+ !<([=a=path =b=path ~] arg)
=/ a-mark=mark -:(flop a-path)
=/ b-mark=mark -:(flop b-path)
?. =(a-mark b-mark)
(strand-fail:strandio %files-not-same-type ~)
=/ a-beam (need (de-beam:format a-path))
;< =a=cage bind:m (get-file a-path)
;< =b=cage bind:m (get-file b-path)
;< =dais:clay bind:m (build-mark:strandio -.a-beam a-mark)
(pure:m (~(diff dais q.a-cage) q.b-cage))
::
++ get-file
|= =path
=/ m (strand ,cage)
^- form:m
=/ beam (need (de-beam:format path))
;< =riot:clay bind:m
(warp:strandio p.beam q.beam ~ %sing %x r.beam (flop s.beam))
?~ riot
(strand-fail:strandio %file-not-found >path< ~)
(pure:m r.u.riot)
--

17
pkg/interface/README.md Normal file
View File

@ -0,0 +1,17 @@
## interface
Landscape is Tlon's suite of userspace applications (and web interface),
currently bundled as part of Arvo.
This directory comprises the source code for the web interface. For code related
to the Gall agents that make up the Landscape suite in Arvo, see
[pkg/arvo][arvo].
### Contributions and feature requests
For information on how to contribute, see [CONTRIBUTING][cont]. To submit
a feature request, submit to the product board at [urbit/landscape][land].
[arvo]: https://github.com/urbit/urbit/tree/master/pkg/arvo
[cont]: https://github.com/urbit/urbit/blob/master/pkg/interface/CONTRIBUTING.md
[land]: https://github.com/urbit/landscape/issues/new?assignees=&labels=feature+request&template=feature_request.md&title=

View File

@ -6383,11 +6383,6 @@
"p-is-promise": "^2.0.0"
}
},
"memoize-one": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz",
"integrity": "sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA=="
},
"memory-fs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
@ -6807,6 +6802,11 @@
"tslib": "^1.10.0"
}
},
"node-fetch": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
},
"node-forge": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz",
@ -7058,6 +7058,14 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"dev": true
},
"oembed-parser": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/oembed-parser/-/oembed-parser-1.4.1.tgz",
"integrity": "sha512-1KqnfrXF3TiAQhJ9+vv3dEtMhPSVSOT9D9XPqLjEtaQg5liPc3LQ65YjgKHo7Z/YY/kmZ1PDb5gMcOxxCPPdBA==",
"requires": {
"node-fetch": "^2.6.0"
}
},
"omit-deep": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/omit-deep/-/omit-deep-0.3.0.tgz",
@ -7937,6 +7945,14 @@
"xtend": "^4.0.1"
}
},
"react-oembed-container": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-oembed-container/-/react-oembed-container-1.0.0.tgz",
"integrity": "sha512-YppvCDgxZkn6qgwAIpxRtmMtxaMpau8yQhm8nzmH7yHpDapmHxzakXvQke5qPfmdYyYW4CsKDfVfGoX14NvQkw==",
"requires": {
"prop-types": "^15.6.0"
}
},
"react-router": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz",
@ -7973,13 +7989,13 @@
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz",
"integrity": "sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg=="
},
"react-window": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.5.tgz",
"integrity": "sha512-HeTwlNa37AFa8MDZFZOKcNEkuF2YflA0hpGPiTT9vR7OawEt+GZbfM6wqkBahD3D3pUjIabQYzsnY/BSJbgq6Q==",
"react-virtuoso": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-0.20.0.tgz",
"integrity": "sha512-h+U6t/+m91AzfUe6bBfaacdLLJl1y8v7CfcXwPgQ/Dic+vNlgQmi6cIKTq18zuF+kI8Q7QN0ojIeqPHWbU8TZA==",
"requires": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
"resize-observer-polyfill": "^1.5.1",
"tslib": "^1.11.1"
}
},
"readable-stream": {
@ -8260,6 +8276,11 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
"dev": true
},
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",

View File

@ -20,6 +20,7 @@
"moment": "^2.20.1",
"mousetrap": "^1.6.5",
"mousetrap-global-bind": "^1.1.0",
"oembed-parser": "^1.4.1",
"prop-types": "^15.7.2",
"react": "^16.5.2",
"react-codemirror2": "^6.0.1",
@ -29,8 +30,9 @@
"react-dom": "^16.8.6",
"react-helmet": "^6.1.0",
"react-markdown": "^4.3.1",
"react-oembed-container": "^1.0.0",
"react-router-dom": "^5.0.0",
"react-window": "^1.8.5",
"react-virtuoso": "^0.20.0",
"remark-disable-tokenizers": "^1.0.24",
"style-loader": "^1.2.1",
"styled-components": "^5.1.0",

View File

@ -12,7 +12,7 @@ export default class ChatApi extends BaseApi<StoreState> {
* Fetch backlog
*/
fetchMessages(start: number, end: number, path: Path) {
fetch(`/chat-view/paginate/${start}/${end}${path}`)
return fetch(`/chat-view/paginate/${start}/${end}${path}`)
.then(response => response.json())
.then((json) => {
this.store.handleEvent({

View File

@ -11,6 +11,7 @@ import GroupsApi from './groups';
import LaunchApi from './launch';
import LinksApi from './links';
import PublishApi from './publish';
import GraphApi from './graph';
import S3Api from './s3';
export default class GlobalApi extends BaseApi<StoreState> {
@ -24,10 +25,15 @@ export default class GlobalApi extends BaseApi<StoreState> {
links = new LinksApi(this.ship, this.channel, this.store);
publish = new PublishApi(this.ship, this.channel, this.store);
s3 = new S3Api(this.ship, this.channel, this.store);
graph = new GraphApi(this.ship, this.channel, this.store);
constructor(public ship: Patp, public channel: any, public store: GlobalStore) {
super(ship,channel,store);
constructor(
public ship: Patp,
public channel: any,
public store: GlobalStore
) {
super(ship, channel, store);
}
}

View File

@ -0,0 +1,132 @@
import BaseApi from './base';
import { StoreState } from '../store/type';
import { Patp, Path, PatpNoSig } from '~/types/noun';
export const createPost = (contents: Object[], parentIndex: string = '') => {
return {
author: `~${window.ship}`,
index: parentIndex + '/' + Date.now(),
'time-sent': Date.now(),
contents,
hash: null,
signatures: []
};
};
export default class GraphApi extends BaseApi<StoreState> {
private storeAction(action: any): Promise<any> {
return this.action('graph-store', 'graph-update', action)
}
addGraph(ship: Patp, name: string, graph: any, mark: any) {
this.storeAction({
'add-graph': {
resource: { ship, name },
graph,
mark
}
});
}
removeGraph(ship: Patp, name: string) {
this.storeAction({
'remove-graph': {
resource: { ship, name }
}
});
}
addPost(ship: Patp, name: string, post: Object) {
let nodes = {};
nodes[post.index] = {
post,
children: { empty: null }
};
this.storeAction({
'add-nodes': {
resource: { ship, name },
nodes
}
});
}
addNodes(ship: Patp, name: string, nodes: Object) {
this.storeAction({
'add-nodes': {
resource: { ship, name },
nodes
}
});
}
removeNodes(ship: Patp, name: string, indices: string[]) {
this.storeAction({
'remove-nodes': {
resource: { ship, name },
indices
}
});
}
getKeys() {
this.scry<any>('graph-store', '/keys')
.then((keys) => {
this.store.handleEvent({
data: keys
});
});
}
getTags() {
this.scry<any>('graph-store', '/tags')
.then((tags) => {
this.store.handleEvent({
data: tags
});
});
}
getTagQueries() {
this.scry<any>('graph-store', '/tag-queries')
.then((tagQueries) => {
this.store.handleEvent({
data: tagQueries
});
});
}
getGraph(ship: string, resource: string) {
this.scry<any>('graph-store', `/graph/${ship}/${resource}`)
.then((graph) => {
this.store.handleEvent({
data: graph
});
});
}
getGraphSubset(ship: string, resource: string, start: string, end: start) {
this.scry<any>(
'graph-store',
`/graph-subset/${ship}/${resource}/${end}/${start}`
).then((subset) => {
this.store.handleEvent({
data: subset
});
});
}
getNode(ship: string, resource: string, index: string) {
this.scry<any>(
'graph-store',
`/node/${ship}/${resource}/${index}`
).then((node) => {
this.store.handleEvent({
data: node
});
});
}
}

View File

@ -44,8 +44,8 @@ export default class LinksApi extends BaseApi<StoreState> {
this.fetchLink(
endpoint,
(res) => {
if (res.data.submission) {
callback(res.data.submission);
if (res.data?.['link-update']?.submission) {
callback(res.data?.['link-update']?.submission);
} else {
console.error('unexpected submission response', res);
}

View File

@ -1,6 +1,6 @@
import BaseApi from "./base";
import { StoreState } from "../store/type";
import { BackgroundConfig } from "../types/local-update";
import { BackgroundConfig, LocalUpdateRemoteContentPolicy } from "../types/local-update";
export default class LocalApi extends BaseApi<StoreState> {
getBaseHash() {
@ -69,6 +69,16 @@ export default class LocalApi extends BaseApi<StoreState> {
});
}
setRemoteContentPolicy(policy: LocalUpdateRemoteContentPolicy) {
this.store.handleEvent({
data: {
local: {
remoteContentPolicy: policy
}
}
});
}
dehydrate() {
this.store.dehydrate();
}

View File

@ -82,6 +82,17 @@ export default class PublishApi extends BaseApi {
return this.action('publish', 'publish-action', act);
}
groupify(bookId: string, group: Path | null) {
return this.publishAction({
groupify: {
book: bookId,
target: group,
inclusive: false
}
});
}
newBook(bookId: string, title: string, description: string, group?: Path) {
const groupInfo = group ? { 'group-path': group,
invitees: [],

View File

@ -1,10 +1,12 @@
import defaultApps from './default-apps';
import { cite } from '~/logic/lib/util';
const indexes = new Map([
['commands', []],
['subscriptions', []],
['groups', []],
['apps', []]
['apps', []],
['other', []]
]);
// result schematic
@ -41,8 +43,6 @@ const commandIndex = function () {
}
});
commands.push(result('Profile', '/~profile', 'profile', null));
return commands;
};
@ -54,6 +54,9 @@ const appIndex = function (apps) {
.filter((e) => {
return apps[e]?.type?.basic;
})
.sort((a,b) => {
return a.localeCompare(b);
})
.map((e) => {
const obj = result(
apps[e].type.basic.title,
@ -70,6 +73,14 @@ const appIndex = function (apps) {
return applications;
};
const otherIndex = function() {
const other = [];
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) {
// all metadata from all apps is indexed
// into subscriptions and groups
@ -99,7 +110,7 @@ export default function index(associations, apps) {
title,
`/~${app}${each['app-path']}`,
app.charAt(0).toUpperCase() + app.slice(1),
shipStart.slice(0, shipStart.indexOf('/'))
cite(shipStart.slice(0, shipStart.indexOf('/')))
);
groups.push(obj);
} else {
@ -107,7 +118,7 @@ export default function index(associations, apps) {
title,
`/~${each['app-name']}/join${each['app-path']}`,
app.charAt(0).toUpperCase() + app.slice(1),
shipStart.slice(0, shipStart.indexOf('/'))
(associations?.contacts?.[each['group-path']]?.metadata?.title || null)
);
subscriptions.push(obj);
}
@ -118,6 +129,7 @@ export default function index(associations, apps) {
indexes.set('subscriptions', subscriptions);
indexes.set('groups', groups);
indexes.set('apps', appIndex(apps));
indexes.set('other', otherIndex());
return indexes;
};

View File

@ -0,0 +1,67 @@
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source));
const isUrl = (string) => {
try {
return URL_REGEX.test(string);
} catch (e) {
return false;
}
}
const tokenizeMessage = (text) => {
let messages = [];
let message = [];
let isInCodeBlock = false;
let endOfCodeBlock = false;
text.split(/\r?\n/).forEach((line, index) => {
if (index !== 0) {
message.push('\n');
}
// A line of backticks enters and exits a codeblock
if (line.startsWith('```')) {
// But we need to check if we've ended a codeblock
endOfCodeBlock = isInCodeBlock;
isInCodeBlock = (!isInCodeBlock);
} else {
endOfCodeBlock = false;
}
if (isInCodeBlock || endOfCodeBlock) {
message.push(line);
} else {
line.split(/\s/).forEach((str) => {
if (
(str.startsWith('`') && str !== '`')
|| (str === '`' && !isInCodeBlock)
) {
isInCodeBlock = true;
} else if (
(str.endsWith('`') && str !== '`')
|| (str === '`' && isInCodeBlock)
) {
isInCodeBlock = false;
}
if (isUrl(str) && !isInCodeBlock) {
if (message.length > 0) {
// If we're in the middle of a message, add it to the stack and reset
messages.push(message);
message = [];
}
messages.push([str]);
message = [];
} else {
message.push(str);
}
});
}
});
if (message.length) {
// Add any remaining message
messages.push(message);
}
return messages;
};
export { tokenizeMessage as default, isUrl, URL_REGEX };

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
export function useWaitForProps<P>(props: P, timeout: number) {
export function useWaitForProps<P>(props: P, timeout: number = 0) {
const [resolve, setResolve] = useState<() => void>(() => () => {});
const [ready, setReady] = useState<(p: P) => boolean | undefined>();
@ -24,9 +24,11 @@ export function useWaitForProps<P>(props: P, timeout: number) {
setReady(() => r);
return new Promise<void>((resolve, reject) => {
setResolve(() => resolve);
setTimeout(() => {
reject(new Error("Timed out"));
}, timeout);
if(timeout > 0) {
setTimeout(() => {
reject(new Error("Timed out"));
}, timeout);
}
});
},
[setResolve, setReady, timeout]

View File

@ -1,8 +1,9 @@
import _ from 'lodash';
import { StoreState } from '../../../store/type';
import { StoreState } from '~/logic/store/type';
import { Cage } from '~/types/cage';
import { ChatUpdate } from '~/types/chat-update';
import { ChatHookUpdate } from '~/types/chat-hook-update';
import { Envelope } from "~/types/chat-update";
type ChatState = Pick<StoreState, 'chatInitialized' | 'chatSynced' | 'inbox' | 'pendingMessages'>;
@ -49,8 +50,11 @@ export default class ChatReducer<S extends ChatState> {
messages(json: ChatUpdate, state: S) {
const data = _.get(json, 'messages', false);
if (data) {
state.inbox[data.path].envelopes =
state.inbox[data.path].envelopes.concat(data.envelopes);
state.inbox[data.path].envelopes = _.unionBy(
state.inbox[data.path].envelopes,
data.envelopes,
(envelope: Envelope) => envelope.uid
);
}
}

View File

@ -0,0 +1,164 @@
import _ from 'lodash';
export const GraphReducer = (json, state) => {
const data = _.get(json, 'graph-update', false);
if (data) {
keys(data, state);
addGraph(data, state);
removeGraph(data, state);
addNodes(data, state);
removeNodes(data, state);
}
};
const keys = (json, state) => {
const data = _.get(json, 'keys', false);
if (data) {
state.graphKeys = new Set(data.map((res) => {
let resource = res.ship + '/' + res.name;
if (!(resource in state.graphs)) {
state.graphs[resource] = new Map();
}
return resource;
}));
}
};
const addGraph = (json, state) => {
const _processNode = (node) => {
// is empty
if (!node.children) {
node.children = new Map();
return node;
}
// is graph
let converted = new Map();
for (let i in node.children) {
let item = node.children[i];
let index = item[0].split('/').slice(1).map((ind) => {
return parseInt(ind, 10);
});
if (index.length === 0) { break; }
converted.set(
index[index.length - 1],
_processNode(item[1])
);
}
node.children = converted;
return node;
};
const data = _.get(json, 'add-graph', false);
if (data) {
if (!('graphs' in state)) {
state.graphs = {};
}
let resource = data.resource.ship + '/' + data.resource.name;
state.graphs[resource] = new Map();
for (let i in data.graph) {
let item = data.graph[i];
let index = item[0].split('/').slice(1).map((ind) => {
return parseInt(ind, 10);
});
if (index.length === 0) { break; }
let node = _processNode(item[1]);
state.graphs[resource].set(index[index.length - 1], node);
}
state.graphKeys.add(resource);
}
};
const removeGraph = (json, state) => {
const data = _.get(json, 'remove-graph', false);
if (data) {
if (!('graphs' in state)) {
state.graphs = {};
}
let resource = data.resource.ship + '/' + data.resource.name;
delete state.graphs[resource];
}
};
const addNodes = (json, state) => {
const _addNode = (graph, index, node) => {
// set child of graph
if (index.length === 1) {
graph.set(index[0], node);
return graph;
}
// set parent of graph
let parNode = graph.get(index[0]);
if (!parNode) {
console.error('parent node does not exist, cannot add child');
return;
}
parNode.children = _addNode(parNode.children, index.slice(1), node);
graph.set(index[0], parNode);
return graph;
};
const data = _.get(json, 'add-nodes', false);
if (data) {
if (!('graphs' in state)) { return; }
let resource = data.resource.ship + '/' + data.resource.name;
if (!(resource in state.graphs)) { return; }
for (let i in data.nodes) {
let item = data.nodes[i];
if (item[0].split('/').length === 0) { return; }
let index = item[0].split('/').slice(1).map((ind) => {
return parseInt(ind, 10);
});
if (index.length === 0) { return; }
// TODO: support adding nodes with children
item[1].children = new Map();
state.graphs[resource] = _addNode(
state.graphs[resource],
index,
item[1]
);
}
}
};
const removeNodes = (json, state) => {
const data = _.get(json, 'remove-nodes', false);
if (data) {
console.log(data);
if (!(data.resource in state.graphs)) { return; }
data.indices.forEach((index) => {
console.log(index);
if (index.split('/').length === 0) { return; }
let indexArr = index.split('/').slice(1).map((ind) => {
return parseInt(ind, 10);
});
if (indexArr.length === 1) {
state.graphs[data.resource].delete(indexArr[0]);
} else {
// TODO: recursive
}
});
}
};

View File

@ -78,6 +78,7 @@ export default class GroupReducer<S extends GroupState> {
this.addGroup(data, state);
this.removeGroup(data, state);
this.changePolicy(data, state);
this.expose(data, state);
}
}
@ -187,6 +188,15 @@ export default class GroupReducer<S extends GroupState> {
}
}
expose(json: GroupUpdate, state: S) {
if( 'expose' in json && state) {
const { resource } = json.expose;
const resourcePath = resourceAsPath(resource);
state.groups[resourcePath].hidden = false;
}
}
private inviteChangePolicy(diff: InvitePolicyDiff, policy: InvitePolicy) {
if ('addInvites' in diff) {
const { addInvites } = diff;

View File

@ -3,7 +3,7 @@ import { StoreState } from '~/store/type';
import { Cage } from '~/types/cage';
import { LocalUpdate, BackgroundConfig } from '~/types/local-update';
type LocalState = Pick<StoreState, 'sidebarShown' | 'omniboxShown' | 'baseHash' | 'hideAvatars' | 'hideNicknames' | 'background' | 'dark' | 'suspendedFocus'>;
type LocalState = Pick<StoreState, 'sidebarShown' | 'omniboxShown' | 'baseHash' | 'hideAvatars' | 'hideNicknames' | 'background' | 'dark' | 'suspendedFocus' | 'remoteContentPolicy'>;
export default class LocalReducer<S extends LocalState> {
rehydrate(state: S) {
@ -18,7 +18,7 @@ export default class LocalReducer<S extends LocalState> {
}
dehydrate(state: S) {
const json = _.pick(state, ['hideNicknames' , 'hideAvatars' , 'background']);
const json = _.pick(state, ['hideNicknames' , 'hideAvatars' , 'background', 'remoteContentPolicy']);
localStorage.setItem('localReducer', JSON.stringify(json));
}
reduce(json: Cage, state: S) {
@ -31,6 +31,7 @@ export default class LocalReducer<S extends LocalState> {
this.hideAvatars(data, state)
this.hideNicknames(data, state)
this.omniboxShown(data, state);
this.remoteContentPolicy(data, state);
}
}
baseHash(obj: LocalUpdate, state: S) {
@ -70,6 +71,12 @@ export default class LocalReducer<S extends LocalState> {
}
}
remoteContentPolicy(obj: LocalUpdate, state: S) {
if('remoteContentPolicy' in obj) {
state.remoteContentPolicy = obj.remoteContentPolicy;
}
}
hideAvatars(obj: LocalUpdate, state: S) {
if('hideAvatars' in obj) {
state.hideAvatars = obj.hideAvatars;

View File

@ -6,6 +6,7 @@ import InviteReducer from '../reducers/invite-update';
import LinkReducer from '../reducers/link-update';
import ListenReducer from '../reducers/listen-update';
import LocalReducer from '../reducers/local';
import S3Reducer from '../reducers/s3-update';
import BaseStore from './base';
@ -21,6 +22,7 @@ export default class LinksStore extends BaseStore {
this.localReducer = new LocalReducer();
this.linkReducer = new LinkReducer();
this.listenReducer = new ListenReducer();
this.s3Reducer = new S3Reducer();
}
initialState() {
@ -37,6 +39,7 @@ export default class LinksStore extends BaseStore {
comments: {},
seen: {},
permissions: {},
s3: {},
sidebarShown: true
};
}
@ -50,6 +53,7 @@ export default class LinksStore extends BaseStore {
this.localReducer.reduce(data, this.state);
this.linkReducer.reduce(data, this.state);
this.listenReducer.reduce(data, this.state);
this.s3Reducer.reduce(data, this.state);
}
}

View File

@ -9,6 +9,7 @@ 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';
@ -53,6 +54,12 @@ export default class GlobalStore extends BaseStore<StoreState> {
suspendedFocus: null,
baseHash: null,
background: undefined,
remoteContentPolicy: {
imageShown: true,
audioShown: true,
videoShown: true,
oembedShown: true,
},
hideAvatars: false,
hideNicknames: false,
invites: {},
@ -64,6 +71,8 @@ export default class GlobalStore extends BaseStore<StoreState> {
},
groups: {},
groupKeys: new Set(),
graphs: {},
graphKeys: new Set(),
launch: {
firstTime: false,
tileOrdering: [],
@ -106,5 +115,6 @@ export default class GlobalStore extends BaseStore<StoreState> {
this.launchReducer.reduce(data, this.state);
this.linkListenReducer.reduce(data, this.state);
this.connReducer.reduce(data, this.state);
GraphReducer(data, this.state);
}
}

View File

@ -11,7 +11,7 @@ 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 { BackgroundConfig } from '~/types/local-update';
import { BackgroundConfig, LocalUpdateRemoteContentPolicy } from '~/types/local-update';
export interface StoreState {
// local state
@ -22,6 +22,7 @@ export interface StoreState {
connection: ConnectionStatus;
baseHash: string | null;
background: BackgroundConfig;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
hideAvatars: boolean;
hideNicknames: boolean;
// invite state
@ -35,6 +36,8 @@ export interface StoreState {
groupKeys: Set<Path>;
permissions: Permissions;
s3: S3State;
graphs: Object;
graphKeys: Set<String>;
// App specific states

View File

@ -27,12 +27,18 @@ const groupSubscriptions: AppSubscription[] = [
['/synced', 'contact-hook']
];
type AppName = 'publish' | 'chat' | 'link' | 'groups';
const graphSubscriptions: AppSubscription[] = [
['/keys', 'graph-store'],
['/updates', 'graph-store']
];
type AppName = 'publish' | 'chat' | 'link' | 'groups' | 'graph';
const appSubscriptions: Record<AppName, AppSubscription[]> = {
chat: chatSubscriptions,
publish: publishSubscriptions,
link: linkSubscriptions,
groups: groupSubscriptions
groups: groupSubscriptions,
graph: graphSubscriptions
};
export default class GlobalSubscription extends BaseSubscription<StoreState> {
@ -40,8 +46,10 @@ export default class GlobalSubscription extends BaseSubscription<StoreState> {
chat: [],
publish: [],
link: [],
groups: []
groups: [],
graph: []
};
start() {
this.subscribe('/all', 'invite-store');
this.subscribe('/groups', 'group-store');
@ -67,7 +75,8 @@ export default class GlobalSubscription extends BaseSubscription<StoreState> {
console.log(`${app} already started`);
return;
}
this.openSubscriptions[app] = appSubscriptions[app].map(([path, agent]) => this.subscribe(path, agent));
this.openSubscriptions[app] =
appSubscriptions[app].map(([path, agent]) => this.subscribe(path, agent));
}
stopApp(app: AppName) {

View File

@ -69,10 +69,14 @@ export interface Envelope {
uid: string;
number: number;
author: Patp;
when: string;
when: number;
letter: Letter;
}
export type IMessage = Envelope & {
pending?: boolean
};
interface LetterText {
text: string;
}

View File

@ -1,12 +1,3 @@
export type LocalUpdate =
LocalUpdateSidebarToggle
| LocalUpdateSetDark
| LocalUpdateBaseHash
| LocalUpdateBackgroundConfig
| LocalUpdateHideAvatars
| LocalUpdateHideNicknames
| LocalUpdateSetOmniboxShown;
interface LocalUpdateSidebarToggle {
sidebarToggle: boolean;
}
@ -31,7 +22,16 @@ interface LocalUpdateHideNicknames {
hideNicknames: boolean;
}
export type BackgroundConfig = BackgroundConfigUrl | BackgroundConfigColor | undefined;
interface LocalUpdateSetOmniboxShown {
omniboxShown: boolean;
}
export interface LocalUpdateRemoteContentPolicy {
imageShown: boolean;
audioShown: boolean;
videoShown: boolean;
oembedShown: boolean;
}
interface BackgroundConfigUrl {
type: 'url';
@ -43,6 +43,14 @@ interface BackgroundConfigColor {
color: string;
}
interface LocalUpdateSetOmniboxShown {
omniboxShown: boolean;
}
export type BackgroundConfig = BackgroundConfigUrl | BackgroundConfigColor | undefined;
export type LocalUpdate =
LocalUpdateSidebarToggle
| LocalUpdateSetDark
| LocalUpdateBaseHash
| LocalUpdateBackgroundConfig
| LocalUpdateHideAvatars
| LocalUpdateHideNicknames
| LocalUpdateSetOmniboxShown
| LocalUpdateRemoteContentPolicy;

View File

@ -1,7 +1,7 @@
import { hot } from 'react-hot-loader/root';
import 'react-hot-loader';
import * as React from 'react';
import { BrowserRouter as Router, Route, withRouter, Switch } from 'react-router-dom';
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 Helmet from 'react-helmet';
@ -16,8 +16,7 @@ import dark from './themes/old-dark';
import { Content } from './components/Content';
import StatusBar from './components/StatusBar';
import Omnibox from './components/Omnibox';
import ErrorComponent from './components/Error';
import Omnibox from './components/leap/Omnibox';
import GlobalStore from '~/logic/store/store';
import GlobalSubscription from '~/logic/subscription/global';
@ -36,7 +35,7 @@ const Root = styled.div`
background-size: cover;
` : p.background?.type === 'color' ? `
background-color: ${p.background.color};
` : ``
` : ''
}
display: flex;
flex-flow: column nowrap;
@ -135,7 +134,8 @@ class App extends React.Component {
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state} />
{...state}
/>
</Router>
</Root>
</ThemeProvider>
@ -143,6 +143,5 @@ class App extends React.Component {
}
}
export default process.env.NODE_ENV === 'production' ? App : hot(App);

View File

@ -4,10 +4,8 @@ import { Col } from "@tlon/indigo-react";
import { Association } from "~/types/metadata-update";
import { StoreState } from "~/logic/store/type";
import { ChatScreen } from "./components/chat";
import { ChatWindow } from "./components/lib/chat-window";
import { ChatHeader } from "./components/lib/chat-header";
import { ChatInput } from "./components/lib/chat-input";
import ChatWindow from "./components/lib/ChatWindow";
import ChatInput from "./components/lib/ChatInput";
import GlobalApi from "~/logic/api/global";
import { deSig } from "~/logic/lib/util";
@ -27,8 +25,8 @@ export function ChatResource(props: ChatResourceProps) {
const { read, length } = config;
const groupPath = props.association["group-path"];
const group = props.groups[groupPath] || groupBunts.group();
const contacts = props.contacts[groupPath];
const group = props.groups[groupPath];
const contacts = props.contacts[groupPath] || {};
const pendingMessages = (props.pendingMessages.get(props.station) || []).map(
(value) => ({
@ -41,25 +39,23 @@ export function ChatResource(props: ChatResourceProps) {
props.chatInitialized &&
!(station in props.inbox) &&
props.chatSynced &&
!(station in props.chatSynced);
!(station in props.chatSynced) || false;
const isChatLoading =
props.chatInitialized &&
!(station in props.inbox) &&
props.chatSynced &&
station in props.chatSynced;
station in props.chatSynced || false;
const isChatUnsynced =
props.chatSynced && !(station in props.chatSynced) && envelopes.length > 0;
props.chatSynced && !(station in props.chatSynced) && envelopes.length > 0 || false;
const unreadCount = length - read;
const unreadMsg = unreadCount > 0 && envelopes[unreadCount - 1];
const roomContacts = contacts[groupPath] || {};
const popout = props.match.url.includes("/popout/");
const [, owner, name] = station.split("/");
const ownerContact = contacts?.[deSig(owner)];
const lastMsgNum = 0;
@ -67,16 +63,18 @@ export function ChatResource(props: ChatResourceProps) {
return (
<Col overflow="hidden" position="relative">
<ChatWindow
remoteContentPolicy={props.remoteContentPolicy}
mailboxSize={length}
match={props.match as any}
stationPendingMessages={[]}
history={props.history}
isChatMissing={isChatMissing}
isChatLoading={isChatLoading}
isChatUnsynced={isChatUnsynced}
unreadCount={unreadCount}
unreadMsg={unreadMsg}
pendingMessages={pendingMessages}
messages={envelopes}
length={length}
contacts={roomContacts}
envelopes={envelopes || []}
contacts={contacts}
association={props.association}
group={group}
ship={owner}
@ -84,6 +82,7 @@ export function ChatResource(props: ChatResourceProps) {
api={props.api}
hideNicknames={props.hideNicknames}
hideAvatars={props.hideAvatars}
location={props.location}
/>
<ChatInput
api={props.api}
@ -91,14 +90,15 @@ export function ChatResource(props: ChatResourceProps) {
station={station}
owner={deSig(owner)}
ownerContact={ownerContact}
envelopes={envelopes}
contacts={roomContacts}
envelopes={envelopes || []}
contacts={contacts}
onUnmount={(msg: string) => {
/*this.setState({
messages: this.state.messages.set(props.station, msg),
}) */
}}
s3={props.s3}
hideAvatars={props.hideAvatars}
placeholder="Message..."
message={"" || ""}
deleteMessage={() => {}}

View File

@ -7,7 +7,6 @@ import './css/custom.css';
import { Skeleton } from './components/skeleton';
import { Sidebar } from './components/sidebar';
import { ChatScreen } from './components/chat';
import { MemberScreen } from './components/member';
import { SettingsScreen } from './components/settings';
import { NewScreen } from './components/new';
import { JoinScreen } from './components/join';
@ -89,7 +88,8 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
pendingMessages,
groups,
hideAvatars,
hideNicknames
hideNicknames,
remoteContentPolicy
} = props;
const renderChannelSidebar = (props, station?) => (
@ -108,7 +108,7 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
return (
<>
<Helmet>
<Helmet defer={false}>
<title>{totalUnreads > 0 ? `(${totalUnreads}) ` : ''}OS1 - Chat</title>
</Helmet>
<Switch>
@ -194,6 +194,11 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
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}
@ -226,56 +231,57 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
envelopes: []
};
let roomContacts = {};
const associatedGroup =
station in associations['chat'] &&
'group-path' in associations.chat[station]
? associations.chat[station]['group-path']
: '';
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];
}
if (associations.chat[station] && associatedGroup in contacts) {
roomContacts = contacts[associatedGroup];
}
const association =
station in associations['chat'] ? associations.chat[station] : {};
const association =
station in associations['chat'] ? associations.chat[station] : {};
const group = groups[association['group-path']] || groupBunts.group();
const group = groups[association['group-path']] || groupBunts.group();
const popout = props.match.url.includes('/popout/');
const popout = props.match.url.includes('/popout/');
return (
<Skeleton
associations={associations}
invites={invites}
sidebarHideOnMobile={true}
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}
sidebar={renderChannelSidebar(props, station)}
>
<ChatScreen
chatSynced={chatSynced || {}}
station={station}
association={association}
api={api}
read={mailbox.config.read}
length={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}
{...props}
/>
</Skeleton>
);
}}
chatInitialized={chatInitialized}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
remoteContentPolicy={remoteContentPolicy}
{...props}
/>
</Skeleton>
);
}}
/>
<Route
exact

View File

@ -1,20 +1,21 @@
import React, { Component, Fragment } from "react";
import React, { Component } from "react";
import moment from "moment";
import { RouteComponentProps } from "react-router-dom";
import { Link, RouteComponentProps } from "react-router-dom";
import { ChatWindow } from './lib/chat-window';
import { ChatHeader } from './lib/chat-header';
import { ChatInput } from "./lib/chat-input";
import { deSig } from "~/logic/lib/util";
import { ChatHookUpdate } from "~/types/chat-hook-update";
import ChatApi from "~/logic/api/chat";
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<{
@ -26,7 +27,7 @@ type ChatScreenProps = RouteComponentProps<{
association: Association;
api: GlobalApi;
read: number;
length: number;
mailboxSize: number;
inbox: Inbox;
contacts: Contacts;
group: Group;
@ -38,13 +39,16 @@ type ChatScreenProps = RouteComponentProps<{
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;
@ -53,8 +57,11 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
this.state = {
messages: new Map(),
dragover: false,
};
this.chatInput = React.createRef();
moment.updateLocale("en", {
calendar: {
sameDay: "[Today]",
@ -67,6 +74,31 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
});
}
readyToUpload(): boolean {
return Boolean(this.chatInput.current?.s3Uploader.current?.inputRef.current);
}
onDragEnter(event) {
if (!this.readyToUpload() || !event.dataTransfer.files.length) {
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;
@ -97,42 +129,42 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
!(props.station in props.chatSynced) &&
props.envelopes.length > 0;
const unreadCount = props.length - props.read;
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">
<ChatHeader
match={props.match}
location={props.location}
api={props.api}
group={props.group}
association={props.association}
station={props.station}
sidebarShown={props.sidebarShown}
popout={props.popout} />
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={() => this.setState({ dragover: false })}
onDrop={this.onDrop.bind(this)}
>
{this.state.dragover ? <SubmitDragger /> : null}
<ChatHeader {...props} />
<ChatWindow
history={props.history}
isChatMissing={isChatMissing}
isChatLoading={isChatLoading}
isChatUnsynced={isChatUnsynced}
unreadCount={unreadCount}
unreadMsg={unreadMsg}
pendingMessages={pendingMessages}
messages={props.envelopes}
length={props.length}
contacts={props.contacts}
association={props.association}
group={props.group}
stationPendingMessages={pendingMessages}
ship={props.match.params.ship}
station={props.station}
api={props.api}
hideNicknames={props.hideNicknames}
hideAvatars={props.hideAvatars}
/>
{...props} />
<ChatInput
ref={this.chatInput}
api={props.api}
numMsgs={lastMsgNum}
station={props.station}
@ -149,6 +181,7 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
deleteMessage={() => this.setState({
messages: this.state.messages.set(props.station, "")
})}
hideAvatars={props.hideAvatars}
/>
</div>
);

View File

@ -0,0 +1,63 @@
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

@ -0,0 +1,255 @@
import React, { Component } from 'react';
import ChatEditor from './chat-editor';
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';
import GlobalApi from '~/logic/api/global';
import { Envelope } from '~/types/chat-update';
import { Contacts, S3Configuration } from '~/types';
interface ChatInputProps {
api: GlobalApi;
numMsgs: number;
station: any;
owner: string;
ownerContact: any;
envelopes: Envelope[];
contacts: Contacts;
onUnmount(msg: string): void;
s3: any;
placeholder: string;
message: string;
deleteMessage(): void;
hideAvatars: boolean;
onPaste?(): void;
}
interface ChatInputState {
inCodeMode: boolean;
submitFocus: boolean;
uploadingPaste: boolean;
}
export default class ChatInput extends Component<ChatInputProps, ChatInputState> {
public s3Uploader: React.RefObject<S3Upload>;
private chatEditor: React.RefObject<ChatEditor>;
constructor(props) {
super(props);
this.state = {
inCodeMode: false,
submitFocus: false,
uploadingPaste: false,
};
this.s3Uploader = React.createRef();
this.chatEditor = React.createRef();
this.submit = this.submit.bind(this);
this.toggleCode = this.toggleCode.bind(this);
}
toggleCode() {
this.setState({
inCodeMode: !this.state.inCodeMode
});
}
getLetterType(letter) {
if (letter.startsWith('/me ')) {
letter = letter.slice(4);
// remove insignificant leading whitespace.
// aces might be relevant to style.
while (letter[0] === '\n') {
letter = letter.slice(1);
}
return {
me: letter
};
} else if (isUrl(letter)) {
return {
url: letter
};
} else {
return {
text: letter
};
}
}
submit(text) {
const { props, state } = this;
if (state.inCodeMode) {
this.setState({
inCodeMode: false
}, () => {
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(), {
code: {
expression: text,
output: undefined
}
}
);
});
return;
}
const messages = tokenizeMessage(text);
props.deleteMessage();
messages.forEach((message) => {
if (message.length > 0) {
message = this.getLetterType(message.join(' '));
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(),
message
);
}
});
}
uploadSuccess(url) {
const { props } = this;
if (this.state.uploadingPaste) {
this.chatEditor.current.editor.setValue(url);
this.setState({ uploadingPaste: false });
} else {
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(),
{ url }
);
}
}
uploadError(error) {
// no-op for now
}
readyToUpload(): boolean {
return Boolean(this.s3Uploader.current?.inputRef.current);
}
onPaste(codemirrorInstance, event: ClipboardEvent) {
if (!event.clipboardData || !event.clipboardData.files.length) {
return;
}
this.setState({ uploadingPaste: true });
event.preventDefault();
event.stopPropagation();
this.uploadFiles(event.clipboardData.files);
}
uploadFiles(files: FileList) {
if (!this.readyToUpload()) {
return;
}
this.s3Uploader.current.inputRef.current.files = files;
const fire = document.createEvent("HTMLEvents");
fire.initEvent("change", true, true);
this.s3Uploader.current?.inputRef.current?.dispatchEvent(fire);
}
render() {
const { props, state } = this;
const color = props.ownerContact
? uxToHex(props.ownerContact.color) : '000000';
const sigilClass = props.ownerContact
? '' : 'mix-blend-diff';
const avatar = (
props.ownerContact &&
((props.ownerContact.avatar !== null) && !props.hideAvatars)
)
? <img src={props.ownerContact.avatar} height={24} width={24} className="dib" />
: <Sigil
ship={window.ship}
size={24}
color={`#${color}`}
classes={sigilClass}
/>;
return (
<div className={
"pa3 cf 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
}}>
{avatar}
</div>
<ChatEditor
ref={this.chatEditor}
inCodeMode={state.inCodeMode}
submit={this.submit}
onUnmount={props.onUnmount}
message={props.message}
onPaste={this.onPaste.bind(this)}
placeholder='Message...'
/>
<div className="ml2 mr2"
style={{
height: '16px',
width: '16px',
flexBasis: 16,
marginTop: 10
}}>
<S3Upload
ref={this.s3Uploader}
configuration={props.s3.configuration}
credentials={props.s3.credentials}
uploadSuccess={this.uploadSuccess.bind(this)}
uploadError={this.uploadError.bind(this)}
accept="*"
>
<img
className="invert-d"
src="/~chat/img/ImageUpload.png"
width="16"
height="16"
/>
</S3Upload>
</div>
<div style={{
height: '16px',
width: '16px',
flexBasis: 16,
marginTop: 10
}}>
<img style={{
filter: state.inCodeMode ? 'invert(100%)' : '',
height: '14px',
width: '14px',
}}
onClick={this.toggleCode}
src="/~chat/img/CodeEval.png"
className="contrast-10-d bg-white bg-none-d ba b--gray1-d br1" />
</div>
</div>
);
}
}

View File

@ -0,0 +1,283 @@
import React, { Component, PureComponent } from "react";
import moment from "moment";
import _ from "lodash";
import { OverlaySigil } from './overlay-sigil';
import { uxToHex, cite, writeText } from '~/logic/lib/util';
import { Envelope, IMessage } from "~/types/chat-update";
import { Group, Association, Contacts, LocalUpdateRemoteContentPolicy } from "~/types";
import TextContent from './content/text';
import CodeContent from './content/code';
import RemoteContent from '~/views/components/RemoteContent';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
export const UnreadMarker = React.forwardRef(({ dayBreak, when, style }, ref) => (
<div ref={element => {
setTimeout(() => {
element.style.opacity = '1';
}, 250);
}} className="green2 flex items-center f9 absolute w-100" style={{...style, opacity: '0'}}>
<hr className="dn-s ma0 w2 b--green2 bt-0" />
<p className="mh4">New messages below</p>
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
{dayBreak
? <p className="gray2 mh4">{moment(when).calendar()}</p>
: null}
<hr style={{ width: "calc(50% - 48px)" }} className="b--green2 ma0 bt-0" />
</div>
));
export const DayBreak = ({ when }) => (
<div className="pv3 gray2 b--gray2 flex items-center justify-center f9 w-100">
<p>{moment(when).calendar()}</p>
</div>
);
interface ChatMessageProps {
measure(element): void;
msg: Envelope | IMessage;
previousMsg: Envelope | IMessage | undefined;
nextMsg: Envelope | IMessage | undefined;
isFirstUnread: boolean;
group: Group;
association: Association;
contacts: Contacts;
unreadRef: React.RefObject<HTMLDivElement>;
hideAvatars: boolean;
hideNicknames: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
className: string;
isPending: boolean;
style?: any;
scrollWindow: HTMLDivElement;
}
export default class ChatMessage extends Component<ChatMessageProps> {
private divRef: React.RefObject<HTMLDivElement>;
constructor(props) {
super(props);
this.divRef = React.createRef();
}
componentDidMount() {
if (this.divRef.current) {
this.props.measure(this.divRef.current);
}
}
render() {
const {
msg,
previousMsg,
nextMsg,
isFirstUnread,
group,
association,
contacts,
unreadRef,
hideAvatars,
hideNicknames,
remoteContentPolicy,
className = '',
isPending,
style,
measure,
scrollWindow
} = 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' : ''} ${className}`
const timestamp = moment.unix(msg.when / 1000).format(renderSigil ? 'hh:mm a' : 'hh:mm');
const reboundMeasure = (event) => {
return measure(this.divRef.current);
};
const messageProps = {
msg,
timestamp,
contacts,
hideNicknames,
association,
group,
hideAvatars,
remoteContentPolicy,
measure: reboundMeasure.bind(this),
style,
containerClass,
isPending,
scrollWindow
};
return (
<div ref={this.divRef} className={containerClass} style={style} data-number={msg.number}>
{dayBreak && !isFirstUnread ? <DayBreak when={msg.when} /> : null}
{renderSigil
? <MessageWithSigil {...messageProps} />
: <MessageWithoutSigil {...messageProps} />}
{isFirstUnread
? <UnreadMarker ref={unreadRef} dayBreak={dayBreak} when={msg.when} style={{ marginTop: (renderSigil ? "-17px" : "-6px") }} />
: null}
</div>
);
}
}
interface MessageProps {
msg: Envelope | IMessage;
timestamp: string;
group: Group;
association: Association;
contacts: Contacts;
hideAvatars: boolean;
hideNicknames: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
containerClass: string;
isPending: boolean;
style: any;
measure(element): void;
scrollWindow: HTMLDivElement;
};
export class MessageWithSigil extends PureComponent<MessageProps> {
render() {
const {
msg,
timestamp,
contacts,
hideNicknames,
association,
group,
hideAvatars,
remoteContentPolicy,
measure,
scrollWindow
} = this.props;
const datestamp = moment.unix(msg.when / 1000).format(DATESTAMP_FORMAT);
const contact = msg.author in contacts ? contacts[msg.author] : false;
const showNickname = !hideNicknames && contact && contact.nickname;
const name = showNickname ? contact.nickname : cite(msg.author);
const color = contact ? `#${uxToHex(contact.color)}` : '#000000';
const sigilClass = contact ? '' : 'mix-blend-diff';
let nameSpan = null;
const copyNotice = (saveName) => {
if (nameSpan !== null) {
nameSpan.innerText = 'Copied';
setTimeout(() => {
nameSpan.innerText = saveName;
}, 800);
}
};
return (
<>
<OverlaySigil
ship={msg.author}
contact={contact}
color={color}
sigilClass={sigilClass}
association={association}
group={group}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
scrollWindow={scrollWindow}
className="fl pr3 v-top bg-white bg-gray0-d"
/>
<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>
</>
);
}
}
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 }}>
<MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure}/>
</div>
</>
);
export const MessageContent = ({ content, remoteContentPolicy, measure }) => {
if ('code' in content) {
return <CodeContent content={content} />;
} else if ('url' in content) {
return (
<RemoteContent
url={content.url}
remoteContentPolicy={remoteContentPolicy}
onLoad={measure}
imageProps={{style: {
maxWidth: '18rem'
}}}
videoProps={{style: {
maxWidth: '18rem'
}}}
/>
);
} else if ('me' in content) {
return (
<p className='f7 i lh-copy v-top'>
{content.me}
</p>
);
}
else if ('text' in content) {
return <TextContent content={content} />;
} else {
return null;
}
};
export const MessagePlaceholder = ({ height, index, className = '', style = {}, ...props }) => (
<div className={`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ${className}`} style={{ height, ...style }} {...props}>
<div className="fl pr3 v-top bg-white bg-gray0-d">
<span
className="db bg-gray2 bg-white-d"
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
visibility: (index % 5 == 0) ? "initial" : "hidden",
}}
></span>
</div>
<div className="fr clamp-message white-d" style={{ flexGrow: 1, marginTop: -8 }}>
<div className="hide-child" style={{paddingTop: "6px", visibility: (index % 5 == 0) ? "initial" : "hidden" }}>
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
<span className="mw5 db"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></span>
</p>
<p className="v-mid mono f9 gray2 dib"><span className="bg-gray5 bg-gray1-d db w-100 h-100" style={{height: "1em", width: `${(index % 3 + 1) * 3}em`}}></span></p>
<p className="v-mid mono f9 ml2 gray2 dib child dn-s"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></p>
</div>
<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

@ -0,0 +1,296 @@
import React, { Component } from "react";
import { RouteComponentProps } from "react-router-dom";
import _ from "lodash";
import GlobalApi from "~/logic/api/global";
import { Patp, Path } from "~/types/noun";
import { Contacts } from "~/types/contact-update";
import { Association } from "~/types/metadata-update";
import { Group } from "~/types/group-update";
import { Envelope, IMessage } from "~/types/chat-update";
import { LocalUpdateRemoteContentPolicy } from "~/types";
import VirtualScroller from "~/views/components/VirtualScroller";
import ChatMessage, { MessagePlaceholder } from './ChatMessage';
import { UnreadNotice } from "./unread-notice";
import { ResubscribeElement } from "./resubscribe-element";
import { BacklogElement } from "./backlog-element";
const INITIAL_LOAD = 20;
const DEFAULT_BACKLOG_SIZE = 100;
const IDLE_THRESHOLD = 64;
type ChatWindowProps = RouteComponentProps<{
ship: Patp;
station: string;
}> & {
unreadCount: number;
envelopes: Envelope[];
isChatMissing: boolean;
isChatLoading: boolean;
isChatUnsynced: boolean;
unreadMsg: Envelope | false;
stationPendingMessages: IMessage[];
mailboxSize: number;
contacts: Contacts;
association: Association;
group: Group;
ship: Patp;
station: any;
api: GlobalApi;
hideNicknames: boolean;
hideAvatars: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
}
interface ChatWindowState {
fetchPending: boolean;
idle: boolean;
initialized: boolean;
}
export default class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
private virtualList: VirtualScroller | null;
INITIALIZATION_MAX_TIME = 1500;
constructor(props) {
super(props);
this.state = {
fetchPending: false,
idle: true,
initialized: false
};
this.dismissUnread = this.dismissUnread.bind(this);
this.initialIndex = this.initialIndex.bind(this);
this.scrollToUnread = this.scrollToUnread.bind(this);
this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
this.firstUnread = this.firstUnread.bind(this);
this.virtualList = null;
}
componentDidMount() {
window.addEventListener('blur', this.handleWindowBlur);
window.addEventListener('focus', this.handleWindowFocus);
this.initialFetch();
setTimeout(() => {
this.setState({ initialized: true });
}, this.INITIALIZATION_MAX_TIME);
}
componentWillUnmount() {
window.removeEventListener('blur', this.handleWindowBlur);
window.removeEventListener('focus', this.handleWindowFocus);
}
handleWindowBlur() {
this.setState({ idle: true });
}
handleWindowFocus() {
this.setState({ idle: false });
}
initialIndex() {
const { mailboxSize, unreadCount } = this.props;
return Math.min(Math.max(mailboxSize - 1 < INITIAL_LOAD
? 0
: this.firstUnread(),
0), mailboxSize);
}
initialFetch() {
const { envelopes, mailboxSize, unreadCount } = this.props;
if (envelopes.length > 0) {
const start = Math.min(mailboxSize - unreadCount, mailboxSize - DEFAULT_BACKLOG_SIZE);
this.stayLockedIfActive();
this.fetchMessages(start, start + DEFAULT_BACKLOG_SIZE, true).then(() => {
if (!this.virtualList) return;
const initialIndex = this.initialIndex();
this.virtualList.scrollToData(initialIndex).then(() => {
if (
initialIndex === mailboxSize
|| (this.virtualList && this.virtualList.window && this.virtualList.window.scrollTop === 0)
) {
this.setState({ idle: false });
this.dismissUnread();
}
this.setState({ initialized: true });
});
});
} else {
setTimeout(() => {
this.initialFetch();
}, 2000);
}
}
componentDidUpdate(prevProps: ChatWindowProps, prevState) {
const { isChatMissing, history, envelopes, mailboxSize, stationPendingMessages } = this.props;
if (isChatMissing) {
history.push("/~chat");
} else if (envelopes.length !== prevProps.envelopes.length && this.state.fetchPending) {
this.setState({ fetchPending: false });
}
if ((mailboxSize !== prevProps.mailboxSize) || (envelopes.length !== prevProps.envelopes.length)) {
this.virtualList?.calculateVisibleItems();
this.stayLockedIfActive();
}
if (stationPendingMessages.length !== prevProps.stationPendingMessages.length) {
this.virtualList?.calculateVisibleItems();
this.virtualList?.scrollToData(mailboxSize);
}
if (!this.state.fetchPending && prevState.fetchPending) {
this.virtualList?.calculateVisibleItems();
}
}
stayLockedIfActive() {
if (this.virtualList && !this.state.idle) {
this.virtualList.resetScroll();
this.dismissUnread();
}
}
scrollToUnread() {
const { mailboxSize, unreadCount } = this.props;
this.virtualList?.scrollToData(mailboxSize - unreadCount);
}
dismissUnread() {
if (this.state.fetchPending) return;
if (this.props.unreadCount === 0) return;
this.props.api.chat.read(this.props.station);
}
fetchMessages(start, end, force = false): Promise<void> {
start = Math.max(start, 0);
end = Math.max(end, 0);
const { api, mailboxSize, station } = this.props;
if (
(this.state.fetchPending ||
mailboxSize <= 0)
&& !force
) {
return new Promise((resolve, reject) => {});
}
this.setState({ fetchPending: true });
return api.chat
.fetchMessages(Math.max(mailboxSize - end, 0), Math.min(mailboxSize - start, mailboxSize), station)
.finally(() => {
this.setState({ fetchPending: false });
});
}
firstUnread() {
const { mailboxSize, unreadCount } = this.props;
return mailboxSize - unreadCount + 1;
}
render() {
const {
envelopes,
stationPendingMessages,
unreadCount,
unreadMsg,
isChatLoading,
isChatUnsynced,
api,
ship,
station,
association,
group,
contacts,
mailboxSize,
hideAvatars,
hideNicknames,
remoteContentPolicy,
} = this.props;
const messages = new Map();
let lastMessage = 0;
[...envelopes]
.sort((a, b) => a.when - b.when)
.forEach(message => {
messages.set(message.number, message);
lastMessage = message.number;
});
stationPendingMessages
.sort((a, b) => a.when - b.when)
.forEach((message, index) => {
index = index + 1; // To 1-index it
messages.set(envelopes.length + index, message);
lastMessage = envelopes.length + index;
});
const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy };
return (
<>
<UnreadNotice
unreadCount={unreadCount}
unreadMsg={unreadCount === 1 && unreadMsg && unreadMsg.author === window.ship ? false : unreadMsg}
dismissUnread={this.dismissUnread}
onClick={this.scrollToUnread}
/>
<BacklogElement isChatLoading={isChatLoading} />
<ResubscribeElement {...{ api, host: ship, station, isChatUnsynced}} />
<VirtualScroller
ref={list => {this.virtualList = list}}
origin="bottom"
style={{ height: '100%' }}
onStartReached={() => {
this.setState({ idle: false });
this.dismissUnread();
}}
onScroll={({ scrollTop }) => {
if (!this.state.idle && scrollTop > IDLE_THRESHOLD) {
this.setState({ idle: true });
}
}}
data={messages}
size={mailboxSize + stationPendingMessages.length}
renderer={({ index, measure, scrollWindow }) => {
const msg: Envelope | IMessage = messages.get(index);
if (!msg) return null;
if (!this.state.initialized) {
return <MessagePlaceholder key={index} height="64px" index={index} />;
}
const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
const isFirstUnread: boolean = Boolean(unreadCount && index === this.firstUnread());
const isLastMessage: boolean = Boolean(index === lastMessage)
const props = { measure, scrollWindow, isPending, isFirstUnread, msg, ...messageProps };
return (
<ChatMessage
key={index}
previousMsg={messages.get(index + 1)}
nextMsg={messages.get(index - 1)}
className={isLastMessage ? 'pb3' : ''}
{...props}
/>
);
}}
loadRows={(start, end) => {
this.fetchMessages(start, end);
}}
/>
</>
);
}
}

View File

@ -5,7 +5,7 @@ export const BacklogElement = (props) => {
return null;
}
return (
<div className="center mw6">
<div className="center mw6 absolute z-9999" style={{ left: 0, right: 0, top: 48}}>
<div className={
"db pa3 ma3 ba b--gray4 bg-gray5 b--gray2-d bg-gray1-d " +
"white-d flex items-center"

View File

@ -101,9 +101,14 @@ export default class ChatEditor extends Component {
}
render() {
const { props } = this;
const {
inCodeMode,
placeholder,
message,
...props
} = this.props;
const codeTheme = props.inCodeMode ? ' code' : '';
const codeTheme = inCodeMode ? ' code' : '';
const options = {
mode: MARKDOWN_CONFIG,
@ -112,10 +117,13 @@ export default class ChatEditor extends Component {
lineWrapping: true,
scrollbarStyle: 'native',
cursorHeight: 0.85,
placeholder: props.inCodeMode ? 'Code...' : props.placeholder,
placeholder: inCodeMode ? 'Code...' : placeholder,
extraKeys: {
'Enter': () => {
this.submit();
},
'Esc': () => {
this.editor?.getInputField().blur();
}
}
};
@ -124,11 +132,11 @@ export default class ChatEditor extends Component {
<div
className={
'chat fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center' +
(props.inCodeMode ? ' code' : '')
(inCodeMode ? ' code' : '')
}
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}>
<CodeEditor
value={props.message}
value={message}
options={options}
onChange={(e, d, v) => this.messageChange(e, d, v)}
editorDidMount={(editor) => {
@ -137,6 +145,7 @@ export default class ChatEditor extends Component {
editor.focus();
}
}}
{...props}
/>
</div>
);

View File

@ -1,58 +0,0 @@
import React, { Component, Fragment } from "react";
import { Link } from "react-router-dom";
import { ChatTabBar } from "./chat-tabbar";
import { SidebarSwitcher } from "~/views/components/SidebarSwitch";
import { deSig } from "~/logic/lib/util";
export 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>
<ChatTabBar
location={props.location}
station={props.station}
isOwner={deSig(props.match.params.ship) === window.ship}
popout={props.popout}
/>
</div>
</Fragment>
);
}

View File

@ -1,265 +0,0 @@
import React, { Component } from 'react';
import ChatEditor from './chat-editor';
import { S3Upload } from './s3-upload'
;
import { uxToHex } from '~/logic/lib/util';
import { Sigil } from '~/logic/lib/sigil';
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source));
export class ChatInput extends Component {
constructor(props) {
super(props);
this.state = {
inCodeMode: false,
};
this.submit = this.submit.bind(this);
this.toggleCode = this.toggleCode.bind(this);
}
uploadSuccess(url) {
const { props } = this;
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(),
{ url }
);
}
uploadError(error) {
// no-op for now
}
toggleCode() {
this.setState({
inCodeMode: !this.state.inCodeMode
});
}
getLetterType(letter) {
if (letter.startsWith('/me ')) {
letter = letter.slice(4);
// remove insignificant leading whitespace.
// aces might be relevant to style.
while (letter[0] === '\n') {
letter = letter.slice(1);
}
return {
me: letter
};
} else if (this.isUrl(letter)) {
return {
url: letter
};
} else {
return {
text: letter
};
}
}
isUrl(string) {
try {
return URL_REGEX.test(string);
} catch (e) {
return false;
}
}
submit(text) {
const { props, state } = this;
if (state.inCodeMode) {
this.setState({
inCodeMode: false
}, () => {
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(), {
code: {
expression: text,
output: undefined
}
}
);
});
return;
}
let messages = [];
let message = [];
let isInCodeBlock = false;
let endOfCodeBlock = false;
text.split(/\r?\n/).forEach((line, index) => {
if (index !== 0) {
message.push('\n');
}
// A line of backticks enters and exits a codeblock
if (line.startsWith('```')) {
// But we need to check if we've ended a codeblock
endOfCodeBlock = isInCodeBlock;
isInCodeBlock = (!isInCodeBlock);
} else {
endOfCodeBlock = false;
}
if (isInCodeBlock || endOfCodeBlock) {
message.push(line);
} else {
line.split(/\s/).forEach((str) => {
if (
(str.startsWith('`') && str !== '`')
|| (str === '`' && !isInCodeBlock)
) {
isInCodeBlock = true;
} else if (
(str.endsWith('`') && str !== '`')
|| (str === '`' && isInCodeBlock)
) {
isInCodeBlock = false;
}
if (this.isUrl(str) && !isInCodeBlock) {
if (message.length > 0) {
// If we're in the middle of a message, add it to the stack and reset
messages.push(message);
message = [];
}
messages.push([str]);
message = [];
} else {
message.push(str);
}
});
}
});
if (message.length) {
// Add any remaining message
messages.push(message);
}
props.deleteMessage();
messages.forEach((message) => {
if (message.length > 0) {
message = this.getLetterType(message.join(' '));
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(),
message
);
}
});
// perf testing:
/*let closure = () => {
let x = 0;
for (var i = 0; i < 30; i++) {
x++;
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(),
{
text: `${x}`
}
);
}
setTimeout(closure, 1000);
};
this.closure = closure.bind(this);
setTimeout(this.closure, 2000);*/
}
uploadSuccess(url) {
const { props } = this;
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(),
{ url }
);
}
uploadError(error) {
// no-op for now
}
render() {
const { props, state } = this;
const color = props.ownerContact
? uxToHex(props.ownerContact.color) : '000000';
const sigilClass = props.ownerContact
? '' : 'mix-blend-diff';
const avatar = (props.ownerContact && (props.ownerContact.avatar !== null))
? <img src={props.ownerContact.avatar} height={24} width={24} className="dib" />
: <Sigil
ship={window.ship}
size={24}
color={`#${color}`}
classes={sigilClass}
/>;
return (
<div className={
"pa3 cf 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
}}>
{avatar}
</div>
<ChatEditor
inCodeMode={state.inCodeMode}
submit={this.submit}
onUnmount={props.onUnmount}
message={props.message}
placeholder='Message...' />
<div className="ml2 mr2"
style={{
height: '16px',
width: '16px',
flexBasis: 16,
marginTop: 10
}}>
<S3Upload
configuration={props.s3.configuration}
credentials={props.s3.credentials}
uploadSuccess={this.uploadSuccess.bind(this)}
uploadError={this.uploadError.bind(this)}
/>
</div>
<div style={{
height: '16px',
width: '16px',
flexBasis: 16,
marginTop: 10
}}>
<img style={{
filter: state.inCodeMode && 'invert(100%)',
height: '14px',
width: '14px',
}}
onClick={this.toggleCode}
src="/~chat/img/CodeEval.png"
className="contrast-10-d bg-white bg-none-d ba b--gray1-d br1" />
</div>
</div>
);
}
}

View File

@ -1,88 +0,0 @@
import React, { PureComponent, Fragment } from "react";
import moment from "moment";
import { Message } from "./message";
type IMessage = Envelope & { pending?: boolean };
export const ChatMessage = (props) => {
const {
msg,
previousMsg,
nextMsg,
isLastUnread,
group,
association,
contacts,
unreadRef,
hideAvatars,
hideNicknames
} = props;
// Render sigil if previous message is not by the same sender
const aut = ["author"];
const renderSigil =
_.get(nextMsg, aut) !== _.get(msg, aut, msg.author);
const paddingTop = renderSigil;
const paddingBot =
_.get(previousMsg, aut) !== _.get(msg, aut, msg.author);
const when = ["when"];
const dayBreak =
moment(_.get(nextMsg, when)).format("YYYY.MM.DD") !==
moment(_.get(msg, when)).format("YYYY.MM.DD");
const messageElem = (
<Message
key={msg.uid}
msg={msg}
renderSigil={renderSigil}
paddingTop={paddingTop}
paddingBot={paddingBot}
pending={Boolean(msg.pending)}
group={group}
contacts={contacts}
association={association}
hideNicknames={hideNicknames}
hideAvatars={hideAvatars}
/>
);
if (props.isLastUnread) {
return (
<Fragment key={msg.uid}>
{messageElem}
<div ref={unreadRef}
className="mv2 green2 flex items-center f9">
<hr className="dn-s ma0 w2 b--green2 bt-0" />
<p className="mh4">New messages below</p>
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
{dayBreak && (
<p className="gray2 mh4">
{moment(_.get(msg, when)).calendar()}
</p>
)}
<hr
style={{ width: "calc(50% - 48px)" }}
className="b--green2 ma0 bt-0"
/>
</div>
</Fragment>
);
} else if (dayBreak) {
return (
<Fragment key={msg.uid}>
{messageElem}
<div
className="pv3 gray2 b--gray2 flex items-center justify-center f9 "
>
<p>{moment(_.get(msg, when)).calendar()}</p>
</div>
</Fragment>
);
} else {
return messageElem;
}
};

View File

@ -1,143 +0,0 @@
import React, { Component, Fragment } from "react";
import { scrollIsAtTop, scrollIsAtBottom } from "~/logic/lib/util";
// Restore chat position on FF when new messages come in
const recalculateScrollTop = (lastScrollHeight, scrollContainer) => {
if (!scrollContainer || !lastScrollHeight) {
return;
}
const newScrollTop = scrollContainer.scrollHeight - lastScrollHeight;
if (scrollContainer.scrollTop !== 0 ||
scrollContainer.scrollTop === newScrollTop) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight - lastScrollHeight;
};
export class ChatScrollContainer extends Component {
constructor(props) {
super(props);
// only for FF
this.state = {
lastScrollHeight: null
};
this.isTriggeredScroll = false;
this.isAtBottom = true;
this.isAtTop = false;
this.containerDidScroll = this.containerDidScroll.bind(this);
this.containerRef = React.createRef();
this.scrollRef = React.createRef();
}
containerDidScroll(e) {
const { props } = this;
if (scrollIsAtTop(e.target)) {
// Save scroll position for FF
if (navigator.userAgent.includes("Firefox")) {
this.setState({
lastScrollHeight: e.target.scrollHeight,
});
}
if (!this.isAtTop) {
props.scrollIsAtTop();
}
this.isTriggeredScroll = false;
this.isAtBottom = false;
this.isAtTop = true;
} else if (scrollIsAtBottom(e.target) && !this.isTriggeredScroll) {
if (!this.isAtBottom) {
props.scrollIsAtBottom();
}
this.isTriggeredScroll = false;
this.isAtBottom = true;
this.isAtTop = false;
} else {
this.isAtBottom = false;
this.isAtTop = false;
this.isTriggeredScroll = false;
}
}
render() {
// Replace with just the "not Firefox" implementation
// when Firefox #1042151 is patched.
if (navigator.userAgent.includes("Firefox")) {
return this.firefoxScrollContainer();
} else {
return this.normalScrollContainer();
}
}
firefoxScrollContainer() {
return (
<div
className="relative overflow-y-scroll h-100"
onScroll={this.containerDidScroll}
ref={this.containerRef}>
<div
className="bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse"
style={{ resize: "vertical" }}>
<div ref={this.scrollRef}></div>
{this.props.children}
</div>
</div>
);
}
normalScrollContainer() {
return (
<div
className={
"overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex " +
"flex-column-reverse relative"
}
style={{ height: "100%", resize: "vertical" }}
onScroll={this.containerDidScroll}>
<div ref={this.scrollRef}></div>
{this.props.children}
</div>
);
}
scrollToBottom() {
this.isTriggeredScroll = true;
if (this.scrollRef.current) {
this.scrollRef.current.scrollIntoView(false);
}
if (navigator.userAgent.includes("Firefox")) {
recalculateScrollTop(
this.state.lastScrollHeight,
this.scrollContainer
);
}
}
scrollToReference(ref) {
this.isTriggeredScroll = true;
if (this.scrollRef.current && ref.current) {
ref.current.scrollIntoView({ block: 'center' });
}
if (navigator.userAgent.includes("Firefox")) {
recalculateScrollTop(
this.state.lastScrollHeight,
this.scrollContainer
);
}
}
}

View File

@ -1,198 +0,0 @@
import React, { Component, Fragment } from "react";
import { ChatMessage } from './chat-message';
import { ChatScrollContainer } from "./chat-scroll-container";
import { UnreadNotice } from "./unread-notice";
import { ResubscribeElement } from "./resubscribe-element";
import { BacklogElement } from "./backlog-element";
const MAX_BACKLOG_SIZE = 1000;
const DEFAULT_BACKLOG_SIZE = 200;
const PAGE_SIZE = 50;
const INITIAL_LOAD = 20;
export class ChatWindow extends Component {
constructor(props) {
super(props);
this.state = {
numPages: 1,
};
this.hasAskedForMessages = false;
this.dismissUnread = this.dismissUnread.bind(this);
this.scrollIsAtBottom = this.scrollIsAtBottom.bind(this);
this.scrollIsAtTop = this.scrollIsAtTop.bind(this);
this.scrollReference = React.createRef();
this.unreadReference = React.createRef();
}
componentDidMount() {
this.initialFetch();
if (this.state.numPages === 1 && this.props.unreadCount < INITIAL_LOAD) {
this.dismissUnread();
this.scrollToBottom();
}
}
initialFetch() {
const { props } = this;
if (props.messages.length > 0) {
const unreadUnloaded = props.unreadCount - props.messages.length;
if (unreadUnloaded <= MAX_BACKLOG_SIZE &&
unreadUnloaded + INITIAL_LOAD > DEFAULT_BACKLOG_SIZE) {
this.fetchBacklog(unreadUnloaded + INITIAL_LOAD);
} else {
this.fetchBacklog(DEFAULT_BACKLOG_SIZE);
}
} else {
setTimeout(() => {
this.initialFetch();
}, 2000);
}
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
if (props.isChatMissing) {
props.history.push("/~chat");
} else if (props.messages.length >= prevProps.messages.length + 10) {
this.hasAskedForMessages = false;
let numPages = props.unreadCount > 0 ?
Math.ceil(props.unreadCount / PAGE_SIZE) : this.state.numPages;
if (this.state.numPages === numPages) {
if (props.unreadCount > 20) {
this.scrollToUnread();
}
} else {
this.setState({ numPages }, () => {
if (props.unreadCount > 20) {
this.scrollToUnread();
}
});
}
} else if (
state.numPages === 1 &&
this.props.unreadCount < INITIAL_LOAD &&
this.props.unreadCount > 0
) {
this.dismissUnread();
this.scrollToBottom();
}
}
scrollIsAtTop() {
const { props, state } = this;
this.setState({ numPages: state.numPages + 1 }, () => {
if (state.numPages * PAGE_SIZE < props.length) {
this.fetchBacklog(DEFAULT_BACKLOG_SIZE);
}
});
}
scrollIsAtBottom() {
if (this.state.numPages !== 1) {
this.setState({ numPages: 1 });
this.dismissUnread();
}
}
scrollToBottom() {
if (this.scrollReference.current) {
this.scrollReference.current.scrollToBottom();
}
if (this.state.numPages !== 1) {
this.setState({ numPages: 1 });
}
}
scrollToUnread() {
if (this.scrollReference.current && this.unreadReference.current) {
this.scrollReference.current.scrollToReference(this.unreadReference);
}
}
dismissUnread() {
this.props.api.chat.read(this.props.station);
}
fetchBacklog(size) {
const { props } = this;
if (
props.messages.length >= props.length ||
this.hasAskedForMessages ||
props.length <= 0
) {
return;
}
const start =
props.length - props.messages[props.messages.length - 1].number;
if (start > 0) {
const end = start + size < props.length ? start + size : props.length;
props.api.chat.fetchMessages(start + 1, end, props.station);
this.hasAskedForMessages = true;
}
}
render() {
const { props, state } = this;
const sliceLength = Math.min(
state.numPages * PAGE_SIZE,
props.messages.length + props.pendingMessages.length
);
const messages =
props.pendingMessages
.concat(props.messages)
.slice(0, sliceLength);
return (
<Fragment>
<UnreadNotice
unreadCount={props.unreadCount}
unreadMsg={props.unreadMsg}
dismissUnread={this.dismissUnread} />
<ChatScrollContainer
ref={this.scrollReference}
scrollIsAtBottom={this.scrollIsAtBottom}
scrollIsAtTop={this.scrollIsAtTop}>
<BacklogElement isChatLoading={props.isChatLoading} />
<ResubscribeElement
api={props.api}
host={props.ship}
station={props.station}
isChatUnsynced={props.isChatUnsynced}
/>
{ messages.map((msg, i) => (
<ChatMessage
key={msg.uid}
unreadRef={this.unreadReference}
isLastUnread={
props.unreadCount > 0 &&
i === props.unreadCount - 1 &&
state.numPages !== 1
}
msg={msg}
previousMsg={messages[i - 1]}
nextMsg={messages[i + 1]}
association={props.association}
group={props.group}
contacts={props.contacts}
hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames}
/>
))
}
</ChatScrollContainer>
</Fragment>
);
}
}

View File

@ -27,6 +27,21 @@ const DISABLED_INLINE_TOKENS = [
const MessageMarkdown = React.memo(props => (
<ReactMarkdown
{...props}
unwrapDisallowed={true}
allowNode={(node, index, parent) => {
if (
node.type === 'blockquote'
&& parent.type === 'root'
&& node.children.length
&& node.children[0].type === 'paragraph'
&& node.children[0].position.start.offset < 2
) {
node.children[0].children[0].value = '>' + node.children[0].children[0].value;
return false;
}
return true;
}}
plugins={[[
RemarkDisableTokenizers,
{ block: DISABLED_BLOCK_TOKENS, inline: DISABLED_INLINE_TOKENS }

View File

@ -1,106 +0,0 @@
import React, { Component } from 'react';
import { Button } from '@tlon/indigo-react';
const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
const YOUTUBE_REGEX =
new RegExp(
String(/(?:https?:\/\/(?:[a-z]+.)?)/.source) // protocol
+ /(?:youtu\.?be(?:\.com)?\/)(?:embed\/)?/.source // short and long-links
+ /(?:(?:(?:(?:watch\?)?(?:time_continue=(?:[0-9]+))?.+v=)?([a-zA-Z0-9_-]+))(?:\?t\=(?:[0-9a-zA-Z]+))?)/.source // id
);
export default class UrlContent extends Component {
constructor() {
super();
this.state = {
unfold: false,
copied: false
};
this.unfoldEmbed = this.unfoldEmbed.bind(this);
}
unfoldEmbed(id) {
let unfoldState = this.state.unfold;
unfoldState = !unfoldState;
this.setState({ unfold: unfoldState });
this.iframe.setAttribute('src', this.iframe.dataset.src);
}
render() {
const { props } = this;
const content = props.content;
const imgMatch = IMAGE_REGEX.exec(props.content.url);
const ytMatch = YOUTUBE_REGEX.exec(props.content.url);
let contents = content.url;
if (imgMatch) {
contents = (
<img
className="o-80-d"
src={content.url}
style={{
maxWidth: '18rem'
}}
></img>
);
return (
<a className='f7 lh-copy v-top word-break-all'
href={content.url}
target="_blank"
rel="noopener noreferrer"
>
{contents}
</a>
);
} else if (ytMatch) {
contents = (
<div className={'embed-container mb2 w-100 w-75-l w-50-xl ' +
((this.state.unfold === true)
? 'db' : 'dn')}
>
<iframe
ref={(el) => {
this.iframe = el;
}}
width="560"
height="315"
data-src={`https://www.youtube.com/embed/${ytMatch[1]}`}
frameBorder="0" allow="picture-in-picture, fullscreen"
>
</iframe>
</div>
);
return (
<div>
<a href={content.url}
className='f7 lh-copy v-top bb b--white-d word-break-all'
target="_blank"
rel="noopener noreferrer"
>
{content.url}
</a>
<Button
border={1}
style={{ display: 'inline-flex', height: '1.66em' }} // Height is hacked to line-height until Button supports proper size
ml={1}
onClick={e => this.unfoldEmbed()}
>
{this.state.unfold ? 'collapse' : 'embed'}
</Button>
{contents}
</div>
);
} else {
return (
<a className='f7 lh-copy v-top bb b--white-d b--black word-break-all'
href={content.url}
target="_blank"
rel="noopener noreferrer"
>
{contents}
</a>
);
}
}
}

View File

@ -1,51 +1,54 @@
import React, { Component } from 'react';
import React, { memo } from 'react';
export const DeleteButton = memo(({ isOwner, station, changeLoading, association, contacts, api }) => {
const leaveButtonClasses = (!isOwner) ? 'pointer' : 'c-default';
const deleteButtonClasses = (isOwner) ?
'b--red2 red2 pointer bg-gray0-d' :
'b--gray3 gray3 bg-gray0-d c-default';
export const DeleteButton = (props) => {
const { isOwner, station, changeLoading, api } = props;
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);
}
);
};
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.{' '}
You will need to request for access again.
</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>
const deleteChat = () => {
changeLoading(
true,
true,
isOwner ? 'Deleting chat...' : 'Leaving chat...',
() => {
api.chat.delete(station);
}
);
};
};
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,11 +1,13 @@
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) {
@ -26,19 +28,19 @@ export class GroupItem extends Component {
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());
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];
@ -47,9 +49,13 @@ export class GroupItem extends Component {
each in props.chatMetadata &&
props.chatMetadata[each].metadata
) {
title = props.chatMetadata[each].metadata.title
? props.chatMetadata[each].metadata.title
: each.substr(1);
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;
@ -73,6 +79,7 @@ export class GroupItem extends Component {
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' }}
@ -82,7 +89,7 @@ export class GroupItem extends Component {
}
return (
<div className={first + 'relative'}>
<p className="f9 ph4 gray3">{title}</p>
<p className="f9 ph4 gray3" key="p">{title}</p>
{dmLink}
{channelItems}
</div>

View File

@ -1,33 +0,0 @@
import React, { Component } from 'react';
import TextContent from './content/text';
import CodeContent from './content/code';
import UrlContent from './content/url';
export default class MessageContent extends Component {
render() {
const { props } = this;
const content = props.letter;
if ('code' in content) {
return <CodeContent content={content} />;
} else if ('url' in content) {
return <UrlContent content={content} />;
} else if ('me' in content) {
return (
<p className='f7 i lh-copy v-top'>
{content.me}
</p>
);
}
else if ('text' in content) {
return <TextContent content={content} />;
} else {
return null;
}
}
}

View File

@ -1,106 +0,0 @@
import React, { Component } from 'react';
import { OverlaySigil } from './overlay-sigil';
import MessageContent from './message-content';
import { uxToHex, cite, writeText } from '~/logic/lib/util';
import moment from 'moment';
export const Message = (props) => {
const pending = props.msg.pending ? ' o-40' : '';
const containerClass =
props.renderSigil ?
`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ` + pending :
'w-100 pr3 cf hide-child flex' + pending;
const timestamp =
moment.unix(props.msg.when / 1000).format(
props.renderSigil ? 'hh:mm a' : 'hh:mm'
);
return (
<div className={containerClass}
style={{
minHeight: 'min-content'
}}>
{
props.renderSigil ? (
renderWithSigil(props, timestamp)
) : (
<div className="flex w-100">
<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 }}>
<MessageContent letter={props.msg.letter} />
</div>
</div>
)
}
</div>
);
};
const renderWithSigil = (props, timestamp) => {
const paddingTop = props.paddingTop ? { 'paddingTop': '6px' } : '';
const datestamp =
'~' + moment.unix(props.msg.when / 1000).format('YYYY.M.D');
const contact = props.msg.author in props.contacts
? props.contacts[props.msg.author] : false;
const showNickname = !props.hideNicknames && contact?.nickname;
let name = `~${props.msg.author}`;
let color = '#000000';
let sigilClass = 'mix-blend-diff';
if (contact) {
name = showNickname
? contact.nickname
: `~${props.msg.author}`;
color = `#${uxToHex(contact.color)}`;
sigilClass = '';
}
if (`~${props.msg.author}` === name) {
name = cite(props.msg.author);
}
return (
<div className="flex w-100">
<OverlaySigil
ship={props.msg.author}
contact={contact}
color={color}
sigilClass={sigilClass}
association={props.association}
group={props.group}
hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames}
className="fl pr3 v-top bg-white bg-gray0-d"
/>
<div className="fr clamp-message white-d"
style={{ flexGrow: 1, marginTop: -8 }}>
<div className="hide-child" style={paddingTop}>
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
<span
className={
'mw5 db truncate pointer ' +
(showNickname ? '' : 'mono')
}
onClick={() => {
writeText(props.msg.author);
}}
title={`~${props.msg.author}`}
>
{name}
</span>
</p>
<p className={`v-mid mono f9 gray2 dib`}>{timestamp}</p>
<p className={`v-mid mono f9 ml2 gray2 dib child dn-s`}>
{datestamp}
</p>
</div>
<MessageContent letter={props.msg.letter} />
</div>
</div>
);
}

View File

@ -1,11 +1,11 @@
import React, { Component } from 'react';
import React, { PureComponent } from 'react';
import { Sigil } from '~/logic/lib/sigil';
import {
ProfileOverlay,
OVERLAY_HEIGHT
} from './profile-overlay';
export class OverlaySigil extends Component {
export class OverlaySigil extends PureComponent {
constructor() {
super();
this.state = {
@ -19,40 +19,29 @@ export class OverlaySigil extends Component {
this.profileShow = this.profileShow.bind(this);
this.profileHide = this.profileHide.bind(this);
this.updateContainerOffset = this.updateContainerOffset.bind(this);
this.updateContainerInterval = null;
}
profileShow() {
this.updateContainerOffset();
this.setState({ profileClicked: true });
this.updateContainerInterval = setInterval(
this.updateContainerOffset.bind(this),
1000
);
this.props.scrollWindow.addEventListener('scroll', this.updateContainerOffset);
}
profileHide() {
this.setState({ profileClicked: false });
if(this.updateContainerInterval) {
clearInterval(this.updateContainerInterval);
this.updateContainerInterval = null;
}
this.props.scrollWindow.removeEventListener('scroll', this.updateContainerOffset, true);
}
updateContainerOffset() {
if (this.containerRef && this.containerRef.current) {
const parent = this.containerRef.current.offsetParent;
const { offsetTop } = this.containerRef.current;
const container = this.containerRef.current;
const scrollWindow = this.props.scrollWindow;
let bottomSpace, topSpace;
const bottomSpace = scrollWindow.scrollHeight - container.offsetTop - scrollWindow.scrollTop;
const topSpace = scrollWindow.offsetHeight - bottomSpace - OVERLAY_HEIGHT;
if(navigator.userAgent.includes('Firefox')) {
topSpace = offsetTop - parent.scrollTop - OVERLAY_HEIGHT / 2;
bottomSpace = parent.clientHeight - topSpace - OVERLAY_HEIGHT;
} else {
topSpace = offsetTop + parent.scrollHeight - parent.clientHeight - parent.scrollTop;
bottomSpace = parent.clientHeight - topSpace - OVERLAY_HEIGHT;
}
this.setState({
topSpace,
bottomSpace
@ -60,6 +49,10 @@ export class OverlaySigil extends Component {
}
}
componentWillUnmount() {
this.props.scrollWindow?.removeEventListener('scroll', this.updateContainerOffset, true);
}
render() {
const { props, state } = this;
const { hideAvatars } = props;

View File

@ -1,11 +1,11 @@
import React, { Component } from 'react';
import React, { PureComponent } from 'react';
import { Link } from 'react-router-dom';
import { cite } from '~/logic/lib/util';
import { Sigil } from '~/logic/lib/sigil';
export const OVERLAY_HEIGHT = 250;
export class ProfileOverlay extends Component {
export class ProfileOverlay extends PureComponent {
constructor() {
super();

View File

@ -1,105 +0,0 @@
import React, { Component } from 'react';
import S3Client from '~/logic/lib/s3';
export class S3Upload extends Component {
constructor(props) {
super(props);
this.s3 = new S3Client();
this.setCredentials(props.credentials, props.configuration);
this.inputRef = React.createRef();
}
isReady(creds, config) {
return (
Boolean(creds) &&
'endpoint' in creds &&
'accessKeyId' in creds &&
'secretAccessKey' in creds &&
creds.endpoint !== '' &&
creds.accessKeyId !== '' &&
creds.secretAccessKey !== '' &&
Boolean(config) &&
'currentBucket' in config &&
config.currentBucket !== ''
);
}
componentDidUpdate(prevProps) {
const { props } = this;
this.setCredentials(props.credentials, props.configuration);
}
setCredentials(credentials, configuration) {
if (!this.isReady(credentials, configuration)) {
return;
}
this.s3.setCredentials(
credentials.endpoint,
credentials.accessKeyId,
credentials.secretAccessKey
);
}
getFileUrl(endpoint, filename) {
return endpoint + '/' + filename;
}
onChange() {
const { props } = this;
if (!this.inputRef.current) {
return;
}
const files = this.inputRef.current.files;
if (files.length <= 0) {
return;
}
const file = files.item(0);
const bucket = props.configuration.currentBucket;
this.s3.upload(bucket, file.name, file).then((data) => {
if (!data || !('Location' in data)) {
return;
}
this.props.uploadSuccess(data.Location);
}).catch((err) => {
console.error(err);
this.props.uploadError(err);
});
}
onClick() {
if (!this.inputRef.current) {
return;
}
this.inputRef.current.click();
}
render() {
const { props } = this;
if (!this.isReady(props.credentials, props.configuration)) {
return <div></div>;
} else {
const classes = props.className ?
'pointer ' + props.className : 'pointer';
return (
<div className={classes}>
<input className="dn"
type="file"
id="fileElement"
ref={this.inputRef}
accept="image/*"
onChange={this.onChange.bind(this)}
/>
<img className="invert-d"
src="/~chat/img/ImageUpload.png"
width="16"
height="16"
onClick={this.onClick.bind(this)}
/>
</div>
);
}
}
}

View File

@ -2,7 +2,7 @@ import React, { Component } from 'react';
import moment from 'moment';
export const UnreadNotice = (props) => {
const { unreadCount, unreadMsg, dismissUnread } = props;
const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
if (!unreadMsg || (unreadCount === 0)) {
return null;
@ -22,7 +22,7 @@ export const UnreadNotice = (props) => {
"ba b--green2 green2 bg-white bg-gray0-d flex items-center " +
"pa2 f9 justify-between br1"
}>
<p className="lh-copy db">
<p className="lh-copy db pointer" onClick={onClick}>
{unreadCount} new messages since{' '}
{datestamp && (
<>

View File

@ -3,7 +3,7 @@ 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 } from '~/logic/lib/util';
import { deSig, cite } from '~/logic/lib/util';
export class NewDmScreen extends Component {
constructor(props) {
@ -91,7 +91,7 @@ export class NewDmScreen extends Component {
const aud = state.ship !== window.ship ? [`~${state.ships[0]}`] : [];
let title = `~${window.ship} <-> ~${state.ships[0]}`;
let title = `${cite(window.ship)} <-> ${cite(state.ships[0])}`;
if (state.title !== '') {
title = state.title;

View File

@ -1,15 +1,12 @@
import React, { Component, Fragment } from 'react';
import { deSig } from '~/logic/lib/util';
import { Link } from 'react-router-dom';
import { ChatHeader } from './lib/chat-header';
import { MetadataSettings } from './lib/metadata-settings';
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';
import { Spinner } from '~/views/components/Spinner';
import { ChatTabBar } from './lib/chat-tabbar';
import SidebarSwitcher from '~/views/components/SidebarSwitch';
export class SettingsScreen extends Component {
constructor(props) {
@ -89,13 +86,17 @@ export class SettingsScreen extends Component {
isOwner={isOwner}
changeLoading={this.changeLoading}
station={station}
association={association}
contacts={contacts}
api={api} />
<MetadataSettings
isOwner={isOwner}
changeLoading={this.changeLoading}
api={api}
association={association}
station={station} />
resource="chat"
app="chat"
/>
<Spinner
awaiting={this.state.awaiting}
classes="absolute right-2 bottom-2 ba pa2 b--gray1-d"

View File

@ -116,20 +116,8 @@ h2 {
100% {transform: rotate(360deg);}
}
/* embeds */
.embed-container {
position: relative;
height: 0;
overflow: hidden;
padding-bottom: 28.125%;
}
.embed-container iframe, .embed-container object, .embed-container embed {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.embed-container iframe {
max-width: 100%;
}
.mh-16 {
@ -188,9 +176,6 @@ h2 {
.h-100-minus-96-s {
height: calc(100% - 96px);
}
.embed-container {
padding-bottom: 56.25%;
}
.unread-notice {
top: 96px;
}
@ -200,18 +185,12 @@ h2 {
.flex-basis-250-m {
flex-basis: 250px;
}
.embed-container {
padding-bottom: 56.25%;
}
}
@media all and (min-width: 46.875em) and (max-width: 60em) {
.flex-basis-250-l {
flex-basis: 250px;
}
.embed-container {
padding-bottom: 37.5%;
}
}
@media all and (min-width: 60em) {
@ -252,10 +231,15 @@ blockquote {
font-family: 'Inter';
}
code, pre.code {
pre, code {
background-color: var(--light-gray);
}
pre code {
background-color: transparent;
white-space: pre-wrap;
}
code, .code, .chat.code .react-codemirror2 .CodeMirror * {
font-family: 'Source Code Pro';
}

View File

@ -20,7 +20,6 @@ export default class DojoApp extends Component {
this.store.setStateHandler(this.setState.bind(this));
this.state = this.store.state;
this.resetControllers();
}
resetControllers() {
@ -29,6 +28,7 @@ export default class DojoApp extends Component {
}
componentDidMount() {
this.resetControllers();
const channel = new window.channel();
this.api = new Api(this.props.ship, channel);
this.store.api = this.api;

View File

@ -5,7 +5,7 @@ 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 './s3-upload';
import { S3Upload } from '~/views/components/s3-upload';
export class ContactCard extends Component {
constructor(props) {
@ -492,7 +492,15 @@ export class ContactCard extends Component {
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"

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { FixedSizeList as List } from 'react-window';
import { Virtuoso as VirtualList } from 'react-virtuoso';
import { ContactItem } from './contact-item';
import { ShareSheet } from './share-sheet';
@ -14,17 +14,17 @@ 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;
activeDrawer: 'contacts' | 'detail' | 'rightPanel';
groups: Groups;
group: Group
contacts: Contacts;
path: Path;
api: GlobalApi;
defaultContacts: Contacts;
selectedContact?: PatpNoSig;
}
interface ContactSidebarState {
awaiting: boolean;
awaiting: boolean;
memberboxHeight: number;
}
@ -57,17 +57,17 @@ export class ContactSidebar extends Component<ContactSidebarProps, ContactSideba
const group = props.groups[props.path];
const members = new Set(group.members || []);
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]
? 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
(<ShareSheet
ship={window.ship}
nickname={me.nickname}
avatar={me.avatar}
@ -75,57 +75,57 @@ export class ContactSidebar extends Component<ContactSidebarProps, ContactSideba
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}
/>
</>
);
) : (
<>
<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}
/>
);
});
.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 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'))
|| (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'}
key={member}
className={'pl4 pt1 pb1 f9 flex justify-start content-center ' +
'bg-white bg-gray0-d relative'}
>
<Sigil
ship={member}
@ -160,8 +160,8 @@ export class ContactSidebar extends Component<ContactSidebarProps, ContactSideba
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}
'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>
@ -180,23 +180,21 @@ export class ContactSidebar extends Component<ContactSidebarProps, ContactSideba
>Channels</Link>
{shareSheet}
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">Members</h2>
<List
height={this.state.memberboxHeight}
<VirtualList
style={{ height: this.state.memberboxHeight, width: '100%' }}
className="flex-auto"
itemCount={contactItems.length + groupItems.length}
itemSize={44}
width="100%"
>
{({ index, style }) => (<div style={style}>{
index <= (contactItems.length - 1) // If the index is within the length of contact items,
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>)}
</List>
}
/>
</div>
<Spinner awaiting={this.state.awaiting} text="Removing from group..." classes="pa2 ba absolute right-1 bottom-1 b--gray1-d" />
</div>
</div>
);
}
}

Some files were not shown because too many files have changed in this diff Show More