Merge branch 'release/link' into lt/link-js

This commit is contained in:
Logan Allen 2020-09-17 14:02:58 -05:00
commit 20832ab53a
136 changed files with 3546 additions and 3641 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:3f5741b71f11a562d443fc619eb1b6bb1ccf419375aa2f1eebbd1c06dce20cd0
size 6268477

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
::
@ -114,7 +114,7 @@
i.syncs
?> ?=(^ pax)
?. =('~' i.pax)
$(syncs t.syncs)
$(syncs t.syncs)
=/ new-path=path
t.pax
=. synced.old

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,
@ -157,7 +159,7 @@
(on-arvo:def wire sign-arvo)
::
++ on-save !>(state)
++ on-load
++ on-load
|= old-vase=vase
^- (quip card _this)
=/ old ((soft state-0) q.old-vase)
@ -211,8 +213,8 @@
?- -.act
%create
?> ?=(^ app-path.act)
?> ?| =(+:group-path.act app-path.act)
=(~(tap in members.act) ~)
?> ?| =(+:group-path.act app-path.act)
=(~(tap in members.act) ~)
==
?^ (chat-scry app-path.act)
~& %chat-already-exists
@ -295,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,
@ -54,7 +55,7 @@
=/ old !<(versioned-state old-vase)
=| cards=(list card)
|^
|- ^- (quip card _this)
|- ^- (quip card _this)
?: ?=(%3 -.old)
[cards this(state old)]
?: ?=(%2 -.old)
@ -80,7 +81,7 @@
%_ $
-.old %2
::
synced.old
synced.old
%- malt
%+ turn
~(tap by synced.old)
@ -126,7 +127,7 @@
%json
(poke-json:cc !<(json vase))
::
%contact-action
%contact-action
(poke-contact-action:cc !<(contact-action vase))
::
%contact-hook-action
@ -149,7 +150,7 @@
%kick [(kick:cc wire) this]
%watch-ack
=^ cards state
(watch-ack:cc wire p.sign)
(watch-ack:cc wire p.sign)
[cards this]
::
%fact
@ -301,8 +302,8 @@
[%pass /group %agent [our.bol %group-store] %watch /groups]~
::
[%contacts @ *]
=/ wir
?: =(%ship i.t.wir)
=/ wir
?: =(%ship i.t.wir)
wir
(migrate wir)
?> ?=([%contacts @ @ *] wir)

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
|%
@ -253,7 +255,7 @@
++ send-diff
|= [pax=path upd=contact-update]
^- (list card)
:~ :*
:~ :*
%give %fact
~[/all /updates [%contacts pax]]
%contact-update !>(upd)

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

@ -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
|%
@ -218,8 +222,8 @@
::
[~ %html]
%. file
%* . html-response:gen
cache
%* . html-response:gen
cache
!=(/app/landscape/index/html (slag 3 scry-path))
==
==

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 0v4.kdc52.27is2.c7mnh.7vsrb.ij4jo
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states
$% state-0

View File

@ -0,0 +1,48 @@
/- *resource
/+ store=graph-store, graph, default-agent, verb, dbug, pull-hook
~% %graph-pull-hook-top ..is ~
|%
+$ card card:agent:gall
++ config
^- config:pull-hook
:* %graph-store
update:store
%graph-update
%graph-push-hook
==
--
::
%- agent:dbug
^- agent:gall
%- (agent:pull-hook config)
^- (pull-hook:pull-hook config)
|_ =bowl:gall
+* this .
def ~(. (default-agent this %|) bowl)
dep ~(. (default:pull-hook this config) bowl)
::
++ on-init on-init:def
++ on-save !>(~)
++ on-load on-load:def
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
++ on-agent on-agent:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-pull-nack
|= [=resource =tang]
^- (quip card _this)
:_ this
=- [%pass /pull-nack %agent [our.bowl %graph-store] %poke %graph-update -]~
!> ^- update:store
[%0 now.bowl [%archive-graph resource]]
::
++ on-pull-kick
|= =resource
^- (unit path)
=/ maybe-time (peek-update-log:graph resource)
?~ maybe-time `/
`/(scot %da u.maybe-time)
--

View File

@ -0,0 +1,123 @@
/+ store=graph-store
/+ met=metadata
/+ res=resource
/+ graph
/+ group
/+ default-agent
/+ dbug
/+ push-hook
~% %graph-push-hook-top ..is ~
|%
+$ card card:agent:gall
++ config
^- config:push-hook
:* %graph-store
/updates
update:store
%graph-update
%graph-pull-hook
==
::
+$ agent (push-hook:push-hook config)
::
++ is-member
|= [=resource:res =bowl:gall]
^- ?
=/ grp ~(. group bowl)
=/ group-paths (groups-from-resource:met [%graph (en-path:res resource)])
?~ group-paths %.n
(is-member:grp src.bowl i.group-paths)
--
::
%- agent:dbug
^- agent:gall
%- (agent:push-hook config)
^- agent
|_ =bowl:gall
+* this .
def ~(. (default-agent this %|) bowl)
grp ~(. group bowl)
gra ~(. graph bowl)
::
++ on-init on-init:def
++ on-save !>(~)
++ on-load on-load:def
++ on-poke on-poke:def
++ on-agent on-agent:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
::
++ should-proxy-update
|= =vase
^- ?
=/ =update:store !<(update:store vase)
?- -.q.update
%add-graph (is-member resource.q.update bowl)
%remove-graph (is-member resource.q.update bowl)
%add-nodes (is-member resource.q.update bowl)
%remove-nodes (is-member resource.q.update bowl)
%add-signatures (is-member resource.uid.q.update bowl)
%remove-signatures (is-member resource.uid.q.update bowl)
%archive-graph (is-member resource.q.update bowl)
%unarchive-graph %.n
%add-tag %.n
%remove-tag %.n
%keys %.n
%tags %.n
%tag-queries %.n
%run-updates (is-member resource.q.update bowl)
==
::
++ resource-for-update
|= =vase
^- (unit resource:res)
=/ =update:store !<(update:store vase)
?- -.q.update
%add-graph `resource.q.update
%remove-graph `resource.q.update
%add-nodes `resource.q.update
%remove-nodes `resource.q.update
%add-signatures `resource.uid.q.update
%remove-signatures `resource.uid.q.update
%archive-graph `resource.q.update
%unarchive-graph ~
%add-tag ~
%remove-tag ~
%keys ~
%tags ~
%tag-queries ~
%run-updates `resource.q.update
==
::
++ initial-watch
|= [=path =resource:res]
^- vase
?> (is-member resource bowl)
!> ^- update:store
?~ path
:: new subscribe
::
(get-graph:gra resource)
:: resubscribe
::
=/ =time (slav %da i.path)
=/ =update-log:store (get-update-log-subset:gra resource time)
[%0 now.bowl [%run-updates resource update-log]]
::
++ take-update
|= =vase
^- [(list card) agent]
=/ =update:store !<(update:store vase)
?+ -.q.update [~ this]
%remove-graph
:_ this
[%give %kick ~[resource+(en-path:res resource.q.update)] ~]~
::
%archive-graph
:_ this
[%give %kick ~[resource+(en-path:res resource.q.update)] ~]~
==
--

View File

@ -1,3 +1,6 @@
:: graph-store [landscape]
::
::
/+ store=graph-store, sigs=signatures, res=resource, default-agent, dbug
~% %graph-store-top ..is ~
|%
@ -282,7 +285,7 @@
?~ index graph
=* atom i.index
=/ =node:store
~| "node does not exist to add signatures to!"
~| "node does not exist to add signatures to!"
(need (get:orm graph atom))
:: last index in list
::
@ -327,7 +330,7 @@
?~ index graph
=* atom i.index
=/ =node:store
~| "node does not exist to add signatures to!"
~| "node does not exist to add signatures to!"
(need (get:orm graph atom))
:: last index in list
::
@ -529,6 +532,15 @@
^- [index:store node:store]
[(snoc index atom) node]
==
::
[%x %update-log-subset @ @ @ @ ~]
=/ =ship (slav %p i.t.t.path)
=/ =term i.t.t.t.path
=/ start=(unit time) (slaw %da i.t.t.t.t.path)
=/ end=(unit time) (slaw %da i.t.t.t.t.t.path)
=/ update-log=(unit update-log:store) (~(get by update-logs) [ship term])
?~ update-log [~ ~]
``noun+!>((subset:orm-log u.update-log start end))
::
[%x %update-log @ @ ~]
=/ =ship (slav %p i.t.t.path)
@ -543,7 +555,7 @@
=/ update-log=(unit update-log:store) (~(get by update-logs) [ship term])
?~ update-log [~ ~]
=/ result=(unit [time update:store])
(peek:orm-log:store u.update-log)
(peek:orm-log:store u.update-log)
?~ result [~ ~]
``noun+!>([~ -.u.result])
==

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
@ -58,7 +60,7 @@
:: ignore duplicate publish groups
?: =(4 (lent path))
~& "ignoring: {<path>}"
~
~
=/ pax=^path
?: =('~' i.path)
t.path

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
@ -128,7 +130,7 @@
^- [resource group]
=/ members=(set ship)
(~(got by groups.old) pax)
=| =invite:policy
=| =invite:policy
?> ?=(^ pax)
=/ rid=resource
(resource-from-old-path t.pax)
@ -149,7 +151,7 @@
|= pax=path
=/ members
(~(got by groups.old) pax)
=| =invite:policy
=| =invite:policy
=/ rid=resource
(resource-from-old-path pax)
=/ =tags
@ -239,7 +241,7 @@
(~(has in members.group) ship)
==
%open
?! ?|
?! ?|
(~(has in banned.policy) ship)
(~(has in ban-ranks.policy) (clan:title ship))
==
@ -285,7 +287,7 @@
^- resource
?> ?=([@ @ *] path)
:- (slav %p i.path)
i.t.path
i.t.path
::
++ add-new
|= =permission:permission-store
@ -293,7 +295,7 @@
?: ?=(%black kind.permission)
[~ ~ [%open ~ who.permission] %.y]
[who.permission ~ [%invite ~] %.y]
::
::
++ update-existing
|= =permission:permission-store
|= =group

View File

@ -2,7 +2,7 @@
/+ drum=hood-drum, helm=hood-helm, kiln=hood-kiln
|%
+$ state
$: %9
$: %10
drum=state:drum
helm=state:helm
kiln=state:kiln
@ -12,6 +12,7 @@
[ver=?(%1 %2 %3 %4 %5 %6) lac=(map @tas fin-any-state)]
[%7 drum=state:drum helm=state:helm kiln=state:kiln]
[%8 drum=state:drum helm=state:helm kiln=state:kiln]
[%9 drum=state:drum helm=state:helm kiln=state:kiln]
==
+$ any-state-tuple
$: drum=any-state:drum

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.ecc81763be57d23e6028.js"></script>
<script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script>
</body>
</html>

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

@ -1,644 +1,46 @@
:: link-listen-hook: get your friends' bookmarks
:: link-listen-hook: no longer in use
::
:: keeps track of a listening=(set app-path). users can manually add to and
:: remove from this set.
::
:: for all ships in groups associated with those resources, we subscribe to
:: their link's local-pages and annotations at the resource path (through
:: link-proxy-hook), and forward all entries into our link-store as
:: submissions and comments.
::
:: if a subscription to a target fails, we assume it's because their
:: metadata+groups definition hasn't been updated to include us yet.
:: we retry with exponential backoff, maxing out at one hour timeouts.
:: to expede this process, we prod other potential listeners when we add
:: them to our metadata+groups definition.
::
::
/- listen-hook=link-listen-hook, *metadata-store, *group, *link
/+ mdl=metadata, default-agent, verb, dbug, group-store, grpl=group, resource, store=link-store
/+ default-agent, verb, dbug
::
~% %link-listen-hook-top ..is ~
|%
+$ versioned-state
$% [%0 state-0]
[%1 state-1]
[%2 state-2]
[%3 state-3]
$% [%0 *]
[%1 *]
[%2 *]
[%3 *]
[%4 ~]
==
+$ state-3 state-1
+$ state-2 state-1
+$ state-1
$: listening=(set app-path)
state-0
==
+$ state-0
$: retry-timers=(map target @dr)
:: reasoning: the resources we're subscribed to,
:: and the groups that cause that.
::
:: we don't strictly need to track this in state, but doing so heavily
:: simplifies logic and reduces the amount of big scries we do.
:: this also gives us the option to check & restore subscriptions,
:: should we ever need that.
::
reasoning=(jug [ship app-path] group-path)
==
::
+$ what-target ?(%local-pages %annotations)
+$ target
$: what=what-target
who=ship
where=path
==
++ wire-to-target
|= =wire
^- target
?> ?=([what-target @ ^] wire)
[i.wire (slav %p i.t.wire) t.t.wire]
++ target-to-wire
|= target
^- wire
[what (scot %p who) where]
::
+$ card card:agent:gall
--
::
=| [%3 state-3]
=| [%4 ~]
=* state -
::
%- agent:dbug
%+ verb |
^- agent:gall
=<
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
::
++ on-init
^- (quip card _this)
:_ this
~[watch-metadata:do watch-groups:do]
::
++ on-save !>(state)
++ on-load
|= =vase
^- (quip card _this)
=/ old=versioned-state
!<(versioned-state vase)
=| cards=(list card)
|-
=* upgrade-loop $
?- -.old
%3 [cards this(state old)]
::
%2
:_ this(state [%3 +.old])
%+ welp cards
:~ [%pass /groups %agent [our.bowl %group-store] %leave ~]
watch-groups:do
==
::
%1
:: the upgrade from 0 left out local-only collections.
:: here, we pull those back in.
::
=. listening.old
(~(run in ~(key by reasoning.old)) tail)
=/ resources=(list [=group-path =app-path])
%~ tap in
%. %link
%~ get ju
.^ (jug app-name [group-path app-path])
%gy
(scot %p our.bowl)
%metadata-store
(scot %da now.bowl)
/app-indices
==
|-
?~ resources
upgrade-loop(old [%2 +.old])
=, i.resources
=/ members=(set ship)
(members-from-path:grp:do group-path)
:: if we're the only group member, this got incorrectly ignored
:: during 0's upgrade logic. watch it now.
::
?. &(=(1 ~(wyt in members)) (~(has in members) our.bowl))
$(resources t.resources)
=^ more-cards state
(handle-listen-action:do %watch app-path)
$(resources t.resources, cards (weld more-cards cards))
::
%0
=/ listening=(set app-path)
(~(run in ~(key by reasoning.old)) tail)
$(old [%1 listening +.old])
==
::
++ on-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
=^ cards state
?+ wire ~|([dap.bowl %weird-agent-wire wire] !!)
[%metadata ~]
(take-metadata-sign:do sign)
::
[%groups ~]
(take-groups-sign:do sign)
::
[%links ?(%local-pages %annotations) @ ^]
(take-link-sign:do (wire-to-target t.wire) sign)
::
[%forward ^]
(take-forward-sign:do t.wire sign)
::
[%prod *]
?> ?=(%poke-ack -.sign)
?~ p.sign [~ state]
%- (slog leaf+"prod failed" u.p.sign)
[~ state]
==
[cards this]
::
++ on-poke
|= [=mark =vase]
?+ mark (on-poke:def mark vase)
%link-listen-poke
=/ =path !<(path vase)
:_ this
%+ weld
(take-retry:do %local-pages src.bowl path)
(take-retry:do %annotations src.bowl path)
::
%link-listen-action
?> (team:title [our src]:bowl)
=^ cards state
~| p.vase
(handle-listen-action:do !<(action:listen-hook vase))
[cards this]
==
::
++ on-arvo
|= [=wire =sign-arvo]
^- (quip card _this)
?+ sign-arvo (on-arvo:def wire sign-arvo)
[%g %done *]
?~ error.sign-arvo [~ this]
=/ =tank leaf+"{(trip dap.bowl)}'s message went wrong!"
%- (slog tank tang.u.error.sign-arvo)
[~ this]
::
[%b %wake *]
?> ?=([%retry @ @ ^] wire)
?^ error.sign-arvo
=/ =tank leaf+"wake on {(spud wire)} went wrong!"
%- (slog tank u.error.sign-arvo)
[~ this]
:_ this
(take-retry:do (wire-to-target t.wire))
==
::
++ on-peek
|= =path
^- (unit (unit cage))
?+ path ~
[%x %listening ~] ``noun+!>(listening)
[%x %listening ^] ``noun+!>((~(has in listening) t.t.path))
==
::
++ on-watch
|= =path
^- (quip card _this)
?. ?=([%listening ~] path) (on-watch:def path)
?> (team:title [our src]:bowl)
:_ this
[%give %fact ~ %link-listen-update !>([%listening listening])]~
::
++ on-leave on-leave:def
++ on-fail on-fail:def
--
::
::
|_ =bowl:gall
+* md ~(. mdl bowl)
++ grp ~(. grpl bowl)
+* this .
def ~(. (default-agent this %|) bowl)
::
:: user actions & updates
::
++ handle-listen-action
|= =action:listen-hook
^- (quip card _state)
::NOTE no-opping where appropriate happens further down the call stack.
:: we *could* no-op here, as %watch when we're already listening should
:: result in no-ops all the way down, but walking through everything
:: makes this a nice "resurrect if broken unexpectedly" option.
::
=* app-path path.action
=^ cards listening
^- (quip card _listening)
=/ had=? (~(has in listening) app-path)
?- -.action
%watch
:_ (~(put in listening) app-path)
?:(had ~ [(send-update action)]~)
::
%leave
:_ (~(del in listening) app-path)
?.(had ~ [(send-update action)]~)
==
=/ groups=(list group-path)
(groups-from-resource:md %link app-path)
|-
?~ groups [cards state]
=^ more-cards state
?- -.action
%watch (listen-to-group app-path i.groups)
%leave (leave-from-group app-path i.groups)
==
$(cards (weld cards more-cards), groups t.groups)
::
++ send-update
|= =update:listen-hook
++ on-init [~ this]
++ on-save !>(state)
++ on-load
|= =vase
^- (quip card _this)
:_ this
:- [%pass /groups %agent [our.bowl %group-store] %leave ~]
%+ turn ~(tap in ~(key by wex.bowl))
|= [=wire =ship =term]
^- card
[%give %fact ~[/listening] %link-listen-update !>(update)]
[%pass wire %agent [ship term] %leave ~]
::
:: metadata subscription
::
++ watch-metadata
^- card
[%pass /metadata %agent [our.bowl %metadata-store] %watch /app-name/link]
::
++ take-metadata-sign
|= =sign:agent:gall
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /metadata] !!)
%kick [[watch-metadata]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to metadata store. very wrong!"
%- (slog tank u.p.sign)
[~ state]
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?. ?=(%metadata-update mark)
~| [dap.bowl %unexpected-mark mark]
!!
%- handle-metadata-update
!<(metadata-update vase)
==
::
++ handle-metadata-update
|= upd=metadata-update
^- (quip card _state)
?+ -.upd [~ state]
%add
?> =(%link app-name.resource.upd)
:: auto-listen to collections in unmanaged groups only
::
=/ rid=resource
(de-path:resource group-path.upd)
=/ =group
(need (scry-group:grp rid))
?. hidden.group
[~ state]
=, resource.upd
=^ update listening
^- (quip card _listening)
?: (~(has in listening) app-path)
[~ listening]
:- [(send-update %watch app-path)]~
(~(put in listening) app-path)
=^ cards state
(listen-to-group app-path group-path.upd)
[(weld update cards) state]
::
%remove
?> =(%link app-name.resource.upd)
=? listening
?=(~ (groups-from-resource:md %link app-path.resource.upd))
(~(del in listening) app-path.resource.upd)
(leave-from-group app-path.resource.upd group-path.upd)
==
::
:: groups subscriptions
::
++ watch-groups
^- card
[%pass /groups %agent [our.bowl %group-store] %watch /groups]
::
++ take-groups-sign
|= =sign:agent:gall
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /groups] !!)
%kick [[watch-groups]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to groups. very wrong!"
%- (slog tank u.p.sign)
[~ state]
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark ~|([dap.bowl %unexpected-mark mark] !!)
%group-initial [~ state] ::NOTE initial handled using metadata
%group-update (handle-group-update !<(update:group-store vase))
==
==
::
++ handle-group-update
|= upd=update:group-store
^- (quip card _state)
?. ?=(?(%add-members %initial-group %remove-members) -.upd)
[~ state]
=/ =path
(en-path:resource resource.upd)
=/ socs=(list app-path)
(app-paths-from-group:md %link path)
=/ whos=(list ship)
?- -.upd
%add-members ~(tap in ships.upd)
%remove-members ~(tap in ships.upd)
%initial-group ~(tap in members.group.upd)
==
=| cards=(list card)
|-
=* loop-socs $
?~ socs [cards state]
?. (~(has in listening) i.socs)
loop-socs(socs t.socs)
|-
=* loop-whos $
?~ whos loop-socs(socs t.socs)
=^ caz state
?. ?=(%remove-members -.upd)
(listen-to-peer i.socs path i.whos)
?: =(our.bowl i.whos)
(handle-listen-action %leave i.socs)
(leave-from-peer i.socs path i.whos)
loop-whos(whos t.whos, cards (weld cards caz))
::
:: link subscriptions
::
++ listen-to-group
|= [=app-path =group-path]
^- (quip card _state)
=/ peers=(list ship)
~| group-path
%~ tap in
(members-from-path:grp group-path)
=| cards=(list card)
|-
?~ peers [cards state]
=^ caz state
(listen-to-peer app-path group-path i.peers)
$(peers t.peers, cards (weld cards caz))
::
++ leave-from-group
|= [=app-path =group-path]
^- (quip card _state)
=/ peers=(list ship)
%~ tap in
(members-from-path:grp group-path)
=| cards=(list card)
|-
?~ peers [cards state]
=^ caz state
(leave-from-peer app-path group-path i.peers)
$(peers t.peers, cards (weld cards caz))
::
++ listen-to-peer
|= [=app-path =group-path who=ship]
^- (quip card _state)
?: =(our.bowl who)
[~ state]
:_ =- state(reasoning -)
(~(put ju reasoning) [who app-path] group-path)
:- (prod-other-listener who app-path)
?^ (~(get ju reasoning) [who app-path])
~
(start-link-subscriptions who app-path)
::
++ leave-from-peer
|= [=app-path =group-path who=ship]
^- (quip card _state)
?: =(our.bowl who)
[~ state]
=. reasoning (~(del ju reasoning) [who app-path] group-path)
::NOTE leaving is always safe, so we just do it unconditionally
(end-link-subscriptions who app-path)
::
++ start-link-subscriptions
|= [=ship =app-path]
^- (list card)
:~ (start-link-subscription %local-pages ship app-path)
(start-link-subscription %annotations ship app-path)
==
::
++ start-link-subscription
|= =target
^- card
:* %pass
[%links (target-to-wire target)]
%agent
[who.target %link-proxy-hook]
%watch
?- what.target
%local-pages [what where]:target
%annotations [what %$ where]:target
==
==
::
++ end-link-subscriptions
|= [who=ship where=path]
^- (quip card _state)
=. retry-timers (~(del by retry-timers) [%local-pages who where])
=. retry-timers (~(del by retry-timers) [%annotations who where])
:_ state
|^ ~[(end %local-pages) (end %annotations)]
::
++ end
|= what=what-target
:* %pass
[%links (target-to-wire what who where)]
%agent
[who %link-proxy-hook]
%leave
~
==
--
::
++ prod-other-listener
|= [who=ship where=path]
^- card
:* %pass
[%prod (scot %p who) where]
%agent
[who %link-listen-hook]
%poke
%link-listen-poke
!>(where)
==
::
++ take-link-sign
|= [=target =sign:agent:gall]
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /links target] !!)
%kick [[(start-link-subscription target)]~ state]
::
%watch-ack
?~ p.sign
=. retry-timers (~(del by retry-timers) target)
[~ state]
:: our subscription request got rejected,
:: most likely because our group definition is out of sync with theirs.
:: set timer for retry.
::
(start-retry target)
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark ~|([dap.bowl %unexpected-mark mark] !!)
%link-initial
%- handle-link-initial
[who.target where.target !<(initial:store vase)]
::
%link-update
%- handle-link-update
[who.target where.target !<(update:store vase)]
==
==
::
++ start-retry
|= =target
^- (quip card _state)
=/ timer=@dr
%+ min ~h1
%+ mul 2
(~(gut by retry-timers) target ~s15)
=. retry-timers
(~(put by retry-timers) target timer)
:_ state
:_ ~
:* %pass
[%retry (target-to-wire target)]
[%arvo %b %wait (add now.bowl timer)]
==
::
++ take-retry
|= =target
^- (list card)
:: relevant: whether :who is still associated with resource :where
::
=; relevant=?
?. relevant ~
[(start-link-subscription target)]~
?. (~(has in listening) where.target)
|
?: %- ~(has by wex.bowl)
[[%links (target-to-wire target)] who.target %link-proxy-hook]
|
%+ lien (groups-from-resource:md %link where.target)
|= =group-path
^- ?
%. who.target
~(has in (members-from-path:grp group-path))
::
++ do-link-action
|= [=wire =action:store]
^- card
:* %pass
wire
%agent
[our.bowl %link-store]
%poke
%link-action
!>(action)
==
::
++ handle-link-initial
|= [who=ship where=path =initial:store]
^- (quip card _state)
?> =(src.bowl who)
?+ -.initial ~|([dap.bowl %unexpected-initial -.initial] !!)
%local-pages
=/ =pages (~(got by pages.initial) where)
(handle-link-update who where [%local-pages where pages])
::
%annotations
=/ urls=(list [=url =notes])
~(tap by (~(got by notes.initial) where))
=| cards=(list card)
|- ^- (quip card _state)
?~ urls [cards state]
=^ caz state
^- (quip card _state)
=, i.urls
(handle-link-update who where [%annotations where url notes])
$(urls t.urls, cards (weld cards caz))
==
::
++ handle-link-update
|= [who=ship where=path =update:store]
^- (quip card _state)
?> =(src.bowl who)
:_ state
?+ -.update ~|([dap.bowl %unexpected-update -.update] !!)
%local-pages
%+ turn pages.update
|= =page
%+ do-link-action
[%forward %local-page (scot %p who) where]
[%hear where who page]
::
%annotations
%+ turn notes.update
|= =note
^- card
%+ do-link-action
`wire`[%forward %annotation (scot %p who) where]
`action:store`[%read where url.update `comment`[who note]]
==
::
++ take-forward-sign
|= [=wire =sign:agent:gall]
^- (quip card _state)
~| [%unexpected-sign on=[%forward wire] -.sign]
?> ?=(%poke-ack -.sign)
?~ p.sign [~ state]
=/ =tank
:- %leaf
;: weld
(trip dap.bowl)
" failed to save submission from "
(spud wire)
==
%- (slog tank u.p.sign)
[~ state]
::
++ scry-for
|* [=mold =app-name =path]
.^ mold
%gx
(scot %p our.bowl)
app-name
(scot %da now.bowl)
(snoc `^path`path %noun)
==
++ on-agent on-agent:def
++ on-poke on-poke:def
++ on-arvo on-arvo:def
++ on-peek on-peek:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-fail on-fail:def
--

View File

@ -1,337 +1,46 @@
:: link-proxy-hook: make local pages available to foreign ships
:: link-proxy-hook: no longer in use
::
:: this is a "proxy" style hook, relaying foreign subscriptions into local
:: stores if permission conditions are met.
:: the patterns herein should one day be generalized into a proxy-hook lib.
::
:: this uses metadata-store to discover resources and their associated
:: groups. it sets the permission condition to be that a ship must be in a
:: group associated with the resource it's subscribing to.
:: we check this on-watch, but also subscribe to metadata & groups so that
:: we can kick subscriptions if needed (eg ship removed from group).
::
:: we deduplicate incoming subscriptions on the same path, ensuring we have
:: exactly one local subscription per unique incoming subscription path.
:: this comes at the cost of assuming that the store's initial response is
:: whatever's returned by the scry at that path, but perhaps that should
:: become part of the stores standard anyway.
::
:: when adding support for new paths, the only things you'll likely want
:: to touch are +permitted, +initial-response, & +kick-proxies.
::
/- *link, *metadata-store, *group
/+ metadata, default-agent, verb, dbug, group-store, grpl=group,
resource, store=link-store
/+ default-agent, verb, dbug
~% %link-proxy-hook-top ..is ~
|%
+$ state-0
$: %0
::TODO we use this to detect "first sub started" and "last sub left",
:: but can't we use [wex sup]:bowl for that?
active=(map path (set ship))
==
+$ state-1
$: %1
active=(map path (set ship))
==
::
+$ versioned-state
$% state-0
state-1
$% [%0 *]
[%1 *]
[%2 ~]
==
::
+$ card card:agent:gall
--
::
=| state-1
=| [%2 ~]
=* state -
::
%- agent:dbug
%+ verb |
^- agent:gall
=<
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %&) bowl)
::
++ on-init
^- (quip card _this)
:_ this
~[watch-groups:do watch-metadata:do]
::
++ on-save !>(state)
++ on-load
|= old-vase=vase
^- (quip card _this)
=/ old
!<(versioned-state old-vase)
?- -.old
%1 [~ this(state old)]
::
%0
:_ this(state [%1 +.old])
:~ [%pass /groups %agent [our.bowl %group-store] %leave ~]
watch-groups:do
==
==
::
++ on-watch
|= =path
^- (quip card _this)
:: the local ship should just use link-store directly
::TODO do we want to allow this anyway, to avoid client-side target checks?
::
?< (team:title [our src]:bowl)
?> (permitted:do src.bowl path)
=^ cards state
(start-proxy:do src.bowl path)
[cards this]
::
++ on-leave
|= =path
^- (quip card _this)
=^ cards state
(stop-proxy:do src.bowl path)
[cards this]
::
++ on-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
?: ?=([%groups ~] wire)
=^ cards state
(take-groups-sign:do sign)
[cards this]
?: ?=([%proxy ^] wire)
=^ cards state
(handle-proxy-sign t.wire sign)
[cards this]
~| [dap.bowl %weird-wire wire]
!!
::
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--
::
|_ =bowl:gall
+* md ~(. metadata bowl)
grp ~(. grpl bowl)
+* this .
def ~(. (default-agent this %&) bowl)
::
:: permissions
++ on-init on-init:def
++ on-save !>(state)
++ on-load
|= old-vase=vase
^- (quip card _this)
=/ paths
%+ turn ~(val by sup.bowl)
|=([=ship =path] path)
:_ this
:- [%pass /groups %agent [our.bowl %group-store] %leave ~]
?~ paths ~
[%give %kick paths ~]~
::
++ permitted
|= [who=ship =path]
^- ?
:: we only expose /local-pages and /annotations,
:: to ships in the groups associated with the resource.
:: (no url-specific annotations subscriptions, either.)
::
=/ target=(unit ^path)
?: ?=([%local-pages ^] path)
`t.path
?: ?=([%annotations ~ ^] path)
`t.t.path
~
?~ target |
%+ lien (groups-from-resource:md %link u.target)
|= =group-path
^- ?
(~(has in (members-from-path:grp group-path)) who)
::
++ kick-revoked-permissions
|= [=path who=(list ship)]
^- (list card)
%+ murn who
|= =ship
^- (unit card)
:: no need to remove to ourselves
::
?: =(our.bowl ship) ~
?: (permitted ship path) ~
`(kick-proxies ship path)
::
:: metadata subscription
::
++ watch-metadata
^- card
[%pass /metadata %agent [our.bowl %metadata-store] %watch /app-name/link]
::
++ take-metadata-sign
|= =sign:agent:gall
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /metadata] !!)
%kick [[watch-metadata]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to metadata store. very wrong!"
%- (slog tank u.p.sign)
[~ state]
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?. ?=(%metadata-update mark)
~| [dap.bowl %unexpected-mark mark]
!!
%- handle-metadata-update
!<(metadata-update vase)
==
::
++ handle-metadata-update
|= upd=metadata-update
^- (quip card _state)
:_ state
?. ?=(%remove -.upd) ~
?> =(%link app-name.resource.upd)
:: if a group is no longer associated with a resource,
:: we need to re-check permissions for everyone in that group.
::
%+ kick-revoked-permissions
app-path.resource.upd
%~ tap in
(members-from-path:grp group-path.upd)
::
:: groups subscription
::TODO largely copied from link-listen-hook. maybe make a store-listener lib?
::
++ watch-groups
^- card
[%pass /groups %agent [our.bowl %group-store] %watch /groups]
::
++ take-groups-sign
|= =sign:agent:gall
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /groups] !!)
%kick [[watch-groups]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to group store. very wrong!"
%- (slog tank u.p.sign)
[~ state]
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark ~|([dap.bowl %unexpected-mark mark] !!)
%group-initial [~ state]
%group-update (handle-group-update !<(update:group-store vase))
==
==
::
++ handle-group-update
|= upd=update:group-store
^- (quip card _state)
:_ state
?. ?=(%remove-members -.upd) ~
:: if someone was removed from a group, find all link resources associated
:: with that group, then kick their subscriptions if they're no longer
::
%- zing
%+ turn (app-paths-from-group:md %link (en-path:resource resource.upd))
|= =app-path
^- (list card)
%+ kick-revoked-permissions
app-path
~(tap in ships.upd)
::
:: proxy subscriptions
::
++ kick-proxies
|= [who=ship =path]
^- card
=- [%give %kick - `who]
:~ [%local-pages path]
[%annotations %$ path]
==
::
++ handle-proxy-sign
|= [=wire =sign:agent:gall]
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack wire] !!)
%fact [[%give %fact ~[wire] cage.sign]~ state]
%kick [[(proxy-pass-link-store wire %watch wire)]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to link-store. very wrong!"
%- (slog tank u.p.sign)
[~ state]
==
::
++ proxy-pass-link-store
|= [=path =task:agent:gall]
^- card
:* %pass
[%proxy path]
%agent
[our.bowl %link-store]
task
==
::
++ initial-response
|= =path
^- card
=; =initial:store
[%give %fact ~ %link-initial !>(initial)]
?+ path !!
[%local-pages ^]
[%local-pages (scry-for (map ^path pages) %link-store path)]
::
[%annotations %$ ^]
[%annotations (scry-for (per-path-url notes) %link-store path)]
==
::
++ start-proxy
|= [who=ship =path]
^- (quip card _state)
:_ state(active (~(put ju active) path who))
:_ ~
:: if we already have a local subscription open,
::
?. =(~ (~(get ju active) path))
:: gather the initial response ourselves, and send that.
::
(initial-response path)
:: else, open a local subscription,
:: sending outward its initial response when we hear it.
::
(proxy-pass-link-store path %watch path)
::
++ stop-proxy
|= [who=ship =path]
^- (quip card _state)
=. active (~(del ju active) path who)
:_ state
:: if there are still subscriptions remaining, do nothing.
::
?. =(~ (~(get ju active) path)) ~
:: else, close the local subscription.
::
[(proxy-pass-link-store path %leave ~)]~
::
:: helpers
::
++ scry-for
|* [=mold =app-name =path]
.^ mold
%gx
(scot %p our.bowl)
app-name
(scot %da now.bowl)
(snoc `^path`path %noun)
==
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-agent on-agent:def
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--

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
@ -50,10 +52,12 @@
:: ?
:: /seen/wood-url/some-path have we seen this here
::
/- *link
/+ store=link-store, default-agent, verb, dbug
/- *link, gra=graph-store, *resource
/+ store=link-store, graph-store, default-agent, verb, dbug
::
|%
+$ state-any $%(state-1 state-0)
+$ state-1 [%1 ~]
+$ state-0
$: %0
by-group=(map path links)
@ -76,414 +80,107 @@
+$ card card:agent:gall
--
::
=| state-0
=| state-1
=* state -
::
%- agent:dbug
%+ verb |
^- agent:gall
=<
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
::
++ on-init on-init:def
++ on-save !>(state)
++ on-load
|= old=vase
^- (quip card _this)
[~ this(state !<(state-0 old))]
::
++ on-poke
|= [=mark =vase]
^- (quip card _this)
?> (team:title [our src]:bowl) ::TODO /lib/store
=^ cards state
?+ mark (on-poke:def mark vase)
::TODO move json conversion into mark once mark performance improves
%json (do-action:do (action:dejs:store !<(json vase)))
%link-action (do-action:do !<(action:store vase))
==
[cards this]
::
++ on-peek
|= =path
^- (unit (unit cage))
?+ path (on-peek:def path)
[%y ?(%local-pages %submissions) ~]
``noun+!>(~(key by by-group))
::
[%x %local-pages *]
``noun+!>((get-local-pages:do t.t.path))
::
[%x %submissions *]
``noun+!>((get-submissions:do t.t.path))
::
[%y ?(%annotations %discussions) *]
=/ [spath=^path surl=url]
(break-discussion-path:store t.t.path)
=- ``noun+!>(-)
::
?: =(~ surl)
:: no url, provide urls that have comments
::
^- (set url)
?~ spath
:: no path, find urls accross all paths
::
%- ~(rep by discussions)
|= [[* discussions=(map url discussion)] urls=(set url)]
%- ~(uni in urls)
~(key by discussions)
:: specified path, find urls for that specific path
::
%~ key by
(~(gut by discussions) spath *(map url *))
:: specified url and path, nothing to list here
::
?^ spath !!
:: no path, find paths with comments for this url
::
^- (set ^path)
%- ~(rep by discussions)
|= [[=^path urls=(map url discussion)] paths=(set ^path)]
?. (~(has by urls) surl) paths
(~(put in paths) path)
::
[%x %annotations *]
``noun+!>((get-annotations:do t.t.path))
::
[%x %discussions *]
``noun+!>((get-discussions:do t.t.path))
::
[%x %seen @ ^]
``noun+!>((is-seen:do t.t.path))
::
[%x %unseen ~]
``noun+!>(get-all-unseen:do)
::
[%x %unseen ^]
``noun+!>((get-unseen:do t.t.path))
==
::
++ on-watch
|= =path
^- (quip card _this)
?> (team:title [our src]:bowl) ::TODO /lib/store
:_ this
|^ ?+ path (on-watch:def path)
[%local-pages *]
%+ give %link-initial
^- initial:store
[%local-pages (get-local-pages:do t.path)]
::
[%submissions *]
%+ give %link-initial
^- initial:store
[%submissions (get-submissions:do t.path)]
::
[%annotations *]
%+ give %link-initial
^- initial:store
[%annotations (get-annotations:do t.path)]
::
[%discussions *]
%+ give %link-initial
^- initial:store
[%discussions (get-discussions:do t.path)]
::
[%seen ~]
~
==
::
++ give
|* [=mark =noun]
^- (list card)
[%give %fact ~ mark !>(noun)]~
::
++ give-single
|* [=mark =noun]
^- card
[%give %fact ~ mark !>(noun)]
--
::
++ on-leave on-leave:def
++ on-agent on-agent:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--
::
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
::
:: writing
::
++ do-action
|= =action:store
^- (quip card _state)
?- -.action
%save (save-page +.action)
%note (note-note +.action)
%seen (seen-submission +.action)
++ on-init on-init:def
++ on-save !>(state)
++ on-load
|= old=vase
^- (quip card _this)
=/ s !<(state-any old)
?: ?=(%1 -.s)
[~ this(state s)]
::
%hear (hear-submission +.action)
%read (read-comment +.action)
==
:: +save-page: save a page ourselves
::
++ save-page
|= [=path title=@t =url]
^- (quip card _state)
?< |(=(~ path) =(~ title) =(~ url))
:: add page to group ours
::
=/ =links (~(gut by by-group) path *links)
=/ =page [title url now.bowl]
=. ours.links [page ours.links]
=. by-group (~(put by by-group) path links)
:: do generic submission logic
::
=^ submission-cards state
(hear-submission path [our.bowl page])
:: mark page as seen (because we submitted it ourselves)
::
=^ seen-cards state
(seen-submission path `url)
:: send updates to subscribers
::
:_ state
:_ (weld submission-cards seen-cards)
:+ %give %fact
:+ :~ /local-pages
[%local-pages path]
==
%link-update
!>([%local-pages path [page]~])
:: +note-note: save a note for a url
::
++ note-note
|= [=path =url udon=@t]
^- (quip card _state)
?< |(=(~ path) =(~ url) =(~ udon))
:: add note to discussion ours
::
=/ urls (~(gut by discussions) path *(map ^url discussion))
=/ =discussion (~(gut by urls) url *discussion)
=/ =note [now.bowl udon]
=. ours.discussion [note ours.discussion]
=. urls (~(put by urls) url discussion)
=. discussions (~(put by discussions) path urls)
:: do generic comment logic
::
=^ cards state
(read-comment path url [our.bowl note])
:: send updates to subscribers
::
:_ state
:_ this(state *state-1)
=/ orm orm:graph-store
|^ ^- (list card)
%- zing
%+ turn ~(tap by by-group.s)
|= [=path =links]
^- (list card)
:_ cards
:+ %give %fact
:+ :~ /annotations
[%annotations %$ path]
[%annotations (build-discussion-path:store url)]
[%annotations (build-discussion-path:store path url)]
?. ?=([@ @ *] path)
(on-bad-path path links)
=/ =resource [(slav %p i.path) i.t.path]
:_ [(archive-graph resource)]~
%+ add-graph resource
^- graph:gra
%+ gas:orm ~
=/ comments (~(gut by discussions.s) path *(map url discussion))
%+ turn submissions.links
|= sub=submission
^- [atom node:gra]
:- time.sub
=/ contents ~[text+title.sub url+url.sub]
=/ parent-hash `@ux`(sham ~ ship.sub time.sub contents)
:- ^- post:gra
:* author=ship.sub
index=~[time.sub]
time-sent=time.sub
contents
hash=`parent-hash
signatures=~
==
%link-update
!>([%annotations path url [note]~])
:: +seen-submission: mark url as seen/read
::
:: if no url specified, all under path are marked as read
::
++ seen-submission
|= [=path murl=(unit url)]
^- (quip card _state)
=/ =links (~(gut by by-group) path *links)
:: new: urls we want to, but haven't yet, marked as seen
^- internal-graph:gra
=/ dis (~(get by comments) url.sub)
?~ dis
[%empty ~]
:- %graph
^- graph:gra
%+ gas:orm ~
%+ turn comments.u.dis
|= [=ship =time udon=@t]
^- [atom node:gra]
:- time
:_ `internal-graph:gra`[%empty ~]
=/ contents ~[text+udon]
:* author=ship
index=~[time.sub time]
time-sent=time
contents
hash=``@ux`(sham `parent-hash ship time contents)
signatures=~
==
::
=/ new=(set url)
%. seen.links
%~ dif in
^- (set url)
?^ murl (sy ~[u.murl])
%- ~(gas in *(set url))
%+ turn submissions.links
|=(submission url)
?: =(~ new) [~ state]
=. seen.links (~(uni in seen.links) new)
:_ state(by-group (~(put by by-group) path links))
[%give %fact ~[/seen] %link-update !>([%observation path new])]~
:: +hear-submission: record page someone else saved
::
++ hear-submission
|= [=path =submission]
^- (quip card _state)
?< =(~ path)
:: add link to group submissions
++ on-bad-path
|= [=path =links]
^- (list card)
~| discarding-malformed-links+[path links]
~
::
=/ =links (~(gut by by-group) path *links)
=^ added submissions.links
?: ?=(^ (find ~[submission] submissions.links))
[| submissions.links]
:- &
(submissions:merge:store submissions.links ~[submission])
=. by-group (~(put by by-group) path links)
:: add submission to global sites
++ add-graph
|= [=resource =graph:gra]
^- card
%- poke-graph-store
[%0 now.bowl %add-graph resource graph `%graph-validator-link]
::
=/ =site (site-from-url:store url.submission)
=. by-site (~(add ja by-site) site [path submission])
:: send updates to subscribers
++ archive-graph
|= =resource
^- card
%- poke-graph-store
[%0 now.bowl %archive-graph resource]
::
:_ state
?. added ~
:_ ~
:+ %give %fact
:+ :~ /submissions
[%submissions path]
==
%link-update
!>([%submissions path [submission]~])
:: +read-comment: record a comment someone else made
::
++ read-comment
|= [=path =url =comment]
^- (quip card _state)
:: add comment to url's discussion
::
=/ urls (~(gut by discussions) path *(map ^url discussion))
=/ =discussion (~(gut by urls) url *discussion)
=^ added comments.discussion
?: ?=(^ (find ~[comment] comments.discussion))
[| comments.discussion]
:- &
(comments:merge:store comments.discussion ~[comment])
=. urls (~(put by urls) url discussion)
=. discussions (~(put by discussions) path urls)
:: send updates to subscribers
::
:_ state
?. added ~
:_ ~
:+ %give %fact
:+ :~ /discussions
[%discussions '' path]
[%discussions (build-discussion-path:store url)]
[%discussions (build-discussion-path:store path url)]
==
%link-update
!>([%discussions path url [comment]~])
::
:: reading
::
++ get-local-pages
|= =path
^- (map ^path pages)
?~ path
:: all paths
::
%- ~(run by by-group)
|=(links ours)
:: specific path
::
%+ ~(put by *(map ^path pages)) path
ours:(~(gut by by-group) path *links)
::
++ get-submissions
|= =path
^- (map ^path submissions)
?~ path
:: all paths
::
%- ~(run by by-group)
|=(links submissions)
:: specific path
::
%+ ~(put by *(map ^path submissions)) path
submissions:(~(gut by by-group) path *links)
::
++ get-all-unseen
^- (jug path url)
%- ~(rut by by-group)
|= [=path *]
(get-unseen path)
::
++ get-unseen
|= =path
^- (set url)
=/ =links
(~(gut by by-group) path *links)
%- ~(gas in *(set url))
%+ murn submissions.links
|= submission
?: (~(has in seen.links) url) ~
(some url)
::
++ is-seen
|= =path
^- ?
=/ [=^path =url]
(break-discussion-path:store path)
%. url
%~ has in
seen:(~(gut by by-group) path *links)
::
::
++ get-annotations
|= =path
^- (per-path-url notes)
=/ args=[=^path =url]
(break-discussion-path:store path)
|^ ?~ path
:: all paths
::
(~(run by discussions) get-ours)
:: specific path
::
%+ ~(put by *(per-path-url notes)) path.args
%- get-ours
%+ ~(gut by discussions) path.args
*(map url discussion)
::
++ get-ours
|= m=(map url discussion)
^- (map url notes)
?: =(~ url.args)
:: all urls
::
%- ~(run by m)
|=(discussion ours)
:: specific url
::
%+ ~(put by *(map url notes)) url.args
ours:(~(gut by m) url.args *discussion)
++ poke-graph-store
|= =update:gra
^- card
:* %pass /migrate-link %agent [our.bowl %graph-store]
%poke %graph-update !>(update)
==
--
::
++ get-discussions
|= =path
^- (per-path-url comments)
=/ args=[=^path =url]
(break-discussion-path:store path)
|^ ?~ path
:: all paths
::
(~(run by discussions) get-comments)
:: specific path
::
%+ ~(put by *(per-path-url comments)) path.args
%- get-comments
%+ ~(gut by discussions) path.args
*(map url discussion)
::
++ get-comments
|= m=(map url discussion)
^- (map url comments)
?: =(~ url.args)
:: all urls
::
%- ~(run by m)
|=(discussion comments)
:: specific url
::
%+ ~(put by *(map url comments)) url.args
comments:(~(gut by m) url.args *discussion)
--
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-agent on-agent:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--

View File

@ -1,626 +1,39 @@
:: link-view: frontend endpoints
::
:: endpoints, mapping onto link-store's paths. p is for page as in pagination.
:: only the /0/submissions endpoint provides updates.
:: as with link-store, urls are expected to use +wood encoding.
::
:: /json/0/submissions initial + updates for all
:: /json/[p]/submissions/[collection] page for one collection
:: /json/[p]/discussions/[wood-url]/[collection] page for url in collection
:: /json/[n]/submission/[wood-url]/[collection] nth matching submission
:: /json/seen mark-as-read updates
::
/- *link, view=link-view
/- *invite-store, group-store
/- listen-hook=link-listen-hook
/- group-hook, permission-hook, permission-group-hook
/- metadata-hook, contact-view
/- pull-hook, *group
/+ store=link-store, metadata, *server, default-agent, verb, dbug, grpl=group
/+ group-store, resource
:: link-view: no longer in use
/+ default-agent, verb, dbug
~% %link-view-top ..is ~
::
::
|%
+$ versioned-state
$% state-0
state-1
==
+$ state-0
$: %0
~
==
::
+$ state-1
$: %1
~
$% [%0 ~]
[%1 ~]
[%2 ~]
==
::
+$ card card:agent:gall
--
::
=| state-1
=| [%2 ~]
=* state -
::
%+ verb |
%- agent:dbug
^- agent:gall
=<
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
::
++ on-init
^- (quip card _this)
:_ this
:~ [%pass /submissions %agent [our.bowl %link-store] %watch /submissions]
[%pass /discussions %agent [our.bowl %link-store] %watch /discussions]
[%pass /seen %agent [our.bowl %link-store] %watch /seen]
::
=+ [%invite-action !>([%create /link])]
[%pass /invitatory/create %agent [our.bowl %invite-store] %poke -]
::
=+ /invitatory/link
[%pass - %agent [our.bowl %invite-store] %watch -]
:* %pass /srv %agent [our.bowl %file-server]
%poke %file-server-action
!>([%serve-dir /'~link' /app/landscape %.n %.y])
==
==
::
++ on-save !>(state)
++ on-load
|= old-vase=vase
^- (quip card _this)
=/ old !<(versioned-state old-vase)
?- -.old
%1 [~ this]
%0
:_ this(state [%1 ~])
:- [%pass /connect %arvo %e %disconnect [~ /'~link']]
:~ :* %pass /srv %agent [our.bowl %file-server]
%poke %file-server-action
!>([%serve-dir /'~link' /app/landscape %.n %.y])
== ==
==
::
++ on-poke
|= [=mark =vase]
^- (quip card _this)
?> (team:title our.bowl src.bowl)
:_ this
?+ mark (on-poke:def mark vase)
%link-action
[(handle-action:do !<(action:store vase)) ~]
::
%link-view-action
(handle-view-action:do !<(action:view vase))
==
::
++ on-watch
|= =path
^- (quip card _this)
?: ?=([%json %seen ~] path)
[~ this]
?: ?=([%tile ~] path)
:_ this
~[give-tile-data:do]
?. ?=([%json @ @ *] path)
(on-watch:def path)
=/ p=@ud (slav %ud i.t.path)
?+ t.t.path (on-watch:def path)
[%submissions ~]
:_ this
(give-initial-submissions:do p ~)
::
[%submissions ^]
:_ this
(give-initial-submissions:do p t.t.t.path)
::
[%submission @ ^]
:_ this
(give-specific-submission:do p (break-discussion-path:store t.t.t.path))
::
[%discussions @ ^]
:_ this
(give-initial-discussions:do p (break-discussion-path:store t.t.t.path))
==
::
++ on-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
?+ -.sign (on-agent:def wire sign)
%poke-ack
?. ?=([%join-group @ @ @ @ ~] wire)
(on-agent:def wire sign)
?^ p.sign
(on-agent:def wire sign)
=/ rid=resource
(de-path:resource t.t.wire)
=/ host=ship
(slav %p i.t.wire)
:_ this
(joined-group:do host rid)
::
%kick
:_ this
=/ app=term
?: ?=([%invites *] wire)
%invite-store
%link-store
[%pass wire %agent [our.bowl app] %watch wire]~
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark (on-agent:def wire sign)
%invite-update [(handle-invite-update:do !<(invite-update vase)) this]
%link-initial [~ this]
::
%link-update
:_ this
:- (send-update:do !<(update:store vase))
?: =(/discussions wire) ~
~[give-tile-data:do]
==
==
::
++ on-arvo
|= [=wire =sign-arvo]
^- (quip card _this)
?. ?=([%e %bound *] sign-arvo)
(on-arvo:def wire sign-arvo)
~? !accepted.sign-arvo
[dap.bowl "bind rejected!" binding.sign-arvo]
[~ this]
::
++ on-peek on-peek:def
++ on-leave on-leave:def
++ on-fail on-fail:def
--
::
~% %link-view-logic ..card ~
|_ =bowl:gall
+* md ~(. metadata bowl)
grp ~(. grpl bowl)
+* this .
def ~(. (default-agent this %|) bowl)
::
++ page-size 25
++ get-paginated
|* [page=(unit @ud) list=(list)]
^- [total=@ud pages=@ud page=_list]
=/ l=@ud (lent list)
:+ l
%+ add (div l page-size)
(min 1 (mod l page-size))
?~ page list
%+ swag
[(mul u.page page-size) page-size]
list
++ on-init [~ this]
++ on-save !>(state)
++ on-load
|= old-vase=vase
^- (quip card _this)
:_ this(state [%2 ~])
[%pass /connect %arvo %e %disconnect [~ /'~link']]~
::
++ page-to-json
=, enjs:format
|* $: page-number=@ud
[total-items=@ud total-pages=@ud page=(list)]
item-to-json=$-(* json)
==
^- json
%- pairs
:~ 'totalItems'^(numb total-items)
'totalPages'^(numb total-pages)
'pageNumber'^(numb page-number)
'page'^a+(turn page item-to-json)
==
++ do-poke
|= [app=term =mark =vase]
^- card
[%pass /create/[app]/[mark] %agent [our.bowl app] %poke mark vase]
::
++ joined-group
|= [host=ship rid=resource]
^- (list card)
=/ =path
(en-path:resource rid)
:~
:: sync the group
::
%^ do-poke %group-pull-hook
%pull-hook-action
!> ^- action:pull-hook
[%add host rid]
::
:: sync the metadata
::
%^ do-poke %metadata-hook
%metadata-hook-action
!> ^- metadata-hook-action:metadata-hook
[%add-synced host path]
::
:: sync the collection
::
%^ do-poke %link-listen-hook
%link-listen-action
!> ^- action:listen-hook
[%watch ~[name.rid]]
==
::
++ handle-invite-update
|= upd=invite-update
^- (list card)
?. ?=(%accepted -.upd) ~
?. =(/link path.upd) ~
=/ rid=resource
(de-path:resource path.invite.upd)
:~ :: add self
:* %pass
[%join-group (scot %p ship.invite.upd) path.invite.upd]
%agent [entity.rid %group-push-hook]
%poke %group-update
!> ^- action:group-store
[%add-members rid (sy our.bowl ~)]
== ==
::
++ handle-action
|= =action:store
^- card
[%pass /action %agent [our.bowl %link-store] %poke %link-action !>(action)]
::
++ handle-view-action
|= act=action:view
^- (list card)
?- -.act
%create (handle-create +.act)
%delete (handle-delete +.act)
%invite (handle-invite +.act)
==
::
++ handle-create
|= [=path title=@t description=@t members=create-members:view real-group=?]
^- (list card)
=/ group-path=^path
?- -.members
%group path.members
::
%ships
[%ship (scot %p our.bowl) path]
==
=; group-setup=(list card)
%+ weld group-setup
:~ :: add collection to metadata-store
::
%^ do-poke %metadata-hook
%metadata-action
!> ^- metadata-action:md
:^ %add group-path
[%link path]
%* . *metadata:md
title title
description description
date-created now.bowl
creator our.bowl
==
::
:: expose the metadata
::
%^ do-poke %metadata-hook
%metadata-hook-action
!> ^- metadata-hook-action:metadata-hook
[%add-owned group-path]
::
:: watch the collection ourselves
::
%^ do-poke %link-listen-hook
%link-listen-action
!> ^- action:listen-hook
[%watch path]
==
?: ?=(%group -.members) ~
:: if the group is "real", make contact-view do the heavy lifting
=/ rid=resource
(de-path:resource group-path)
?: real-group
:- %^ do-poke %contact-view
%contact-view-action
!> ^- contact-view-action:contact-view
[%groupify rid title description]
%+ turn ~(tap in ships.members)
|= =ship
^- card
%^ do-poke %invite-hook
%invite-action
!> ^- invite-action
:^ %invite /link
(sham group-path eny.bowl)
:* our.bowl
%group-hook
group-path
ship
title
==
:: for "unmanaged" groups, do it ourselves
::
=/ =policy
[%invite ships.members]
:* :: create the new group
::
%^ do-poke %group-store
%group-action
!> ^- action:group-store
[%add-group rid policy %.y]
::
:: send invites
::
%+ turn ~(tap in ships.members)
|= =ship
^- card
%^ do-poke %invite-hook
%invite-action
!> ^- invite-action
:^ %invite /link
(sham group-path eny.bowl)
:* our.bowl
%group-hook
group-path
ship
title
==
==
::
++ handle-delete
|= =path
^- (list card)
=/ groups=(list ^path)
(groups-from-resource:md [%link path])
%- zing
%+ turn groups
|= =group=^path
=/ rid=resource
(de-path:resource group-path)
%+ snoc
^- (list card)
:: if it's a real group, we can't/shouldn't unsync it. this leaves us with
:: no way to stop propagation of collection deletion.
::
?. ?=([%'~' ^] group-path) ~
:: if it's an unmanaged group, we just stop syncing the group & metadata,
:: and clean up the group (after un-hooking it, to not push deletion).
::
:~ %^ do-poke %group-hook
%group-hook-action
!> ^- action:group-hook
[%remove rid]
::
%^ do-poke %metadata-hook
%metadata-hook-action
!> ^- metadata-hook-action:metadata-hook
[%remove group-path]
::
%^ do-poke %group-store
%group-action
!> ^- action:group-store
[%remove-group rid ~]
==
:: remove collection from metadata-store
::
%^ do-poke %metadata-store
%metadata-action
!> ^- metadata-action:md
[%remove group-path [%link path]]
::
++ handle-invite
|= [=path ships=(set ship)]
^- (list card)
%- zing
%+ turn (groups-from-resource:md %link path)
|= =group=^path
^- (list card)
=/ rid=resource
(de-path:resource group-path)
=/ =group
(need (scry-group:grp rid))
%- zing
:~
?. ?=(%invite -.policy.group)
~
:~ %^ do-poke %group-store
%group-action
!> ^- action:group-store
[%change-policy rid %invite %add-invites ships]
==
::
%+ turn ~(tap in ships)
|= =ship
^- card
%^ do-poke %invite-hook
%invite-action
!> ^- invite-action
:^ %invite /link
(sham group-path eny.bowl)
:* our.bowl
%group-pull-hook
group-path
ship
(rsh 3 1 (spat path))
==
==
:: +give-tile-data: total unread count as json object
::
::NOTE the full recalc of totals here probably isn't the end of the world.
:: but in case it is, well, here it is.
::
++ give-tile-data
^- card
=; =json
[%give %fact ~[/tile] %json !>(json)]
%+ frond:enjs:format 'unseen'
%- numb:enjs:format
%- %~ rep in
(scry-for (jug path url) /unseen)
|= [[=path unseen=(set url)] total=@ud]
%+ add total
~(wyt in unseen)
::
:: +give-initial-submissions: page of submissions on path
::
:: for the / path, give page for every path
::
:: result is in the shape of: {
:: "/some/path": {
:: totalItems: 1,
:: totalPages: 1,
:: pageNumber: 0,
:: page: [
:: { commentCount: 1, ...restOfTheSubmission }
:: ]
:: },
:: "/maybe/more": { etc }
:: }
::
++ give-initial-submissions
~/ %link-view-initial-submissions
|= [p=@ud =requested=path]
^- (list card)
:_ :: only keep the base case alive (for updates), kick all others
::
?: &(=(0 p) ?=(~ requested-path)) ~
[%give %kick ~ ~]~
=; =json
[%give %fact ~ %json !>(json)]
%+ frond:enjs:format 'link-update'
%+ frond:enjs:format 'initial-submissions'
%- pairs:enjs:format
%+ turn
%~ tap by
%+ scry-for (map path submissions)
[%submissions requested-path]
|= [=path =submissions]
^- [@t json]
:- (spat path)
=; =json
:: add unseen count
::
?> ?=(%o -.json)
:- %o
%+ ~(put by p.json) 'unseenCount'
%- numb:enjs:format
%~ wyt in
%+ scry-for (set url)
[%unseen path]
?: &(=(0 p) ?=(~ requested-path))
:: for a broad-scope initial result, only give total counts
::
=, enjs:format
%- pairs
=+ l=(lent submissions)
:~ 'totalItems'^(numb l)
'totalPages'^(numb (div l page-size))
==
%^ page-to-json p
%+ get-paginated `p
submissions
|= =submission
^- json
=/ =json (submission:enjs:store submission)
?> ?=([%o *] json)
:: add in seen status
::
=. p.json
%+ ~(put by p.json) 'seen'
:- %b
%+ scry-for ?
[%seen (build-discussion-path:store path url.submission)]
:: add in comment count
::
=; comment-count=@ud
:- %o
%+ ~(put by p.json) 'commentCount'
(numb:enjs:format comment-count)
%- lent
~| [path url.submission]
^- comments
=- (~(got by (~(got by -) path)) url.submission)
%+ scry-for (per-path-url comments)
:- %discussions
(build-discussion-path:store path url.submission)
::
++ give-specific-submission
|= [n=@ud =path =url]
:_ [%give %kick ~ ~]~
=; =json
[%give %fact ~ %json !>(json)]
%+ frond:enjs:format 'link-update'
%+ frond:enjs:format 'submission'
^- json
=; sub=(unit submission)
?~ sub ~
(submission:enjs:store u.sub)
=/ =submissions
=- (~(got by -) path)
%+ scry-for (map ^path submissions)
[%submissions path]
|-
?~ submissions ~
=* sub i.submissions
?. =(url.sub url)
$(submissions t.submissions)
?: =(0 n) `sub
$(n (dec n), submissions t.submissions)
::
++ give-initial-discussions
|= [p=@ud =path =url]
^- (list card)
:_ ?: =(0 p) ~
[%give %kick ~ ~]~
=; =json
[%give %fact ~ %json !>(json)]
%+ frond:enjs:format 'link-update'
%+ frond:enjs:format 'initial-discussions'
%^ page-to-json p
%+ get-paginated `p
=- (~(got by (~(got by -) path)) url)
%+ scry-for (per-path-url comments)
[%discussions (build-discussion-path:store path url)]
comment:enjs:store
::
++ send-update
|= =update:store
^- card
?+ -.update ~|([dap.bowl %unexpected-update -.update] !!)
%submissions
%+ give-json
%+ frond:enjs:format 'link-update'
(update:enjs:store update)
:~ /json/0/submissions
(weld /json/0/submissions path.update)
==
::
%discussions
%+ give-json
%+ frond:enjs:format 'link-update'
(update:enjs:store update)
:_ ~
%+ weld /json/0/discussions
(build-discussion-path:store [path url]:update)
::
%observation
%+ give-json
%+ frond:enjs:format 'link-update'
(update:enjs:store update)
~[/json/seen]
==
::
++ give-json
|= [=json paths=(list path)]
^- card
[%give %fact paths %json !>(json)]
::
++ scry-for
|* [=mold =path]
.^ mold
%gx
(scot %p our.bowl)
%link-store
(scot %da now.bowl)
(snoc `^path`path %noun)
==
++ on-poke on-poke:def
++ on-watch on-watch:def
++ on-agent on-agent:def
++ on-arvo on-arvo:def
++ on-peek on-peek:def
++ on-leave on-leave:def
++ on-fail on-fail:def
--

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
@ -37,7 +39,7 @@
[[%pass /updates %agent [our.bowl %metadata-store] %watch /updates]~ this]
::
++ on-save !>(state)
++ on-load
++ on-load
|= =vase
=/ old
!<(versioned-state vase)

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

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,5 +1,5 @@
/- spider
/+ libstrand=strand, default-agent, verb
/+ libstrand=strand, default-agent, verb, server
=, strand=strand:libstrand
|%
+$ card card:agent:gall
@ -17,15 +17,25 @@
$: starting=(map yarn [=trying =vase])
running=trie
tid=(map tid yarn)
serving=(map tid [@ta =mark])
==
::
+$ clean-slate-any
$^ clean-slate-ket
$% clean-slate-sig
clean-slate-1
clean-slate
==
::
+$ clean-slate
$: %2
starting=(map yarn [=trying =vase])
running=(list yarn)
tid=(map tid yarn)
serving=(map tid [@ta =mark])
==
::
+$ clean-slate-1
$: %1
starting=(map yarn [=trying =vase])
running=(list yarn)
@ -133,7 +143,10 @@
sc ~(. spider-core bowl)
def ~(. (default-agent this %|) bowl)
::
++ on-init on-init:def
++ on-init
^- (quip card _this)
:_ this
~[bind-eyre:sc]
++ on-save clean-state:sc
++ on-load
|^
@ -141,7 +154,9 @@
=+ !<(any=clean-slate-any old-state)
=? any ?=(^ -.any) (old-to-1 any)
=? any ?=(~ -.any) (old-to-1 any)
?> ?=(%1 -.any)
=^ upgrade-cards any
(old-to-2 any)
?> ?=(%2 -.any)
::
=. tid.state tid.any
=/ yarns=(list yarn)
@ -154,12 +169,26 @@
(handle-stop-thread:sc (yarn-to-tid i.yarns) |)
=^ cards-2 this
$(yarns t.yarns)
[(weld cards-1 cards-2) this]
[:(weld upgrade-cards cards-1 cards-2) this]
::
++ old-to-1
|= old=clean-slate-ket
^- clean-slate
^- clean-slate-1
1+old(starting (~(run by starting.old) |=([* v=vase] none+v)))
::
++ old-to-2
|= old=clean-slate-any
^- (quip card clean-slate)
?> ?=(?(%1 %2) -.old)
?: ?=(%2 -.old)
`old
:- ~[bind-eyre:sc]
:* %2
starting.old
running.old
tid.old
~
==
--
::
++ on-poke
@ -172,6 +201,9 @@
%spider-input (on-poke-input:sc !<(input vase))
%spider-start (handle-start-thread:sc !<(start-args vase))
%spider-stop (handle-stop-thread:sc !<([tid ?] vase))
::
%handle-http-request
(handle-http-request:sc !<([@ta =inbound-request:eyre] vase))
==
[cards this]
::
@ -182,6 +214,7 @@
?+ path (on-watch:def path)
[%thread @ *] (on-watch:sc t.path)
[%thread-result @ ~] (on-watch-result:sc i.t.path)
[%http-response *] `state
==
[cards this]
::
@ -216,6 +249,7 @@
?+ wire (on-arvo:def wire sign-arvo)
[%thread @ *] (handle-sign:sc i.t.wire t.t.wire sign-arvo)
[%build @ ~] (handle-build:sc i.t.wire sign-arvo)
[%bind ~] `state
==
[cards this]
:: On unexpected failure, kill all outstanding strands
@ -228,6 +262,41 @@
--
::
|_ =bowl:gall
::
++ bind-eyre
^- card
[%pass /bind %arvo %e %connect [~ /spider] %spider]
::
++ handle-http-request
|= [eyre-id=@ta =inbound-request:eyre]
^- (quip card _state)
?> authenticated.inbound-request
=/ url
(parse-request-line:server url.request.inbound-request)
?> ?=([%spider @t @t @t ~] site.url)
=* input-mark i.t.site.url
=* thread i.t.t.site.url
=* output-mark i.t.t.t.site.url
=/ =tid
(scot %uv (sham eny.bowl))
=. serving.state
(~(put by serving.state) tid [eyre-id output-mark])
=+ .^
=tube:clay
%cc
/(scot %p our.bowl)/[q.byk.bowl]/(scot %da now.bowl)/json/[input-mark]
==
?> ?=(^ body.request.inbound-request)
=/ body=json
(need (de-json:html q.u.body.request.inbound-request))
=/ input=vase
(tube !>(body))
=/ =start-args
[~ `tid thread input]
=^ cards state
(handle-start-thread start-args)
[cards state]
::
++ on-poke-input
|= input
=/ yarn (~(got by tid.state) tid)
@ -394,6 +463,25 @@
:~ [%give %fact ~[/thread-result/[tid]] %thread-fail !>([term tang])]
[%give %kick ~[/thread-result/[tid]] ~]
==
++ thread-http-fail
|= [=tid =term =tang]
^- (quip card ^state)
=- (fall - `state)
%+ bind
(~(get by serving.state) tid)
|= [eyre-id=@ta output=mark]
:_ state(serving (~(del by serving.state) tid))
%+ give-simple-payload:app:server eyre-id
^- simple-payload:http
:_ ~ :_ ~
?. ?=(http-error:spider term)
((slog tang) 500)
?- term
%bad-request 400
%forbidden 403
%nonexistent 404
%offline 504
==
::
++ thread-fail
|= [=yarn =term =tang]
@ -402,7 +490,24 @@
=/ =tid (yarn-to-tid yarn)
=/ fail-cards (thread-say-fail tid term tang)
=^ cards state (thread-clean yarn)
[(weld fail-cards cards) state]
=^ http-cards state (thread-http-fail tid term tang)
[:(weld fail-cards cards http-cards) state]
::
++ thread-http-response
|= [=tid =vase]
^- (quip card ^state)
=- (fall - `state)
%+ bind
(~(get by serving.state) tid)
|= [eyre-id=@ta output=mark]
=+ .^
=tube:clay
%cc
/(scot %p our.bowl)/[q.byk.bowl]/(scot %da now.bowl)/[output]/json
==
:_ state(serving (~(del by serving.state) tid))
%+ give-simple-payload:app:server eyre-id
(json-response:gen:server !<(json (tube vase)))
::
++ thread-done
|= [=yarn =vase]
@ -413,8 +518,10 @@
:~ [%give %fact ~[/thread-result/[tid]] %thread-done vase]
[%give %kick ~[/thread-result/[tid]] ~]
==
=^ http-cards state
(thread-http-response tid vase)
=^ cards state (thread-clean yarn)
[(weld done-cards cards) state]
[:(weld done-cards cards http-cards) state]
::
++ thread-clean
|= =yarn
@ -474,5 +581,5 @@
::
++ clean-state
!> ^- clean-slate
1+state(running (turn (tap-yarn running.state) head))
2+state(running (turn (tap-yarn running.state) head))
--

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

@ -0,0 +1,61 @@
/- sur=graph-view
/+ resource, group-store
^?
=< [sur .]
=, sur
|%
++ dejs
=, dejs:format
|%
++ action
|^
^- $-(json ^action)
%- of
:~ create+create
delete+delete
join+join
leave+leave
groupify+groupify
::invite+invite
==
::
++ create
%- ou
:~ resource+(un dejs:resource)
title+(un so)
description+(un so)
mark+(uf ~ (mu so))
associated+(un associated)
==
::
++ leave
%- ot
:~ resource+dejs:resource
==
::
++ delete
%- ot
:~ resource+dejs:resource
==
::
++ join
%- ot
:~ resource+dejs:resource
ship+(su ;~(pfix sig fed:ag))
==
::
++ groupify
%- ou
:~ resource+(un dejs:resource)
to+(uf ~ (mu dejs:resource))
==
++ invite !!
::
++ associated
%- of
:~ group+dejs:resource
policy+policy:dejs:group-store
==
--
--
--

View File

@ -13,12 +13,24 @@
::
++ get-graph
|= res=resource
^- marked-graph:store
%+ scry-for marked-graph:store
^- update:store
%+ scry-for update:store
/graph/(scot %p entity.res)/[name.res]
::
++ peek-log
++ get-update-log
|= rid=resource
^- update-log:store
%+ scry-for update-log:store
/update-log/(scot %p entity.rid)/[name.rid]
::
++ peek-update-log
|= res=resource
^- (unit time)
(scry-for (unit time) /peek-update-log/(scot %p entity.res)/[name.res])
::
++ get-update-log-subset
|= [res=resource start=@da]
^- update-log:store
%+ scry-for update-log:store
/update-log-subset/(scot %p entity.res)/[name.res]/(scot %da start)/'~'
--

View File

@ -105,6 +105,8 @@
%file-server
%glob
%graph-store
%graph-pull-hook
%graph-push-hook
==
::
++ deft-fish :: default connects
@ -207,7 +209,7 @@
==
::
++ on-load
|= [hood-version=?(%1 %2 %3 %4 %5 %6 %7 %8 %9) old=any-state]
|= [hood-version=?(%1 %2 %3 %4 %5 %6 %7 %8 %9 %10) old=any-state]
=< se-abet =< se-view
=. sat old
=. dev (~(gut by bin) ost *source)
@ -236,6 +238,9 @@
(se-born | %home %group-pull-hook)
=? ..on-load (lte hood-version %9)
(se-born | %home %graph-store)
=? ..on-load (lte hood-version %10)
=> (se-born | %home %graph-push-hook)
(se-born | %home %graph-pull-hook)
..on-load
::
++ reap-phat :: ack connect

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,12 +25,24 @@
|%
+$ 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
update-mark=term
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
@ -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

@ -241,6 +241,16 @@
;< our=@p bind:m get-our
(watch wire [our term] path)
::
++ scry
|* [=mold =path]
=/ m (strand ,mold)
^- form:m
?> ?=(^ path)
?> ?=(^ t.path)
;< =bowl:spider bind:m get-bowl
%- pure:m
.^(mold i.path (scot %p our.bowl) i.t.path (scot %da now.bowl) t.t.path)
::
++ leave
|= [=wire =dock]
=/ m (strand ,~)
@ -285,6 +295,20 @@
[%pass /wait/(scot %da until) %arvo %b %wait until]
(send-raw-card card)
::
++ map-err
|* computation-result=mold
=/ m (strand ,computation-result)
|= [f=$-([term tang] [term tang]) computation=form:m]
^- form:m
|= tin=strand-input:strand
=* loop $
=/ c-res (computation tin)
?: ?=(%cont -.next.c-res)
c-res(self.next ..loop(computation self.next.c-res))
?. ?=(%fail -.next.c-res)
c-res
c-res(err.next (f err.next.c-res))
::
++ set-timeout
|* computation-result=mold
=/ m (strand ,computation-result)
@ -478,6 +502,17 @@
`[%skip ~]
`[%done +>.sign-arvo.u.in.tin]
==
:: +check-online: require that peer respond before timeout
::
++ check-online
|= [who=ship lag=@dr]
=/ m (strand ,~)
^- form:m
%+ (map-err ,~) |=(* [%offline *tang])
%+ (set-timeout ,~) lag
;< ~ bind:m
(poke [who %hood] %helm-hi !>(~))
(pure:m ~)
::
:: Queue on skip, try next on fail %ignore
::

View File

@ -1,7 +1,9 @@
/+ *graph-store
|_ upd=update
++ grad %noun
++ grow
|%
++ noun upd
++ json (update:enjs upd)
--
::

View File

@ -0,0 +1,27 @@
/- *post
|_ i=indexed-post
++ grow
|%
++ noun i
--
++ grab
|%
++ noun
|= p=*
=/ ip ;;(indexed-post p)
?+ index.p.ip ~|(index+index.p.ip !!)
:: top-level link post; title and url
::
[@ ~]
?> ?=([[%text @] [%url @] ~] contents.p.ip)
ip
::
:: comment on link post; comment text
::
[@ @ ~]
?> ?=([[%text @] ~] contents.p.ip)
ip
==
--
++ grad %noun
--

View File

@ -0,0 +1,13 @@
/+ *graph-view
|_ act=action
++ grad %noun
++ grow
|%
++ noun act
--
++ grab
|%
++ noun action
++ json action:dejs
--
--

View File

@ -0,0 +1,45 @@
/- *group, store=graph-store
/+ resource
^?
|%
:: $associated: A group to associate, or a policy if it is unmanaged
::
+$ associated
$% [%group rid=resource]
[%policy =policy]
==
::
:: $error: An error from a graph-view poke
::
:: %offline: Ship is offline
:: %bad-perms: Not permitted
:: %unknown: Anything not described above
::
+$ error
?(%offline %bad-perms %unknown)
:: $action: A semantic action on graphs
::
:: %create: Create a graph and associated metadata
:: %delete: Delete a graph
:: %join: Join a graph
:: %invite: Invite users to a graph
:: %groupify: Make graph into managed group
::
+$ action
$%
$: %create
rid=resource
title=@t
description=@t
mark=(unit mark)
=associated
==
[%delete rid=resource]
[%leave rid=resource]
[%join rid=resource =ship]
::[%invite rid=resource ships=(set ship)]
[%groupify rid=resource to=(unit resource)]
[%forward rid=resource =update:store]
==
--

View File

@ -5,4 +5,10 @@
+$ input [=tid =cage]
+$ tid tid:strand
+$ bowl bowl:strand
+$ http-error
$? %bad-request :: 400
%forbidden :: 403
%nonexistent :: 404
%offline :: 504
==
--

View File

@ -0,0 +1,60 @@
/- spider, graph=graph-store, *metadata-store, *group, group-store
/+ strandio, resource, graph-view
=>
|%
++ strand strand:spider
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ handle-group
|= [rid=resource =associated:graph-view]
=/ m (strand ,resource)
?: ?=(%group -.associated)
(pure:m rid.associated)
=/ =action:group-store
[%add-group rid policy.associated %&]
;< ~ bind:m (poke-our %group-store %group-action !>(action))
;< ~ bind:m
(poke-our %group-push-hook %push-hook-action !>([%add rid]))
(pure:m rid)
--
::
=, strand=strand:spider
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<(=action:graph-view arg)
?> ?=(%create -.action)
;< =bowl:spider bind:m get-bowl:strandio
:: Add graph to graph-store
::
?. =(our.bowl entity.rid.action)
(strand-fail:strandio %bad-request ~)
=/ =update:graph
[%0 now.bowl %add-graph rid.action *graph:graph mark.action]
;< ~ bind:m
(poke-our %graph-store graph-update+!>(update))
;< ~ bind:m
(poke-our %graph-push-hook %push-hook-action !>([%add rid.action]))
:: Add group, if graph is unmanaged
::
;< group=resource bind:m
(handle-group rid.action associated.action)
=/ group-path=path
(en-path:resource group)
:: Setup metadata
::
=/ =metadata
%* . *metadata
title title.action
description description.action
date-created now.bowl
creator our.bowl
==
=/ act=metadata-action
[%add group-path graph+(en-path:resource rid.action) metadata]
;< ~ bind:m (poke-our %metadata-hook %metadata-action !>(act))
;< ~ bind:m
(poke-our %metadata-hook %metadata-hook-action !>([%add-owned group-path]))
(pure:m !>(~))

View File

@ -0,0 +1,70 @@
/- spider, graph-view, graph=graph-store, *metadata-store, *group
/+ strandio, resource
=>
|%
++ strand strand:spider
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ scry-metadata
|= rid=resource
=/ m (strand ,(unit resource))
;< paxs=(unit (set path)) bind:m
%+ scry:strandio ,(unit (set path))
;: weld
/gx/metadata-store/resource/publish
(en-path:resource rid)
/noun
==
?~ paxs (pure:m ~)
?~ u.paxs (pure:m ~)
(pure:m `(de-path:resource n.u.paxs))
::
++ scry-group
|= rid=resource
=/ m (strand ,group)
;< ugroup=(unit group) bind:m
%+ scry:strandio ,(unit group)
;: weld
/gx/group-store/groups
(en-path:resource rid)
/noun
==
(pure:m (need ugroup))
::
++ delete-graph
|= rid=resource
=/ m (strand ,~)
^- form:m
;< ~ bind:m
(poke-our %graph-push-hook %push-hook-action !>([%remove rid]))
;< =bowl:spider bind:m get-bowl:strandio
;< ~ bind:m
(poke-our %graph-store %graph-update !>([%0 now.bowl %archive-graph rid]))
(pure:m ~)
--
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<(=action:graph-view arg)
?> ?=(%delete -.action)
;< =bowl:spider bind:m get-bowl:strandio
?. =(our.bowl entity.rid.action)
(strand-fail:strandio %bad-request ~)
;< ugroup-rid=(unit resource) bind:m
(scry-metadata rid.action)
?~ ugroup-rid !!
;< =group bind:m
(scry-group u.ugroup-rid)
?. hidden.group
;< ~ bind:m
(delete-graph rid.action)
(pure:m !>(~))
;< ~ bind:m
(poke-our %group-push-hook %push-hook-action !>([%remove rid.action]))
;< ~ bind:m
(poke-our %group-store %group-action !>([%remove-group rid.action]))
;< ~ bind:m (delete-graph rid.action)
(pure:m !>(~))

View File

@ -0,0 +1,20 @@
/- spider, graph-view, graph=graph-store, *metadata-store, *group
/+ strandio, resource
=>
|%
++ strand strand:spider
++ poke poke:strandio
--
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<(=action:graph-view arg)
?> ?=(%forward -.action)
;< ~ bind:m
%+ (map-err:strandio ,~) |=(* [%forbidden ~])
%+ poke
[entity.rid.action %graph-push-hook]
[%graph-update !>(update.action)]
(pure:m !>(~))

View File

@ -0,0 +1,74 @@
/- spider, graph-view, graph=graph-store, *metadata-store, *group, *metadata-store
/+ strandio, resource
=>
|%
++ strand strand:spider
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ check-live
|= who=ship
=/ m (strand ,~)
^- form:m
%+ (set-timeout:strandio ,~) ~s20
;< ~ bind:m
(poke [who %hood] %helm-hi !>(~))
(pure:m ~)
::
++ scry-group
|= rid=resource
=/ m (strand ,group)
^- form:m
;< ugroup=(unit group) bind:m
%+ scry:strandio (unit group)
%+ weld /gx/group-store/groups
(snoc (en-path:resource rid) %noun)
?> ?=(^ ugroup)
(pure:m u.ugroup)
::
++ scry-metadatum
|= rid=resource
=/ m (strand ,metadata)
^- form:m
=/ enc-path=@t
(scot %t (spat (en-path:resource rid)))
;< umeta=(unit metadata) bind:m
%+ scry:strandio (unit metadata)
%+ weld /gx/metadata-store/metadata
/[enc-path]/graph/[enc-path]/noun
?> ?=(^ umeta)
(pure:m u.umeta)
--
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<(=action:graph-view arg)
?> ?=(%groupify -.action)
;< =group bind:m (scry-group rid.action)
?. hidden.group
(strand-fail:strandio %bad-request ~)
;< =metadata bind:m
(scry-metadatum rid.action)
?~ to.action
;< ~ bind:m
%+ poke-our %contact-view
contact-view-action+!>([%groupify rid.action title.metadata description.metadata])
(pure:m !>(~))
;< new=^group bind:m (scry-group u.to.action)
?< hidden.new
=/ new-path
(en-path:resource u.to.action)
=/ app-path
(en-path:resource rid.action)
=/ add-md=metadata-action
[%add new-path graph+app-path metadata]
;< ~ bind:m
(poke-our %metadata-store metadata-action+!>(add-md))
;< ~ bind:m
%+ poke-our %metadata-store
metadata-action+!>([%remove app-path graph+app-path])
;< ~ bind:m
(poke-our %group-store %group-update !>([%remove-group rid.action]))
(pure:m !>(~))

View File

@ -0,0 +1,61 @@
/- spider, graph-view, graph=graph-store, *metadata-store, *group
/+ strandio, resource
=>
|%
++ strand strand:spider
++ fail strand-fail:strand
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ scry-metadata
|= rid=resource
=/ m (strand ,(unit resource))
^- form:m
;< pax=(unit (set path)) bind:m
%+ scry:strandio ,(unit (set path))
;: weld
/gx/metadata-store/resource/graph
(en-path:resource rid)
/noun
==
%- pure:m
?~ pax ~
?~ u.pax ~
`(de-path:resource n.u.pax)
--
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<(=action:graph-view arg)
?> ?=(%join -.action)
;< =bowl:spider bind:m get-bowl:strandio
?: =(our.bowl entity.rid.action)
(fail %bad-request ~)
;< group=(unit resource) bind:m (scry-metadata rid.action)
?^ group
:: We have group, graph is managed
;< ~ bind:m
%+ poke-our %graph-pull-hook
pull-hook-action+!>([%add ship.action rid.action])
(pure:m !>(~))
:: Else, add group then join
;< ~ bind:m
%+ (map-err:strandio ,~) |=(* [%forbidden ~])
%+ poke
[ship.action %group-push-hook]
group-update+!>([%add-members rid.action (sy our.bowl ~)])
::
;< ~ bind:m
%+ poke-our %group-pull-hook
pull-hook-action+!>([%add ship.action rid.action])
::
;< ~ bind:m
%+ poke-our %metadata-hook
metadata-hook-action+!>([%add-synced ship.action rid.action])
::
;< ~ bind:m
%+ poke-our %graph-pull-hook
pull-hook-action+!>([%add ship.action rid.action])
(pure:m !>(~))

View File

@ -0,0 +1,67 @@
/- spider, graph-view, graph=graph-store, *metadata-store, *group
/+ strandio, resource
=>
|%
++ strand strand:spider
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ scry-metadata
|= rid=resource
=/ m (strand ,resource)
^- form:m
;< pax=(unit (set path)) bind:m
%+ scry:strandio ,(unit (set path))
;: weld
/gx/metadata-store/resource/graph
(en-path:resource rid)
/noun
==
?> ?=(^ pax)
?> ?=(^ u.pax)
(pure:m (de-path:resource n.u.pax))
::
++ scry-group
|= rid=resource
=/ m (strand ,group)
^- form:m
;< ugroup=(unit group) bind:m
%+ scry:strandio ,(unit group)
;: weld
/gx/group-store/resource/graph
(en-path:resource rid)
/noun
==
(pure:m (need ugroup))
::
++ delete-graph
|= rid=resource
=/ m (strand ,~)
^- form:m
;< ~ bind:m
(poke-our %graph-pull-hook %pull-hook-action !>([%remove rid]))
;< ~ bind:m
(poke-our %graph-store %graph-update !>([%archive-graph rid]))
(pure:m ~)
--
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<([=action:graph-view ~] arg)
?> ?=(%leave -.action)
;< =bowl:spider bind:m get-bowl:strandio
?: =(our.bowl entity.rid.action)
(strand-fail:strandio %bad-request ~)
;< group-rid=resource bind:m (scry-metadata rid.action)
;< g=group bind:m (scry-group group-rid)
?. hidden.g
;< ~ bind:m (delete-graph rid.action)
(pure:m !>(~))
;< ~ bind:m
(poke-our %group-push-hook %pull-hook-action !>([%remove rid.action]))
;< ~ bind:m
(poke-our %group-store %group-action !>([%remove-group rid.action]))
;< ~ bind:m (delete-graph rid.action)
(pure:m !>(~))

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

@ -57,4 +57,16 @@ export default class BaseApi<S extends object = {}> {
scry<T>(app: string, path: Path): Promise<T> {
return fetch(`/~/scry/${app}${path}.json`).then(r => r.json() as Promise<T>);
}
async spider<T>(inputMark: string, outputMark: string, threadName: string, body: any): Promise<T> {
const res = await fetch(`/spider/${inputMark}/${threadName}/${outputMark}.json`, {
method: 'POST',
body: JSON.stringify(body)
});
return res.json();
}
}

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

@ -1,7 +1,9 @@
import BaseApi from './base';
import { StoreState } from '../store/type';
import { Patp, Path, PatpNoSig } from '~/types/noun';
import _ from 'lodash';
import {makeResource, resourceFromPath} from '../lib/group';
import {GroupPolicy, Enc, Post} from '~/types';
export const createPost = (contents: Object[], parentIndex: string = '') => {
return {
@ -20,6 +22,68 @@ export default class GraphApi extends BaseApi<StoreState> {
return this.action('graph-store', 'graph-update', action)
}
private viewAction(threadName: string, action: any) {
return this.spider('graph-view-action', 'json', threadName, action);
}
createManagedGraph(name: string, title: string, description: string, group: Path) {
const associated = { group: resourceFromPath(group) };
const resource = makeResource(`~${window.ship}`, name);
return this.viewAction('graph-create', {
"create": {
resource,
title,
description,
associated
}
});
}
createUnmanagedGraph(name: string, title: string, description: string, policy: Enc<GroupPolicy>) {
const resource = makeResource(`~${window.ship}`, name);
return this.viewAction('graph-create', {
"create": {
resource,
title,
description,
associated: { policy }
}
});
}
joinGraph(ship: Patp, name: string) {
const resource = makeResource(ship, name);
return this.viewAction('graph-join', {
join: {
resource,
ship,
}
});
}
deleteGraph(name: string) {
const resource = makeResource(`~${window.ship}`, name);
return this.viewAction('graph-delete', {
"delete": {
resource
}
});
}
groupifyGraph(ship: Patp, name: string, toPath?: string) {
const resource = makeResource(ship, name);
const to = toPath && resourceFromPath(toPath);
return this.viewAction('graph-groupify', {
groupify: {
resource,
to
}
});
}
addGraph(ship: Patp, name: string, graph: any, mark: any) {
this.storeAction({
'add-graph': {
@ -38,8 +102,9 @@ export default class GraphApi extends BaseApi<StoreState> {
});
}
addPost(ship: Patp, name: string, post: Object) {
addPost(ship: Patp, name: string, post: Post) {
let nodes = {};
const resource = { ship, name };
nodes[post.index] = {
post,
children: { empty: null }
@ -47,7 +112,7 @@ export default class GraphApi extends BaseApi<StoreState> {
return this.storeAction({
'add-nodes': {
resource: { ship, name },
resource,
nodes
}
});
@ -63,7 +128,7 @@ export default class GraphApi extends BaseApi<StoreState> {
}
removeNodes(ship: Patp, name: string, indices: string[]) {
this.storeAction({
return this.storeAction({
'remove-nodes': {
resource: { ship, name },
indices
@ -107,7 +172,7 @@ export default class GraphApi extends BaseApi<StoreState> {
});
}
getGraphSubset(ship: string, resource: string, start: string, end: start) {
getGraphSubset(ship: string, resource: string, start: string, end: string) {
this.scry<any>(
'graph-store',
`/graph-subset/${ship}/${resource}/${end}/${start}`

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

@ -13,3 +13,8 @@ export function resourceFromPath(path: Path): Resource {
const [, , ship, name] = path.split('/');
return { ship, name }
}
export function makeResource(ship: string, name:string) {
return { ship, name };
}

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

@ -140,10 +140,21 @@ const addNodes = (json, state) => {
};
const removeNodes = (json, state) => {
const _remove = (graph, index) => {
if (index.length === 1) {
graph.delete(index[0]);
} else {
const child = graph.get(index[0]);
_remove(child.children, index.slice(1));
graph.set(index[0], child);
}
};
const data = _.get(json, 'remove-nodes', false);
if (data) {
console.log(data);
if (!(data.resource in state.graphs)) { return; }
const { ship, name } = data.resource;
const res = `${ship}/${name}`;
if (!(res in state.graphs)) { return; }
data.indices.forEach((index) => {
console.log(index);
@ -151,13 +162,7 @@ const removeNodes = (json, state) => {
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
}
_remove(state.graphs[res], indexArr);
});
}
};

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

@ -54,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: {},

View File

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

View File

@ -73,6 +73,10 @@ export interface Envelope {
letter: Letter;
}
export type IMessage = Envelope & {
pending?: boolean
};
interface LetterText {
text: string;
}

View File

@ -0,0 +1,30 @@
import {Patp} from "./noun";
export interface TextContent { text: string; };
export interface UrlContent { url: string; }
export interface CodeContent { expresssion: string; output: string; };
export interface ReferenceContent { uid: string; }
export type Content = TextContent | UrlContent | CodeContent | ReferenceContent;
export interface Post {
author: Patp;
contents: Content[];
hash?: string;
index: string;
pending?: boolean;
signatures: string[];
'time-sent': number;
}
export interface GraphNode {
children: Graph;
post: Post;
}
export type Graph = Map<number, GraphNode>;
export type Graphs = { [rid: string]: Graph };

View File

@ -5,6 +5,7 @@ export * from './connection';
export * from './contact-update';
export * from './global';
export * from './group-update';
export * from './graph-update';
export * from './invite-update';
export * from './launch-update';
export * from './link-listen-update';

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

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

@ -8,13 +8,15 @@ 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 { S3Upload, SubmitDragger } from '~/views/components/s3-upload';
import { IUnControlledCodeMirror } from "react-codemirror2";
type ChatScreenProps = RouteComponentProps<{
@ -26,7 +28,7 @@ type ChatScreenProps = RouteComponentProps<{
association: Association;
api: GlobalApi;
read: number;
length: number;
mailboxSize: number;
inbox: Inbox;
contacts: Contacts;
group: Group;
@ -38,13 +40,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 +58,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 +75,26 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
});
}
readyToUpload(): boolean {
return Boolean(this.chatInput.current?.s3Uploader.current?.inputRef.current);
}
onDragEnter() {
if (!this.readyToUpload()) {
return;
}
this.setState({ dragover: true });
}
onDrop(event: DragEvent) {
this.setState({ dragover: false });
if (!event.dataTransfer || !event.dataTransfer.files.length) {
return;
}
event.preventDefault();
this.chatInput.current?.uploadFiles(event.dataTransfer.files);
}
render() {
const { props, state } = this;
@ -97,42 +125,36 @@ 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) {
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}

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,268 +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) && !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
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

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

@ -2,87 +2,91 @@ import React, { PureComponent, Fragment } from "react";
import moment from "moment";
import { Message } from "./message";
import { Envelope } from "~/types/chat-update";
import _ from "lodash";
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>
export class ChatMessage extends PureComponent {
render() {
const {
msg,
previousMsg,
nextMsg,
isFirstUnread,
group,
association,
contacts,
unreadRef,
hideAvatars,
hideNicknames,
remoteContentPolicy,
className = ''
} = this.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}
remoteContentPolicy={remoteContentPolicy}
className={className}
/>
);
} 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;
if (isFirstUnread) {
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}>
<div
className="pv3 gray2 b--gray2 flex items-center justify-center f9 "
>
<p>{moment(_.get(msg, when)).calendar()}</p>
</div>
{messageElem}
</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,54 +1,140 @@
import React, { Component, Fragment } from "react";
import { Virtuoso as VirtualList, VirtuosoMethods } from 'react-virtuoso';
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";
import { Envelope, IMessage } from "~/types/chat-update";
import { RouteComponentProps } from "react-router-dom";
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 GlobalApi from "~/logic/api/global";
import _ from "lodash";
import { LocalUpdateRemoteContentPolicy } from "~/types";
import { ListRange } from "react-virtuoso/dist/engines/scrollSeekEngine";
const MAX_BACKLOG_SIZE = 1000;
const DEFAULT_BACKLOG_SIZE = 200;
const PAGE_SIZE = 50;
const INITIAL_LOAD = 20;
const DEFAULT_BACKLOG_SIZE = 200;
const IDLE_THRESHOLD = 3;
const Placeholder = ({ 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>
);
export class ChatWindow extends Component {
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;
range: ListRange;
initialized: boolean;
}
export class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
private unreadReference: React.RefObject<Component>;
private virtualList: React.RefObject<VirtuosoMethods>;
constructor(props) {
super(props);
this.state = {
numPages: 1,
};
this.hasAskedForMessages = false;
this.state = {
fetchPending: false,
idle: (this.initialIndex() < props.mailboxSize - IDLE_THRESHOLD) ? true : false,
range: { startIndex: 0, endIndex: 0},
initialized: false
};
this.dismissUnread = this.dismissUnread.bind(this);
this.scrollIsAtBottom = this.scrollIsAtBottom.bind(this);
this.scrollIsAtTop = this.scrollIsAtTop.bind(this);
this.initialIndex = this.initialIndex.bind(this);
this.scrollToUnread = this.scrollToUnread.bind(this);
this.scrollReference = React.createRef();
this.unreadReference = React.createRef();
this.virtualList = React.createRef();
}
componentDidMount() {
this.initialFetch();
}
if (this.state.numPages === 1 && this.props.unreadCount < INITIAL_LOAD) {
this.dismissUnread();
this.scrollToBottom();
}
initialIndex() {
const { mailboxSize, unreadCount } = this.props;
return Math.min(Math.max(mailboxSize - 1 < INITIAL_LOAD
? 0
: unreadCount // otherwise if there are unread messages
? mailboxSize - unreadCount - 1 // put the one right before at the top
: mailboxSize - 1,
0), mailboxSize);
}
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);
const { envelopes, mailboxSize, unreadCount } = this.props;
if (envelopes.length > 0) {
const start = Math.min(mailboxSize - unreadCount, mailboxSize - DEFAULT_BACKLOG_SIZE);
this.fetchMessages(start, start + DEFAULT_BACKLOG_SIZE, true);
const initialIndex = this.initialIndex();
if (initialIndex < mailboxSize - IDLE_THRESHOLD) {
this.setState({ idle: true });
}
if (unreadCount !== mailboxSize) {
this.virtualList.current?.scrollToIndex({
index: initialIndex,
align: initialIndex <= 1 ? 'end' : 'start'
});
setTimeout(() => {
this.setState({ initialized: true });
}, 500);
} else {
this.setState({ initialized: true });
}
} else {
setTimeout(() => {
this.initialFetch();
@ -57,140 +143,164 @@ export class ChatWindow extends Component {
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
const { isChatMissing, history, envelopes, mailboxSize, unreadCount } = this.props;
let { idle } = this.state;
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 (isChatMissing) {
history.push("/~chat");
} else if (envelopes.length !== prevProps.envelopes.length && this.state.fetchPending) {
this.setState({ fetchPending: false });
}
if (this.state.numPages === numPages) {
if (props.unreadCount > 20) {
this.scrollToUnread();
if (this.state.range.endIndex !== prevState.range.endIndex) {
if (this.state.range.endIndex < mailboxSize - IDLE_THRESHOLD) {
if (!idle) {
idle = true;
}
} else {
this.setState({ numPages }, () => {
if (props.unreadCount > 20) {
this.scrollToUnread();
}
});
} else if (idle && (unreadCount === 0 || this.state.range.endIndex === 0)) {
idle = false;
}
} else if (
state.numPages === 1 &&
this.props.unreadCount < INITIAL_LOAD &&
this.props.unreadCount > 0
) {
this.dismissUnread();
this.scrollToBottom();
this.setState({ idle });
}
}
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();
if (!idle && idle !== prevState.idle) {
setTimeout(() => {
this.virtualList.current?.scrollToIndex(mailboxSize);
}, 500)
}
}
scrollToBottom() {
if (this.scrollReference.current) {
this.scrollReference.current.scrollToBottom();
if (!idle && prevProps.unreadCount !== unreadCount) {
this.virtualList.current?.scrollToIndex(mailboxSize);
}
if (this.state.numPages !== 1) {
this.setState({ numPages: 1 });
if (!idle && envelopes.length !== prevProps.envelopes.length) {
this.virtualList.current?.scrollToIndex(mailboxSize);
}
}
scrollToUnread() {
if (this.scrollReference.current && this.unreadReference.current) {
this.scrollReference.current.scrollToReference(this.unreadReference);
}
const { mailboxSize, unreadCount } = this.props;
this.virtualList.current?.scrollToIndex({
index: mailboxSize - unreadCount,
align: 'center'
});
}
dismissUnread() {
this.props.api.chat.read(this.props.station);
}
fetchBacklog(size) {
const { props } = this;
fetchMessages(start, end, force = false) {
start = Math.max(start, 0);
end = Math.max(end, 0);
const { api, mailboxSize, station } = this.props;
if (
props.messages.length >= props.length ||
this.hasAskedForMessages ||
props.length <= 0
(this.state.fetchPending ||
mailboxSize <= 0)
&& !force
) {
return;
}
api.chat
.fetchMessages(Math.max(mailboxSize - end, 0), Math.min(mailboxSize - start, mailboxSize), station)
.finally(() => {
this.setState({ fetchPending: false });
});
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;
}
this.setState({ fetchPending: 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);
const {
envelopes,
stationPendingMessages,
unreadCount,
unreadMsg,
isChatLoading,
isChatUnsynced,
api,
ship,
station,
association,
group,
contacts,
mailboxSize,
hideAvatars,
hideNicknames,
remoteContentPolicy,
} = this.props;
const messages: Envelope[] = [];
const debouncedFetch = _.debounce(this.fetchMessages, 500).bind(this);
envelopes
.forEach((message) => {
messages[message.number] = message;
});
stationPendingMessages.sort((a, b) => a.when - b.when).forEach((message, index) => {
messages[mailboxSize + index + 1] = message;
});
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>
unreadCount={unreadCount}
unreadMsg={this.state.idle ? unreadMsg : false}
dismissUnread={this.dismissUnread}
onClick={this.scrollToUnread}
/>
<BacklogElement isChatLoading={isChatLoading} />
<ResubscribeElement {...{ api, host: ship, station, isChatUnsynced}} />
{messages.length ? <VirtualList
ref={this.virtualList}
style={{height: '100%', width: '100%', visibility: this.state.initialized ? 'initial' : 'hidden'}}
totalCount={mailboxSize + stationPendingMessages.length}
followOutput={!this.state.idle}
endReached={this.dismissUnread}
scrollSeek={{
enter: velocity => Math.abs(velocity) > 2000,
exit: velocity => Math.abs(velocity) < 200,
change: (_velocity, _range) => {},
placeholder: this.state.initialized ? Placeholder : () => <div></div>
}}
startReached={() => debouncedFetch(0, DEFAULT_BACKLOG_SIZE)}
overscan={DEFAULT_BACKLOG_SIZE}
rangeChanged={(range) => {
this.setState({ range });
debouncedFetch(range.startIndex - (DEFAULT_BACKLOG_SIZE / 2), range.endIndex + (DEFAULT_BACKLOG_SIZE / 2));
}}
item={(i) => {
const number = i + 1;
const msg = messages[number];
if (!msg) {
debouncedFetch(number - DEFAULT_BACKLOG_SIZE, number + DEFAULT_BACKLOG_SIZE);
return <Placeholder index={number} height="0px" style={{overflow: 'hidden'}} />;
}
return <ChatMessage
key={number}
unreadRef={this.unreadReference}
isFirstUnread={
unreadCount
&& mailboxSize - unreadCount === number
&& this.state.idle
}
msg={msg}
previousMsg={messages[number + 1]}
nextMsg={messages[number - 1]}
association={association}
group={group}
contacts={contacts}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
remoteContentPolicy={remoteContentPolicy}
className={number === mailboxSize + stationPendingMessages.length ? 'pb3' : ''}
/>
}}
/> : <div style={{height: '100%', width: '100%'}}></div>}
</Fragment>
);
}

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,13 +1,13 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { ChannelItem } from './channel-item';
import { deSig } from "~/logic/lib/util";
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(`(( <-> )?~${deSig(window.ship)}( <-> )?)`);
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) {

View File

@ -2,7 +2,7 @@ import React, { Component } from 'react';
import TextContent from './content/text';
import CodeContent from './content/code';
import UrlContent from './content/url';
import RemoteContent from '~/views/components/RemoteContent';
export default class MessageContent extends Component {
@ -15,7 +15,18 @@ export default class MessageContent extends Component {
if ('code' in content) {
return <CodeContent content={content} />;
} else if ('url' in content) {
return <UrlContent content={content} />;
return (
<RemoteContent
url={content.url}
remoteContentPolicy={props.remoteContentPolicy}
imageProps={{style: {
maxWidth: '18rem'
}}}
videoProps={{style: {
maxWidth: '18rem'
}}}
/>
);
} else if ('me' in content) {
return (
<p className='f7 i lh-copy v-top'>

View File

@ -1,37 +1,41 @@
import React, { Component } from 'react';
import React 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 {
msg,
renderSigil,
remoteContentPolicy,
className = ''
} = props;
const pending = 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;
renderSigil
? `w-100 f7 pl3 pt4 pr3 cf flex lh-copy ${pending} ${className}`
: `w-100 pr3 cf hide-child flex ${pending} ${className}`;
const timestamp =
moment.unix(props.msg.when / 1000).format(
props.renderSigil ? 'hh:mm a' : 'hh:mm'
moment.unix(msg.when / 1000).format(
renderSigil ? 'hh:mm a' : 'hh:mm'
);
return (
<div className={containerClass}
style={{
minHeight: 'min-content'
}}>
{
props.renderSigil ? (
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} />
<MessageContent letter={msg.letter} remoteContentPolicy={remoteContentPolicy}/>
</div>
</div>
)
@ -41,66 +45,80 @@ export const Message = (props) => {
};
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 = '';
}
const paddingTop = props.paddingTop ? { 'paddingTop': '6px' } : '';
const datestamp =
'~' + moment.unix(props.msg.when / 1000).format('YYYY.M.D');
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>
);
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);
}
let nameSpan = null;
const copyNotice = (saveName) => {
if (nameSpan !== null) {
nameSpan.innerText = 'Copied';
setTimeout(() => {
nameSpan.innerText = saveName;
}, 800);
}
};
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')
}
ref={(e) => nameSpan = e}
onClick={() => {
writeText(`~${props.msg.author}`);
copyNotice(name);
}}
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} remoteContentPolicy={props.remoteContentPolicy} />
</div>
</div>
);
};

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

@ -85,6 +85,8 @@ export class SettingsScreen extends Component {
isOwner={isOwner}
changeLoading={this.changeLoading}
station={station}
association={association}
contacts={contacts}
api={api} />
<MetadataSettings
isOwner={isOwner}

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