Merge remote-tracking branch 'origin/release/next-vere' into scot-jets

This commit is contained in:
Elliot Glaysher 2020-05-14 14:42:53 -07:00
commit 19bb618d8c
105 changed files with 3417 additions and 1147 deletions

View File

@ -1,5 +1,117 @@
# Maintainers' Guide
## Branch organization
The essence of this branching scheme is that you create "release branches" of
independently releasable units of work. These can then be released by their
maintainers when ready.
### Master branch
Master is what's released on the network. Deployment instructions are in the
next section, but tagged releases should always come from this branch.
### Feature branches
Anyone can create feature branches. For those with commit access to
urbit/urbit, you're welcome to create them in this repo; otherwise, fork the
repo and create them there.
Usually, new development should start from master, but if your work depends on
work in another feature branch or release branch, start from there.
If, after starting your work, you need changes that are in master, merge it into
your branch. If you need changes that are in a release branch or feature
branch, merge it into your branch, but understand that your work now depends on
that release branch, which means it won't be released until that one is
released.
### Release branches
Release branches are code that is ready to release. All release branch names
should start with `release/`.
All code must be reviewed before being pushed to a release branch. Thus,
feature branches should be PR'd against a release branch, not master.
Create new release branches as needed. You don't need a new one for every PR,
since many changes are relatively small and can be merged together with little
risk. However, once you merge two branches, they're now coupled and will only
be released together -- unless one of the underlying commits is separately put
on a release branch.
Here's a worked example. The rule is to make however many branches are useful,
and no more. This example is not prescriptive, the developers making the
changes may add, remove, or rename branches in this flow at will.
Suppose you (plural, the dev community at large) complete some work in a
userspace app, and you put it in `release/next-userspace`. Separately, you make
a small JS change. If you PR it to `release/next-userspace`, then it will only
be released at the same time as the app changes. Maybe this is fine, or maybe
you want this change to go out quickly, and the change in
`release/next-userspace` is relatively risky, so you don't want to push it out
on Friday afternoon. In this case, put the change in another release branch,
say `release/next-js`. Now either can be released independently.
Suppose you do further work that you want to PR to `release/next-userspace`, but
it depends on your fixes in `release/next-js`. Simply merge `release/next-js`
into either your feature branch or `release/next-userspace` and PR your finished
work to `release/next-userspace`. Now there is a one-way coupling:
`release/next-userspace` contains `release/next-js`, so releasing it will
implicitly release `release/next-js`. However, you can still release
`release/next-js` independently.
This scheme extends to other branches, like `release/next-kernel` or
`release/os1.1` or `release/ford-fusion`. Some branches may be long-lived and
represent simply the "next" release of something, while others will have a
definite lifetime that corresponds to development of a particular feature or
numbered release.
Since they are "done", release branches should be considered "public", in the
sense that others may depend on them at will. Thus, never rebase a release
branch.
When cutting a new release, you can filter branches with `git branch --list
'release/*'` or by typing "release/" in the branch filter on Github. This will
give you the list of branches which have passed review and may be merged to
master and released. When choosing which branches to release, make sure you
understand the risks of releasing them immediately. If merging these produces
nontrivial conflicts, consider asking the developers on those branches to merge
between themselves. In many cases a developer can do this directly, but if it's
sufficiently nontrivial, this may be a reviewed PR of one release branch into
another.
### Non-OTAable release branches
In some cases, work is completed which cannot be OTA'd as written. For example,
the code may lack state adapters, or it may not properly handle outstanding
subscriptions. It could also be code which is planned to be released only upon
a breach (network-wide or rolling).
In this case, the code may be PR'd to a `na-release/` branch. All rules are the
same as for release branches, except that the code does not need to apply
cleanly to an existing ship. If you later write state adapter or otherwise make
it OTAable, then you may PR it to a release branch.
### Other cases
Outside contributors can generally target their PRs against master unless
specifically instructed. Maintainers should retarget those branches as
appropriate.
If a commit is not something that goes into a release (eg changes to README or
CI), it may be committed straight to master.
If a hotfix is urgent, it may be PR'd straight to master. This should only be
done if you reasonably expect that it will be released soon and before anything
else is released.
If a series of commits that you want to release is on a release branch, but you
really don't want to release the whole branch, you must cherry-pick them onto
another release branch. Cherry-picking isn't ideal because those commits will
be duplicated in the history, but it won't have any serious side effects.
## Hotfixes
Here lies an informal guide for making hotfix releases and deploying them to
@ -175,4 +287,3 @@ Post an announcement to urbit-dev. The tag annotation, basically, is fine here
-- I usually add the %base hash (for Urbit OS releases) and the release binary
URLs (for Vere releases). Check the urbit-dev archives for examples of these
announcements.

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cdc31bb717626f95d7455349dac4f3171205667c4d08ed9fad071bd13266bab6
size 10013218
oid sha256:801eb8574daff9f0ac88e2e40dab09d95bd8d667df953e971501a1f8db4fd039
size 10394205

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a637a7a8e2061caa09ea1cf62c2295a0b14920588d01338e9bd2f06eecf1c1f
size 1234571
oid sha256:df9ab46632f1a6727837eb03ddf5d37c8f415d89b3205fbc2005891fd3e8921e
size 1234585

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8eeac47513dba778cd83269996eb04f1519280ec5a36bf0395517451cd087d5
size 12473677
oid sha256:50a06217c5354abe42baec10072ddba8e0129c20806fc8173529989724397836
size 12874302

View File

@ -226,8 +226,6 @@
::
++ catch-up
^- (quip card _state)
?. .^(? %gu /(scot %p our.bowl)/chat-store/(scot %da now.bowl))
[~ state]
=/ =inbox
(scry-for inbox %chat-store /all)
|- ^- (quip card _state)

View File

@ -602,7 +602,8 @@
::
[%backlog @ @ @ *]
=/ chat=path (oust [(dec (lent t.wir)) 1] `(list @ta)`t.wir)
%. (poke-chat-hook-action %remove chat)
:_ state
%. ~[(chat-view-poke %delete chat)]
%- slog
:* leaf+"chat-hook failed subscribe on {(spud chat)}"
leaf+"stack trace:"

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 B

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

View File

@ -26,5 +26,6 @@
<script src="/~channel/channel.js"></script>
<script src="/~modulo/session.js"></script>
<script src="/~chat/js/index.js"></script>
<script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -417,14 +417,17 @@
:* to
(mul windup-years yer:yo)
stars
(div (mul unlock-years yer:yo) stars)
1
(div (mul unlock-years yer:yo) stars)
==
::
++ register-conditional
|= [to=address [b1=@ud b2=@ud b3=@ud] unlock-years-per-batch=@ud]
%- register-conditional:dat
=- [`address`to b1 b2 b3 `@ud`- 1]
(div (mul unlock-years-per-batch yer:yo) :(add b1 b2 b3))
:* to
b1 b2 b3
1
(div (mul unlock-years-per-batch yer:yo) :(add b1 b2 b3))
==
::
--
--

View File

@ -19,7 +19,7 @@
+$ state-zero [%0 state-base]
+$ state-one [%1 state-base]
+$ state-base
$: synced=(map path ship)
$: =synced
invite-created=_|
==
--
@ -77,6 +77,7 @@
^- (quip card _this)
?+ path (on-watch:def path)
[%contacts *] [(watch-contacts:cc t.path) this]
[%synced *] [(watch-synced:cc t.path) this]
==
::
++ on-agent
@ -124,30 +125,29 @@
++ poke-contact-action
|= act=contact-action
^- (quip card _state)
|^
:_ state
?+ -.act !!
%edit (handle-contact-action path.act ship.act act)
%add (handle-contact-action path.act ship.act act)
%remove (handle-contact-action path.act ship.act act)
==
::
++ handle-contact-action
|= [=path =ship act=contact-action]
^- (list card)
:: local
?: (team:title our.bol src.bol)
=/ shp ?:(=(path /~/default) our.bol (~(got by synced) path))
=/ appl ?:(=(shp our.bol) %contact-store %contact-hook)
[%pass / %agent [shp appl] %poke %contact-action !>(act)]~
:: foreign
=/ shp (~(got by synced) path)
?. |(=(shp our.bol) =(src.bol ship)) ~
:: scry group to check if ship is a member
=/ =group (need (group-scry path))
?. (~(has in group) shp) ~
[%pass / %agent [our.bol %contact-store] %poke %contact-action !>(act)]~
--
::
++ handle-contact-action
|= [=path =ship act=contact-action]
^- (list card)
:: local
?: (team:title our.bol src.bol)
?. (~(has by synced) path) ~
=/ shp ?:(=(path /~/default) our.bol (~(got by synced) path))
=/ appl ?:(=(shp our.bol) %contact-store %contact-hook)
[%pass / %agent [shp appl] %poke %contact-action !>(act)]~
:: foreign
=/ shp (~(got by synced) path)
?. |(=(shp our.bol) =(src.bol ship)) ~
:: scry group to check if ship is a member
=/ =group (need (group-scry path))
?. (~(has in group) shp) ~
[%pass / %agent [our.bol %contact-store] %poke %contact-action !>(act)]~
::
++ poke-hook-action
|= act=contact-hook-action
@ -160,7 +160,9 @@
[~ state]
=. synced (~(put by synced) path.act our.bol)
:_ state
[%pass contact-path %agent [our.bol %contact-store] %watch contact-path]~
:~ [%pass contact-path %agent [our.bol %contact-store] %watch contact-path]
[%give %fact [/synced]~ %contact-hook-update !>([%initial synced])]
==
::
%add-synced
?> (team:title our.bol src.bol)
@ -168,7 +170,9 @@
=. synced (~(put by synced) path.act ship.act)
=/ contact-path [%contacts path.act]
:_ state
[%pass contact-path %agent [ship.act %contact-hook] %watch contact-path]~
:~ [%pass contact-path %agent [ship.act %contact-hook] %watch contact-path]
[%give %fact [/synced]~ %contact-hook-update !>([%initial synced])]
==
::
%remove
=/ ship (~(get by synced) path.act)
@ -179,13 +183,20 @@
%- zing
:~ (pull-wire [%contacts path.act])
[%give %kick ~[[%contacts path.act]] ~]~
[%give %fact [/synced]~ %contact-hook-update !>([%initial synced])]~
==
?. |(=(u.ship src.bol) (team:title our.bol src.bol))
:: if neither ship = source or source = us, do nothing
[~ state]
:: delete a foreign ship's path
:- (pull-wire [%contacts path.act])
state(synced (~(del by synced) path.act))
=/ cards
(handle-contact-action path.act our.bol [%remove path.act our.bol])
:_ state(synced (~(del by synced) path.act))
%- zing
:~ (pull-wire [%contacts path.act])
[%give %fact [/synced]~ %contact-hook-update !>([%initial synced])]~
cards
==
==
::
++ watch-contacts
@ -197,10 +208,13 @@
=/ =group (need (group-scry pax))
?> (~(has in group) src.bol)
=/ contacts (need (contacts-scry pax))
:~ :*
%give %fact ~ %contact-update
!>([%contacts pax contacts])
== ==
[%give %fact ~ %contact-update !>([%contacts pax contacts])]~
::
++ watch-synced
|= pax=path
^- (list card)
?> (team:title our.bol src.bol)
[%give %fact ~ %contact-hook-update !>([%initial synced])]~
::
++ watch-ack
|= [wir=wire saw=(unit tang)]
@ -308,13 +322,15 @@
==
::
%add
=/ owner (~(got by synced) path.fact)
?> |(=(owner src.bol) =(src.bol ship.fact))
=/ owner (~(get by synced) path.fact)
?~ owner ~
?> |(=(u.owner src.bol) =(src.bol ship.fact))
~[(contact-poke [%add path.fact ship.fact contact.fact])]
::
%remove
=/ owner (~(got by synced) path.fact)
?> |(=(owner src.bol) =(src.bol ship.fact))
=/ owner (~(get by synced) path.fact)
?~ owner ~
?> |(=(u.owner src.bol) =(src.bol ship.fact))
%+ welp
:~ (group-poke [%remove [ship.fact ~ ~] path.fact])
(contact-poke [%remove path.fact ship.fact])
@ -353,7 +369,8 @@
|= =path
^- (quip card _state)
?. (~(has by synced) path)
[~ state]
:_ state
[(contact-poke [%delete path])]~
:_ state(synced (~(del by synced) path))
:~ [%pass [%contacts path] %agent [our.bol %contact-store] %leave ~]
[(contact-poke [%delete path])]

View File

@ -5,18 +5,33 @@
+$ card card:agent:gall
+$ versioned-state
$% state-zero
state-one
==
::
+$ rolodex-0 (map path contacts-0)
+$ contacts-0 (map ship contact-0)
+$ avatar-0 [content-type=@t octs=[p=@ud q=@t]]
+$ contact-0
$: nickname=@t
email=@t
phone=@t
website=@t
notes=@t
color=@ux
avatar=(unit avatar-0)
==
::
+$ state-zero
$: %0
=rolodex
rolodex=rolodex-0
==
+$ diff
$% [%contact-update contact-update]
+$ state-one
$: %1
=rolodex
==
--
::
=| state-zero
=| state-one
=* state -
%- agent:dbug
^- agent:gall
@ -30,8 +45,26 @@
++ on-init on-init:def
++ on-save !>(state)
++ on-load
|= old=vase
`this(state !<(state-zero old))
|= old-vase=vase
=/ old !<(versioned-state old-vase)
?: ?=(%1 -.old)
[~ this(state old)]
=/ new-rolodex=^rolodex
%- ~(run by rolodex.old)
|= cons=contacts-0
^- contacts
%- ~(run by cons)
|= con=contact-0
^- contact
:* nickname.con
email.con
phone.con
website.con
notes.con
color.con
~
==
[~ this(state [%1 new-rolodex])]
::
++ on-poke
|= [=mark =vase]
@ -142,7 +175,7 @@
|= [=path =ship]
^- (quip card _state)
=/ contacts (~(got by rolodex) path)
?> (~(has by contacts) ship)
?. (~(has by contacts) ship) [~ state]
=. contacts (~(del by contacts) ship)
:- (send-diff path [%remove path ship])
state(rolodex (~(put by rolodex) path contacts))

View File

@ -147,9 +147,9 @@
::
%delete
%+ weld
:~ (group-poke [%unbundle path.act])
:~ (contact-hook-poke [%remove path.act])
(group-poke [%unbundle path.act])
(contact-poke [%delete path.act])
(contact-hook-poke [%remove path.act])
==
(delete-metadata path.act)
::
@ -181,21 +181,19 @@
::
:: avatar images
::
:: [%'~groups' %avatar @ *]
:: =/ pax=path `path`t.t.site.url
:: ?~ pax not-found:gen
:: =/ pas `path`(flop pax)
:: ?~ pas not-found:gen
:: =/ pav `path`(flop t.pas)
:: ~& pav+pav
:: ~& name+name
:: =/ contact (contact-scry `path`(weld pav [name]~))
:: ?~ contact not-found:gen
:: ?~ avatar.u.contact not-found:gen
:: =* avatar u.avatar.u.contact
:: =/ decoded (de:base64 q.octs.avatar)
:: ?~ decoded not-found:gen
:: [[200 ['content-type' content-type.avatar]~] `u.decoded]
[%'~groups' %avatar @ *]
=/ =path (flop t.t.site.url)
?~ path not-found:gen
=/ contact (contact-scry `^path`(snoc (flop t.path) name))
?~ contact not-found:gen
?~ avatar.u.contact not-found:gen
?- -.u.avatar.u.contact
%url [[307 ['location' url.u.avatar.u.contact]~] ~]
%octt
=/ max-3-days ['cache-control' 'max-age=259200']
=/ content-type ['content-type' content-type.u.avatar.u.contact]
[[200 [content-type max-3-days ~]] `octs.u.avatar.u.contact]
==
::
[%'~groups' *] (html-response:gen index)
==

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

View File

@ -13,5 +13,6 @@
<script src="/~channel/channel.js"></script>
<script src="/~modulo/session.js"></script>
<script src="/~groups/js/index.js"></script>
<script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -38,18 +38,12 @@
^- (quip card _this)
=/ old !<(state-zero vase)
:_ this(state old)
%+ murn
~(tap by synced.old)
%+ murn ~(tap by synced.old)
|= [=path =ship]
^- (unit card)
=/ =wire
[(scot %p ship) %group path]
=/ =term
?: =(our.bowl ship)
%group-store
%group-hook
?: (~(has by wex.bowl) [wire ship term])
~
=/ =wire [(scot %p ship) %group path]
=/ =term ?:(=(our.bowl ship) %group-store %group-hook)
?: (~(has by wex.bowl) [wire ship term]) ~
`[%pass wire %agent [ship term] %watch [%group path]]
::
++ on-leave on-leave:def
@ -173,10 +167,9 @@
%remove [(update-subscribers [%group pax.diff] diff) state]
::
%unbundle
:_ state(synced (~(del by synced.state) pax.diff))
%+ snoc
(update-subscribers [%group pax.diff] diff)
[%give %kick [%group pax.diff]~ ~]
=/ ship (~(get by synced.state) pax.diff)
?~ ship [~ state]
(poke-group-hook-action [%remove pax.diff])
==
::
++ handle-foreign
@ -185,7 +178,6 @@
?- -.diff
%keys [~ state]
%bundle [~ state]
::
%path
:_ state
?~ pax.diff ~
@ -219,23 +211,26 @@
[(group-poke pax.diff diff)]~
::
%remove
:_ state
?~ pax.diff ~
?~ pax.diff [~ state]
=/ ship (~(get by synced.state) pax.diff)
?~ ship ~
?. =(src.bol u.ship) ~
[(group-poke pax.diff diff)]~
?~ ship [~ state]
?. =(src.bol u.ship) [~ state]
?. (~(has in members.diff) our.bol)
:_ state
[(group-poke pax.diff diff)]~
=/ changes (poke-group-hook-action [%remove pax.diff])
:_ +.changes
%+ welp -.changes
:~ (group-poke pax.diff diff)
(group-poke pax.diff [%unbundle pax.diff])
==
::
%unbundle
?~ pax.diff
[~ state]
?~ pax.diff [~ state]
=/ ship (~(get by synced.state) pax.diff)
?~ ship
[~ state]
?. =(src.bol u.ship)
[~ state]
:_ state(synced (~(del by synced.state) pax.diff))
[(group-poke pax.diff diff)]~
?~ ship [~ state]
?. =(src.bol u.ship) [~ state]
(poke-group-hook-action [%remove pax.diff])
==
::
++ group-poke
@ -262,5 +257,4 @@
?: =(u.shp our.bol)
[%pass wir %agent [our.bol %group-store] %leave ~]~
[%pass wir %agent [u.shp %group-hook] %leave ~]~
::
--

View File

@ -43,9 +43,9 @@
!:
=> |% ::
++ hood-old :: unified old-state
{?($1 $2 $3) lac/(map @tas hood-part-old)} ::
{?($1 $2 $3 $4) lac/(map @tas hood-part-old)} ::
++ hood-1 :: unified state
{$3 lac/(map @tas hood-part)} ::
{$4 lac/(map @tas hood-part)} ::
++ hood-good :: extract specific
=+ hed=$:hood-head
|@ ++ $
@ -140,7 +140,7 @@
`..on-init
::
++ on-save
!>([%3 lac])
!>([%4 lac])
::
++ on-load
|= =old-state=vase
@ -150,7 +150,8 @@
?- -.old-state
%1 ((wrap on-load):from-drum:(help hid) %1)
%2 ((wrap on-load):from-drum:(help hid) %2)
%3 `lac
%3 ((wrap on-load):from-drum:(help hid) %3)
%4 `lac
==
[cards ..on-init]
::

View File

@ -178,8 +178,11 @@ class Channel {
this.lastEventId = e.lastEventId;
let obj = JSON.parse(e.data);
if (obj.response == "poke") {
let funcs = this.outstandingPokes.get(obj.id);
let pokeFuncs = this.outstandingPokes.get(obj.id);
let subFuncs = this.outstandingSubscriptions.get(obj.id);
if (obj.response == "poke" && !!pokeFuncs) {
let funcs = pokeFuncs;
if (obj.hasOwnProperty("ok")) {
funcs["success"]();
} else if (obj.hasOwnProperty("err")) {
@ -189,19 +192,20 @@ class Channel {
}
this.outstandingPokes.delete(obj.id);
} else if (obj.response == "subscribe") {
} else if (obj.response == "subscribe" ||
(obj.response == "poke" && !!subFuncs)) {
let funcs = subFuncs;
// on a response to a subscribe, we only notify the caller on err
//
let funcs = this.outstandingSubscriptions.get(obj.id);
if (obj.hasOwnProperty("err")) {
funcs["err"](obj.err);
this.outstandingSubscriptions.delete(obj.id);
}
} else if (obj.response == "diff") {
let funcs = this.outstandingSubscriptions.get(obj.id);
let funcs = subFuncs;
funcs["event"](obj.json);
} else if (obj.response == "quit") {
let funcs = this.outstandingSubscriptions.get(obj.id);
let funcs = subFuncs;
funcs["quit"](obj);
this.outstandingSubscriptions.delete(obj.id);
} else {

File diff suppressed because one or more lines are too long

View File

@ -114,8 +114,6 @@
`t.t.path
~
?~ target |
~? !.^(? %gu (scot %p our.bowl) %metadata-store (scot %da now.bowl) ~)
%woah-md-s-not-booted ::TODO fallback if needed
%+ lien (groups-from-resource:md %link u.target)
|= =group-path
^- ?

File diff suppressed because one or more lines are too long

View File

@ -331,9 +331,16 @@
?+ mar (on-poke:def mar vas)
::
%noun
?: =(q.vas %flush-limbo)
[~ this(limbo [~ ~])]
[~ this]
?+ q.vas
[~ this]
::
%flush-limbo [~ this(limbo [~ ~])]
::
%reset-warp
=/ rav [%sing %t [%da now.bol] /app/publish/notebooks]
:_ this
[%pass /read/paths %arvo %c %warp our.bol q.byk.bol `rav]~
==
::
%handle-http-request
=+ !<([id=@ta req=inbound-request:eyre] vas)
@ -1702,8 +1709,107 @@
:~ [%give %fact [/primary]~ %publish-primary-delta !>(act)]
[%give %fact [/publishtile]~ %json !>(jon)]
==
:: %groupify
::
%groupify
?. (team:title our.bol src.bol)
~|("action not permitted" !!)
=/ book (~(get by books) our.bol book.act)
?~ book
~|("nonexistent notebook: {<book.act>}" !!)
::
=/ old-write writers.u.book
=/ old-read subscribers.u.book
?> ?=([%'~' ^] old-write)
=/ destroy-old-groups=(list card)
:~ (group-poke [%unbundle old-write])
(group-poke [%unbundle old-read])
(group-hook-poke [%remove old-write])
(group-hook-poke [%remove old-read])
(perm-hook-poke [%remove old-write])
(perm-hook-poke [%remove old-read])
==
::
?~ target.act
:: create new group from subscribers
::
=. writers.u.book (slag 1 writers.u.book)
=. subscribers.u.book writers.u.book
=/ del=notebook-delta [%edit-book our.bol book.act u.book]
:_ state(books (~(put by books) [our.bol book.act] u.book))
%+ weld destroy-old-groups
^- (list card)
:~ [%give %fact [/notebook/[book.act]]~ %publish-notebook-delta !>(del)]
[%give %fact [/primary]~ %publish-primary-delta !>(del)]
%- contact-view-create
:* writers.u.book
(get-subscribers book.act)
title.u.book
description.u.book
==
%- metadata-poke
:* %add
writers.u.book
[%publish /(scot %p our.bol)/[book.act]]
title.u.book
description.u.book
0x0
date-created.u.book
our.bol
==
==
::
?> ?=(^ u.target.act)
=. writers.u.book u.target.act
=. subscribers.u.book u.target.act
=/ group-host=@p (slav %p i.u.target.act)
::
=/ scry-pax :(weld /=group-store/(scot %da now.bol) u.target.act /noun)
=/ old-group=(set @p) (need .^((unit (set @p)) %gx scry-pax))
=/ dif-peeps=(set @p) (~(dif in (get-subscribers book.act)) old-group)
::
=/ del=notebook-delta [%edit-book our.bol book.act u.book]
:_ state(books (~(put by books) [our.bol book.act] u.book))
%+ weld
%+ weld destroy-old-groups
^- (list card)
:~ [%give %fact [/notebook/[book.act]]~ %publish-notebook-delta !>(del)]
[%give %fact [/primary]~ %publish-primary-delta !>(del)]
%- metadata-poke
:* %add
writers.u.book
[%publish /(scot %p our.bol)/[book.act]]
title.u.book
description.u.book
0x0
date-created.u.book
our.bol
==
==
?: ?& inclusive.act
=(group-host our.bol)
==
:: add all subscribers to group
::
[(group-poke [%add dif-peeps u.target.act])]~
:: kick subscribers who are not already in group
::
%+ turn ~(tap in dif-peeps)
|= who=@p
^- card
[%give %kick [/notebook/[book.act]]~ `who]
==
::
++ get-subscribers
|= book=@tas
^- (set @p)
%+ roll ~(val by sup.bol)
|= [[who=@p pax=path] out=(set @p)]
^- (set @p)
?. ?=([%notebook @ ~] pax) out
?. =(book i.t.pax) out
(~(put in out) who)
::
++ get-notebook
|= [host=@p book-name=@tas sty=_state]
^- (unit notebook)
@ -1732,6 +1838,11 @@
[%give %fact [/publishtile]~ %json !>(jon)]
==
::
++ metadata-poke
|= act=metadata-action
^- card
[%pass / %agent [our.bol %metadata-hook] %poke %metadata-action !>(act)]
::
++ emit-metadata
|= del=metadata-delta
^- (list card)
@ -1753,8 +1864,9 @@
::
%remove
=/ app-path [(scot %p author.del) /[book.del]]
=/ group-path (group-from-book app-path)
[(metadata-poke [%remove group-path [%publish app-path]])]~
=/ group-path=(unit path) (group-from-book app-path)
?~ group-path ~
[(metadata-poke [%remove u.group-path [%publish app-path]])]~
==
::
++ add
@ -1762,11 +1874,6 @@
^- (list card)
[(metadata-poke [%add group-path [%publish app-path] metadata])]~
::
++ metadata-poke
|= act=metadata-action
^- card
[%pass / %agent [our.bol %metadata-hook] %poke %metadata-action !>(act)]
::
++ metadata-scry
|= [group-path=path app-path=path]
^- (unit metadata)
@ -1785,13 +1892,12 @@
::
++ group-from-book
|= app-path=path
^- path
^- (unit path)
?. .^(? %gu (scot %p our.bol) %metadata-store (scot %da now.bol) ~)
?: ?=([@ ^] app-path)
~& [%assuming-ported-legacy-publish app-path]
[%'~' app-path]
~| [%weird-publish app-path]
!!
`[%'~' app-path]
~&([%weird-publish app-path] ~)
=/ resource-indices
.^ (jug resource group-path)
%gy
@ -1800,8 +1906,12 @@
(scot %da now.bol)
/resource-indices
==
=/ groups=(set path) (~(got by resource-indices) [%publish app-path])
(snag 0 ~(tap in groups))
=/ groups=(unit (set path))
(~(get by resource-indices) [%publish app-path])
?~ groups ~
=/ group-paths ~(tap in u.groups)
?~ group-paths ~
`i.group-paths
--
::
++ metadata-hook-poke

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,96 @@
/- *s3
/+ s3-json, default-agent, verb, dbug
~% %s3-top ..is ~
|%
+$ card card:agent:gall
+$ versioned-state
$% state-zero
==
::
+$ state-zero [%0 =credentials =configuration]
--
::
=| state-zero
=* state -
::
%- agent:dbug
%+ verb |
^- agent:gall
~% %s3-agent-core ..card ~
|_ =bowl:gall
+* this .
def ~(. (default-agent this %|) bowl)
::
++ on-init on-init:def
++ on-save !>(state)
++ on-load
|= old-vase=vase
[~ this(state !<(state-zero old-vase))]
::
++ on-poke
~/ %s3-poke
|= [=mark =vase]
^- (quip card _this)
|^
?> (team:title our.bowl src.bowl)
=^ cards state
?+ mark (on-poke:def mark vase)
%s3-action (poke-action !<(action vase))
==
[cards this]
::
++ poke-action
|= act=action
^- (quip card _state)
:- [%give %fact [/all]~ %s3-update !>(act)]~
?- -.act
%set-endpoint
state(endpoint.credentials endpoint.act)
::
%set-access-key-id
state(access-key-id.credentials access-key-id.act)
::
%set-secret-access-key
state(secret-access-key.credentials secret-access-key.act)
::
%set-current-bucket
%_ state
current-bucket.configuration bucket.act
buckets.configuration (~(put in buckets.configuration) bucket.act)
==
::
%add-bucket
state(buckets.configuration (~(put in buckets.configuration) bucket.act))
::
%remove-bucket
state(buckets.configuration (~(del in buckets.configuration) bucket.act))
==
--
::
++ on-watch
~/ %s3-watch
|= =path
^- (quip card _this)
|^
?> (team:title our.bowl src.bowl)
=/ cards=(list card)
?+ path (on-watch:def path)
[%all ~]
:~ (give %s3-update !>([%credentials credentials]))
(give %s3-update !>([%configuration configuration]))
==
==
[cards this]
::
++ give
|= =cage
^- card
[%give %fact ~ cage]
--
::
++ on-leave on-leave:def
++ on-peek on-peek:def
++ on-agent on-agent:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,10 @@
:: s3-store|add-bucket: add new bucket to S3 store
::
/- *s3
:- %say
|= $: [now=@da eny=@uvJ =beak]
[[bucket=@t ~] ~]
==
:- %s3-action
^- action
[%add-bucket bucket]

View File

@ -0,0 +1,10 @@
:: s3-store|remove-bucket: remove bucket from S3 store
::
/- *s3
:- %say
|= $: [now=@da eny=@uvJ =beak]
[[bucket=@t ~] ~]
==
:- %s3-action
^- action
[%remove-bucket bucket]

View File

@ -0,0 +1,10 @@
:: s3-store|set-access-key-id: set S3 access key ID
::
/- *s3
:- %say
|= $: [now=@da eny=@uvJ =beak]
[[access-key-id=@t ~] ~]
==
:- %s3-action
^- action
[%set-access-key-id access-key-id]

View File

@ -0,0 +1,10 @@
:: s3-store|set-current-bucket: set current bucket for S3
::
/- *s3
:- %say
|= $: [now=@da eny=@uvJ =beak]
[[bucket=@t ~] ~]
==
:- %s3-action
^- action
[%set-current-bucket bucket]

View File

@ -0,0 +1,10 @@
:: s3-store|set-endpoint: set S3 endpoint
::
/- *s3
:- %say
|= $: [now=@da eny=@uvJ =beak]
[[endpoint=@t ~] ~]
==
:- %s3-action
^- action
[%set-endpoint endpoint]

View File

@ -0,0 +1,10 @@
:: s3-store|set-secret-access-key: set S3 secret access key
::
/- *s3
:- %say
|= $: [now=@da eny=@uvJ =beak]
[[secret-access-key=@t ~] ~]
==
:- %s3-action
^- action
[%set-secret-access-key secret-access-key]

View File

@ -41,9 +41,16 @@
(fall ((ot output+(ar dank) ~) a) ~)
::
++ lett
=, enjs:format
|= =letter
^- json
=, enjs:format
=; result=(each json tang)
?- -.result
%& p.result
%| (frond %text s+'[[json rendering error]]')
==
%- mule
|.
?- -.letter
%text
(frond %text s+text.letter)

View File

@ -1,10 +1,22 @@
/- *contact-view
/- *contact-view, *contact-hook
/+ base64
|%
++ nu :: parse number as hex
|= jon/json
?> ?=({$s *} jon)
(rash p.jon hex)
::
++ hook-update-to-json
|= upd=contact-hook-update
=, enjs:format
^- json
%+ frond %contact-hook-update
%- pairs
%+ turn ~(tap by synced.upd)
|= [pax=^path shp=^ship]
^- [cord json]
[(spat pax) s+(scot %p shp)]
::
++ rolodex-to-json
|= rolo=rolodex
=, enjs:format
@ -15,38 +27,34 @@
|= [pax=^path =contacts]
^- [cord json]
:- (spat pax)
(contacts-to-json contacts)
(contacts-to-json pax contacts)
::
++ contacts-to-json
|= con=contacts
|= [=path con=contacts]
^- json
=, enjs:format
%- pairs
%- pairs:enjs:format
%+ turn ~(tap by con)
|= [shp=^ship =contact]
|= [=ship =contact]
^- [cord json]
:- (crip (slag 1 (scow %p shp)))
(contact-to-json contact)
[(crip (slag 1 (scow %p ship))) (contact-to-json path ship contact)]
::
++ contact-to-json
|= con=contact
|= [=path =ship con=contact]
^- json
=, enjs:format
%- pairs
%- pairs:enjs:format
:~ [%nickname s+nickname.con]
[%email s+email.con]
[%phone s+phone.con]
[%website s+website.con]
[%notes s+notes.con]
[%color s+(scot %ux color.con)]
[%avatar s+'TODO']
[%avatar (avatar-to-json path ship avatar.con)]
==
::
++ edit-to-json
|= edit=edit-field
|= [=path =ship edit=edit-field]
^- json
=, enjs:format
%+ frond -.edit
%+ frond:enjs:format -.edit
?- -.edit
%nickname s+nickname.edit
%email s+email.edit
@ -54,7 +62,25 @@
%website s+website.edit
%notes s+notes.edit
%color s+(scot %ux color.edit)
%avatar s+'TODO'
%avatar (avatar-to-json path ship avatar.edit)
==
::
++ avatar-to-json
|= [=path =ship avat=(unit avatar)]
^- json
?~ avat ~
?- -.u.avat
%octt
:- %s
%- crip
%- zing
:~ "/~groups/avatar"
(trip (spat path))
"/"
(trip (scot %p ship))
==
::
%url s+url.u.avat
==
::
++ update-to-json
@ -73,7 +99,7 @@
%- pairs
:~ [%path (path path.upd)]
[%ship (ship ship.upd)]
[%contact (contact-to-json contact.upd)]
[%contact (contact-to-json path.upd ship.upd contact.upd)]
==
?: ?=(%remove -.upd)
:- %remove
@ -86,7 +112,7 @@
%- pairs
:~ [%path (path path.upd)]
[%ship (ship ship.upd)]
[%edit-field (edit-to-json edit-field.upd)]
[%edit-field (edit-to-json path.upd ship.upd edit-field.upd)]
==
[*@t *^json]
==
@ -179,10 +205,31 @@
==
::
++ avat
%- ot:dejs:format
:~ [%content-type so:dejs:format]
[%octs octet]
|= jon=json
^- avatar
|^
=/ =avatar (parse-json jon)
?- -.avatar
%url avatar
%octt
=. octs.avatar (need (de:base64 q.octs.avatar))
avatar
==
::
++ parse-json
%- of:dejs:format
:~ [%octt octt]
[%url url]
==
::
++ octt
%- ot:dejs:format
:~ [%content-type so:dejs:format]
[%octs octet]
==
::
++ url so:dejs:format
--
::
++ cont
%- ot:dejs:format

View File

@ -118,6 +118,7 @@
%link-view
%metadata-store
%metadata-hook
%s3-store
==
::
++ deft-fish :: default connects
@ -155,6 +156,7 @@
=+ (~(gut by bin) ost *source)
=* dev -
|_ {moz/(list card:agent:gall) biz/(list dill-blit:dill)}
+* this .
++ diff-sole-effect-phat :: app event
|= {way/wire fec/sole-effect}
=< se-abet =< se-view
@ -223,7 +225,7 @@
==
::
++ on-load
|= ver=?(%1 %2)
|= ver=?(%1 %2 %3)
?- ver
%1
=< se-abet =< se-view
@ -237,7 +239,8 @@
=< (se-born %home %link-store)
=< (se-born %home %link-proxy-hook)
=< (se-born %home %link-listen-hook)
(se-born %home %link-view)
=< (se-born %home %link-view)
(se-born %home %s3-store)
::
%2
=< se-abet =< se-view
@ -250,7 +253,22 @@
=< (se-born %home %link-store)
=< (se-born %home %link-proxy-hook)
=< (se-born %home %link-listen-hook)
(se-born %home %link-view)
=< (se-born %home %link-view)
(se-born %home %s3-store)
::
%3
=< se-abet =< se-view
=< (se-emit %pass /kiln %arvo %g %sear ~wisrut-nocsub)
=< (se-born %home %metadata-store)
=< (se-born %home %metadata-hook)
=< (se-born %home %contact-store)
=< (se-born %home %contact-hook)
=< (se-born %home %contact-view)
=< (se-born %home %link-store)
=< (se-born %home %link-proxy-hook)
=< (se-born %home %link-listen-hook)
=< (se-born %home %link-view)
(se-born %home %s3-store)
==
::
++ reap-phat :: ack connect
@ -329,23 +347,70 @@
[%give %fact ~[/drum] %dill-blit !>(dill-blit)]
::
++ se-adit :: update servers
^+ .
:: ensure dojo connects after talk
=* dojo-on-top |=([a=* b=*] |(=(%dojo a) &(!=(%dojo b) (aor a b))))
%+ roll (sort ~(tap in ray) dojo-on-top)
=< .(con +>)
|: $:{wel/well:gall con/_..se-adit} ^+ con
=. +>.$ con
=+ hig=(~(get by fur) q.wel)
?: &(?=(^ hig) |(?=(~ u.hig) =(p.wel syd.u.u.hig))) +>.$
=. +>.$ (se-text "activated app {(trip p.wel)}/{(trip q.wel)}")
%- se-emit(fur (~(put by fur) q.wel ~))
^+ this
|^
=/ servers=(list well:gall)
(sort ~(tap in ray) sort-by-priorities)
|-
?~ servers
this
=/ wel=well:gall
i.servers
=/ =wire [%drum p.wel q.wel ~]
[%pass wire %arvo %g %conf [our.hid q.wel] our.hid p.wel]
=/ hig=(unit (unit server))
(~(get by fur) q.wel)
?: &(?=(^ hig) |(?=(~ u.hig) =(p.wel syd.u.u.hig)))
$(servers t.servers)
=. fur
(~(put by fur) q.wel ~)
=. this
(se-text "activated app {(trip p.wel)}/{(trip q.wel)}")
=. this
%- se-emit
[%pass wire %arvo %g %conf [our.hid q.wel] our.hid p.wel]
$(servers t.servers)
::
++ priorities
^- (list (set @))
:~
:: set up stores with priority: depended on, but never depending
%- sy
:~ %permission-store
%chat-store
%contact-store
%group-store
%link-store
%invite-store
%metadata-store
==
:: ensure chat-cli can sub to invites
(sy ~[%chat-hook])
==
++ sort-by-priorities
=/ priorities priorities
|= [[desk a=term] [desk b=term]]
^- ?
?~ priorities
(aor a b)
=* priority i.priorities
?: &((~(has in priority) a) (~(has in priority) b))
(aor a b)
?: (~(has in priority) a)
%.y
?: (~(has in priority) b)
%.n
$(priorities t.priorities)
--
::
++ se-adze :: update connections
^+ .
%+ roll ~(tap in eel)
%+ roll
%+ sort
~(tap in eel)
|= [[@ a=term] [@ b=term]]
?: =(a %dojo) %.n
?: =(b %dojo) %.y
(aor a b)
=< .(con +>)
|: $:{gil/gill:gall con/_.} ^+ con
=. +>.$ con

50
pkg/arvo/lib/s3-json.hoon Normal file
View File

@ -0,0 +1,50 @@
/- *s3
|%
++ json-to-action
|= =json
^- action
=, format
|^ (parse-json json)
++ parse-json
%- of:dejs
:~ [%set-endpoint so:dejs]
[%set-access-key-id so:dejs]
[%set-secret-access-key so:dejs]
[%add-bucket so:dejs]
[%remove-bucket so:dejs]
[%set-current-bucket so:dejs]
==
--
::
++ update-to-json
|= upd=update
^- json
=, format
%+ frond:enjs %s3-update
%- pairs:enjs
:~ ?- -.upd
%set-current-bucket [%'setCurrentBucket' s+bucket.upd]
%add-bucket [%'addBucket' s+bucket.upd]
%remove-bucket [%'removeBucket' s+bucket.upd]
%set-endpoint [%'setEndpoint' s+endpoint.upd]
%set-access-key-id [%'setAccessKeyId' s+access-key-id.upd]
%set-secret-access-key
[%'setSecretAccessKey' s+secret-access-key.upd]
::
%credentials
:- %credentials
%- pairs:enjs
:~ [%endpoint s+endpoint.credentials.upd]
[%'accessKeyId' s+access-key-id.credentials.upd]
[%'secretAccessKey' s+secret-access-key.credentials.upd]
==
::
%configuration
:- %configuration
%- pairs:enjs
:~ [%buckets a+(turn ~(tap in buckets.configuration.upd) |=(a=@t s+a))]
[%'currentBucket' s+current-bucket.configuration.upd]
==
==
==
--

View File

@ -0,0 +1,13 @@
/+ *contact-json
|_ upd=contact-hook-update
++ grow
|%
++ json (hook-update-to-json upd)
--
::
++ grab
|%
++ noun contact-hook-update
--
::
--

View File

@ -32,6 +32,7 @@
subscribe+subscribe
unsubscribe+unsubscribe
read+read
groupify+groupify
==
::
++ new-book
@ -114,6 +115,12 @@
book+so
note+so
==
++ groupify
%- ot
:~ book+so
target+(mu pa)
inclusive+bo
==
++ group-info
%- ot
:~ group-path+pa

View File

@ -0,0 +1,8 @@
/+ *s3-json
|_ act=action
++ grab
|%
++ noun action
++ json json-to-action
--
--

View File

@ -0,0 +1,12 @@
/+ *s3-json
|_ upd=update
++ grow
|%
++ json (update-to-json upd)
--
::
++ grab
|%
++ noun update
--
--

View File

@ -12,4 +12,7 @@
::
[%remove =path]
==
::
+$ synced (map path ship)
+$ contact-hook-update [%initial =synced]
--

View File

@ -1,10 +1,11 @@
/- *identity
|%
+$ rolodex (map path contacts)
::
+$ contacts (map ship contact)
::
+$ avatar [content-type=@t octs=[p=@ud q=@t]]
+$ rolodex (map path contacts)
+$ contacts (map ship contact)
+$ avatar
$% [%octt content-type=@t octs=[p=@ud q=@t]]
[%url url=@t]
==
::
+$ contact
$: nickname=@t

View File

@ -25,6 +25,8 @@
[%unsubscribe who=@p book=@tas]
::
[%read who=@p book=@tas note=@tas]
::
[%groupify book=@tas target=(unit path) inclusive=?]
==
::
+$ comment comment-3

27
pkg/arvo/sur/s3.hoon Normal file
View File

@ -0,0 +1,27 @@
|%
+$ credentials
$: endpoint=@t
access-key-id=@t
secret-access-key=@t
==
::
+$ configuration
$: buckets=(set @t)
current-bucket=@t
==
::
+$ action
$% [%set-endpoint endpoint=@t]
[%set-access-key-id access-key-id=@t]
[%set-secret-access-key secret-access-key=@t]
[%add-bucket bucket=@t]
[%remove-bucket bucket=@t]
[%set-current-bucket bucket=@t]
==
::
+$ update
$% [%credentials =credentials]
[%configuration =configuration]
action
==
--

View File

@ -12085,6 +12085,7 @@
^- (list term)
?+ typ ~
{$hold *} $(typ ~(repo ut typ))
{$hint *} $(typ ~(repo ut typ))
{$core *}
%- zing
%+ turn ~(tap by q.r.q.typ)

View File

@ -78,7 +78,7 @@ instance FromNoun H.StdMethod where
-- Http Server Configuration ---------------------------------------------------
newtype PEM = PEM { unPEM :: Cord }
newtype PEM = PEM { unPEM :: Wain }
deriving newtype (Eq, Ord, Show, ToNoun, FromNoun)
type Key = PEM

View File

@ -24,6 +24,7 @@ type Life = Word -- Number of Azimoth key revs.
type Bloq = Atom -- TODO
type Oath = Atom -- Signature
-- Parsed URLs -----------------------------------------------------------------
type Host = Each Turf Ipv4
@ -169,7 +170,7 @@ data HttpServerReq = HttpServerReq
data HttpClientEv
= HttpClientEvReceive (KingId, ()) ServerId HttpEvent
| HttpClientEvBorn (KingId, ()) ()
| HttpClientEvCrud Path Cord Tang
| HttpClientEvCrud Path Noun
deriving (Eq, Ord, Show)
data HttpServerEv
@ -178,7 +179,7 @@ data HttpServerEv
| HttpServerEvRequestLocal (ServId, UD, UD, ()) HttpServerReq
| HttpServerEvLive (ServId, ()) Port (Maybe Port)
| HttpServerEvBorn (KingId, ()) ()
| HttpServerEvCrud Path Cord Tang
| HttpServerEvCrud Path Noun
deriving (Eq, Ord, Show)
deriveNoun ''Address
@ -193,7 +194,7 @@ deriveNoun ''HttpServerReq
data AmesEv
= AmesEvHear () AmesDest Bytes
| AmesEvHole () AmesDest Bytes
| AmesEvCrud Path Cord Tang
| AmesEvCrud Path Noun
deriving (Eq, Ord, Show)
deriveNoun ''AmesEv
@ -202,10 +203,10 @@ deriveNoun ''AmesEv
-- Arvo Events -----------------------------------------------------------------
data ArvoEv
= ArvoEvWhom () Ship
| ArvoEvWack () Word512
= ArvoEvWhom () Ship
| ArvoEvWack () Word512
| ArvoEvWarn Path Noun
| ArvoEvCrud Path Cord Tang
| ArvoEvCrud Path Noun
| ArvoEvVeer Atom Noun
deriving (Eq, Ord, Show)
@ -216,7 +217,7 @@ deriveNoun ''ArvoEv
data BoatEv
= BoatEvBoat () ()
| BoatEvCrud Path Cord Tang
| BoatEvCrud Path Noun
deriving (Eq, Ord, Show)
deriveNoun ''BoatEv
@ -227,7 +228,7 @@ deriveNoun ''BoatEv
data BehnEv
= BehnEvWake () ()
| BehnEvBorn (KingId, ()) ()
| BehnEvCrud Path Cord Tang
| BehnEvCrud Path Noun
deriving (Eq, Ord, Show)
deriveNoun ''BehnEv
@ -237,7 +238,7 @@ deriveNoun ''BehnEv
data NewtEv
= NewtEvBorn (KingId, ()) ()
| NewtEvCrud Path Cord Tang
| NewtEvCrud Path Noun
deriving (Eq, Ord, Show)
deriveNoun ''NewtEv
@ -247,7 +248,7 @@ deriveNoun ''NewtEv
data SyncEv
= SyncEvInto (Nullable (KingId, ())) Desk Bool [(Path, Maybe Mime)]
| SyncEvCrud Path Cord Tang
| SyncEvCrud Path Noun
deriving (Eq, Ord, Show)
deriveNoun ''SyncEv
@ -278,7 +279,7 @@ data TermEv
| TermEvBlew (UD, ()) Word Word
| TermEvBoot (UD, ()) Bool LegacyBootEvent
| TermEvHail (UD, ()) ()
| TermEvCrud Path Cord Tang
| TermEvCrud Path Noun
deriving (Eq, Show)
deriveNoun ''LegacyBootEvent

View File

@ -8,7 +8,7 @@ module Urbit.Noun.Conversions
, Bytes(..), Octs(..), File(..)
, Cord(..), Knot(..), Term(..), Tape(..), Tour(..)
, BigTape(..), BigCord(..)
, Wall, Each(..)
, Wain(..), Wall, Each(..)
, UD(..), UV(..), UW(..), cordToUW
, Mug(..), Path(..), EvilPath(..), Ship(..)
, Lenient(..), pathToFilePath, filePathToPath
@ -442,6 +442,20 @@ instance FromNoun Tape where
Right tx -> pure (Tape tx)
-- Wain -- List of Lines -------------------------------------------------------
newtype Wain = Wain { unWain :: Text }
deriving newtype (Eq, Ord, Show, IsString, NFData)
instance ToNoun Wain where
toNoun (Wain t) = toNoun (Cord <$> lines t)
instance FromNoun Wain where
parseNoun n = named "Wain" $ do
tx :: [Cord] <- parseNoun n
pure $ Wain $ unlines (unCord <$> tx)
-- Wall -- Text Lines ----------------------------------------------------------
type Wall = [Tape]

View File

@ -33,6 +33,7 @@ import Urbit.Vere.Pier.Types
import Data.Binary.Builder (Builder, fromByteString)
import Data.Bits (shiftL, (.|.))
import Data.PEM (pemParseBS, pemWriteBS)
import Network.Socket (SockAddr(..))
import System.Directory (doesFileExist, removeFile)
import System.Random (randomIO)
@ -216,6 +217,9 @@ writePortsFile f = writeFile f . encodeUtf8 . portsFileText
cordBytes :: Cord -> ByteString
cordBytes = encodeUtf8 . unCord
wainBytes :: Wain -> ByteString
wainBytes = encodeUtf8 . unWain
pass :: Monad m => m ()
pass = pure ()
@ -499,14 +503,22 @@ httpServerPorts fak = do
pure (PortsToTry { .. })
parseCerts :: ByteString -> Maybe (ByteString, [ByteString])
parseCerts bs = do
pems <- pemParseBS bs & either (const Nothing) Just
case pems of
[] -> Nothing
p:ps -> pure (pemWriteBS p, pemWriteBS <$> ps)
startServ :: (HasPierConfig e, HasLogFunc e, HasNetworkConfig e)
=> Bool -> HttpServerConf -> (Ev -> STM ())
-> RIO e Serv
startServ isFake conf plan = do
logDebug "startServ"
let tls = hscSecure conf <&> \(PEM key, PEM cert) ->
(W.tlsSettingsMemory (cordBytes cert) (cordBytes key))
let tls = do (PEM key, PEM certs) <- hscSecure conf
(cert, chain) <- parseCerts (wainBytes certs)
pure $ W.tlsSettingsChainMemory cert chain $ wainBytes key
sId <- io $ ServId . UV . fromIntegral <$> (randomIO :: IO Word32)
liv <- newTVarIO emptyLiveReqs

View File

@ -67,6 +67,7 @@ dependencies:
- network
- optparse-applicative
- para
- pem
- pretty-show
- primitive
- process

View File

@ -71,7 +71,7 @@ h2 {
}
.clamp-attachment {
overflow: scroll;
overflow: auto;
max-height: 10em;
max-width: 100%;
}
@ -169,6 +169,15 @@ h2 {
border-radius: 100%;
}
.shadow-6 {
box-shadow: 2px 4px 20px rgba(0, 0, 0, 0.25);
}
.brt2 {
border-radius: 0.25rem 0.25rem 0 0;
}
.green3 {
color: #7ea899;
}
@ -363,6 +372,9 @@ pre.CodeMirror-placeholder.CodeMirror-line-like { color: var(--gray); }
.b--white-d {
border-color: #fff;
}
.b--green2-d {
border-color: #2aa779;
}
.bb-d {
border-bottom-width: 1px;
border-bottom-style: solid;

View File

@ -72,7 +72,7 @@ class UrbitApi {
addPendingMessage(msg) {
if (store.state.pendingMessages.has(msg.path)) {
store.state.pendingMessages.get(msg.path).push(msg.envelope);
store.state.pendingMessages.get(msg.path).unshift(msg.envelope);
} else {
store.state.pendingMessages.set(msg.path, [msg.envelope]);
}

View File

@ -22,6 +22,37 @@ function getNumPending(props) {
return result;
}
const ACTIVITY_TIMEOUT = 60000; // a minute
const DEFAULT_BACKLOG_SIZE = 300;
function scrollIsAtTop(container) {
if ((navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")) ||
navigator.userAgent.includes("Firefox")
) {
return container.scrollTop === 0;
} else if (navigator.userAgent.includes("Safari")) {
return container.scrollHeight + Math.round(container.scrollTop) <=
container.clientHeight + 10;
} else {
return false;
}
}
function scrollIsAtBottom(container) {
if ((navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")) ||
navigator.userAgent.includes("Firefox")
) {
return container.scrollHeight - Math.round(container.scrollTop) <=
container.clientHeight + 10;
} else if (navigator.userAgent.includes("Safari")) {
return container.scrollTop === 0;
} else {
return false;
}
}
export class ChatScreen extends Component {
constructor(props) {
super(props);
@ -29,9 +60,10 @@ export class ChatScreen extends Component {
this.state = {
numPages: 1,
scrollLocked: false,
read: props.read,
active: true,
// only for FF
lastScrollHeight: null,
scrollBottom: true
};
this.hasAskedForMessages = false;
@ -41,6 +73,12 @@ export class ChatScreen extends Component {
this.onScroll = this.onScroll.bind(this);
this.unreadMarker = null;
this.scrolledToMarker = false;
this.setUnreadMarker = this.setUnreadMarker.bind(this);
this.activityTimeout = true;
this.handleActivity = this.handleActivity.bind(this);
this.setInactive = this.setInactive.bind(this);
moment.updateLocale('en', {
calendar: {
@ -56,10 +94,72 @@ export class ChatScreen extends Component {
}
componentDidMount() {
this.askForMessages();
this.scrollToBottom();
document.addEventListener("mousemove", this.handleActivity, false);
document.addEventListener("mousedown", this.handleActivity, false);
document.addEventListener("keypress", this.handleActivity, false);
document.addEventListener("touchmove", this.handleActivity, false);
this.activityTimeout = setTimeout(this.setInactive, ACTIVITY_TIMEOUT);
}
componentWillUnmount() {
document.removeEventListener("mousemove", this.handleActivity, false);
document.removeEventListener("mousedown", this.handleActivity, false);
document.removeEventListener("keypress", this.handleActivity, false);
document.removeEventListener("touchmove", this.handleActivity, false);
if(this.activityTimeout) {
clearTimeout(this.activityTimeout);
}
}
handleActivity() {
if(!this.state.active) {
this.setState({ active: true });
}
if(this.activityTimeout) {
clearTimeout(this.activityTimeout);
}
this.activityTimeout = setTimeout(this.setInactive, ACTIVITY_TIMEOUT);
}
setInactive() {
this.activityTimeout = null;
this.setState({ active: false, scrollLocked: true });
}
receivedNewChat() {
const { props } = this;
this.hasAskedForMessages = false;
this.unreadMarker = null;
this.scrolledToMarker = false;
this.setState({ read: props.read });
const unread = props.length - props.read;
const unreadUnloaded = unread - props.envelopes.length;
if(unreadUnloaded + 20 > DEFAULT_BACKLOG_SIZE) {
this.askForMessages(unreadUnloaded + 20);
} else {
this.askForMessages(DEFAULT_BACKLOG_SIZE);
}
if(props.read === props.length){
this.scrolledToMarker = true;
this.setState(
{
scrollLocked: false,
},
() => {
this.scrollToBottom();
}
);
} else {
this.setState({ scrollLocked: true, numPages: Math.ceil(unread/100) });
}
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
@ -68,18 +168,7 @@ export class ChatScreen extends Component {
prevProps.match.params.station !== props.match.params.station ||
prevProps.match.params.ship !== props.match.params.ship
) {
this.hasAskedForMessages = false;
if (props.envelopes.length < 100) {
this.askForMessages();
}
this.setState(
{ scrollLocked: false },
() => {
this.scrollToBottom();
}
);
this.receivedNewChat();
} else if (props.chatInitialized &&
!(props.station in props.inbox) &&
(!!props.chatSynced && !(props.station in props.chatSynced))) {
@ -89,21 +178,26 @@ export class ChatScreen extends Component {
props.envelopes.length >= prevProps.envelopes.length + 10
) {
this.hasAskedForMessages = false;
} else if(props.length !== prevProps.length &&
prevProps.length === prevState.read &&
state.active
) {
this.setState({ read: props.length });
this.props.api.chat.read(this.props.station);
}
if(!prevProps.chatInitialized && props.chatInitialized) {
this.receivedNewChat();
}
// FF logic
if (
navigator.userAgent.includes("Firefox") &&
(props.length !== prevProps.length ||
props.envelopes.length !== prevProps.envelopes.length ||
getNumPending(props) !== this.lastNumPending ||
state.numPages !== prevState.numPages)
) {
if(state.scrollBottom) {
setTimeout(() => {
this.scrollToBottom();
})
} else {
this.scrollToBottom();
if(navigator.userAgent.includes("Firefox")) {
this.recalculateScrollTop();
}
@ -111,16 +205,9 @@ export class ChatScreen extends Component {
}
}
askForMessages() {
askForMessages(size) {
const { props, state } = this;
if (props.envelopes.length === 0) {
setTimeout(() => {
this.askForMessages();
}, 500);
return;
}
if (
props.envelopes.length >= props.length ||
this.hasAskedForMessages ||
@ -132,7 +219,7 @@ export class ChatScreen extends Component {
let start =
props.length - props.envelopes[props.envelopes.length - 1].number;
if (start > 0) {
let end = start + 300 < props.length ? start + 300 : props.length;
const end = start + size < props.length ? start + size : props.length;
this.hasAskedForMessages = true;
props.subscription.fetchMessages(start + 1, end, props.station);
}
@ -161,80 +248,51 @@ export class ChatScreen extends Component {
}
onScroll(e) {
if (
(navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")) ||
navigator.userAgent.includes("Firefox")
) {
// Google Chrome and Firefox
if (e.target.scrollTop === 0) {
// Save scroll position for FF
if (navigator.userAgent.includes('Firefox')) {
this.setState({
lastScrollHeight: e.target.scrollHeight
})
if(scrollIsAtTop(e.target)) {
// Save scroll position for FF
if (navigator.userAgent.includes('Firefox')) {
this.setState({
lastScrollHeight: e.target.scrollHeight
});
}
this.setState(
{
numPages: this.state.numPages + 1,
scrollLocked: true
},
() => {
this.askForMessages(DEFAULT_BACKLOG_SIZE);
}
this.setState(
{
numPages: this.state.numPages + 1,
scrollLocked: true
},
() => {
this.askForMessages();
}
);
} else if (
e.target.scrollHeight - Math.round(e.target.scrollTop) ===
e.target.clientHeight
) {
this.setState({
numPages: 1,
scrollLocked: false,
scrollBottom: true
});
} else if (navigator.userAgent.includes('Firefox')) {
this.setState({ scrollBottom: false });
}
} else if (navigator.userAgent.includes("Safari")) {
// Safari
if (e.target.scrollTop === 0) {
this.setState({
numPages: 1,
scrollLocked: false
});
} else if (
e.target.scrollHeight + Math.round(e.target.scrollTop) <=
e.target.clientHeight + 10
) {
this.setState(
{
numPages: this.state.numPages + 1,
scrollLocked: true
},
() => {
this.askForMessages();
}
);
}
} else {
console.log("Your browser is not supported.");
);
} else if (scrollIsAtBottom(e.target)) {
this.dismissUnread();
this.setState({
numPages: 1,
scrollLocked: false
});
}
if(!!this.unreadMarker) {
if(
!navigator.userAgent.includes('Firefox') &&
e.target.scrollHeight - e.target.scrollTop - (e.target.clientHeight * 1.5) + this.unreadMarker.offsetTop > 50
) {
this.props.api.chat.read(this.props.station);
} else if(navigator.userAgent.includes('Firefox') &&
this.unreadMarker.offsetTop - e.target.scrollTop - (e.target.clientHeight / 2) > 0
) {
this.props.api.chat.read(this.props.station);
}
}
setUnreadMarker(ref) {
if(ref && !this.scrolledToMarker) {
this.setState({ scrollLocked: true }, () => {
ref.scrollIntoView({ block: 'center' });
if(ref.offsetParent &&
scrollIsAtBottom(ref.offsetParent)) {
this.dismissUnread();
this.setState({
numPages: 1,
scrollLocked: false
});
}
});
this.scrolledToMarker = true;
}
this.unreadMarker = ref;
}
dismissUnread() {
this.props.api.chat.read(this.props.station);
}
chatWindow(unread) {
@ -288,14 +346,15 @@ export class ChatScreen extends Component {
paddingTop={paddingTop}
paddingBot={paddingBot}
pending={!!msg.pending}
group={props.association}
/>
);
if(unread > 0 && i === unread) {
if(unread > 0 && i === unread - 1) {
return (
<>
{messageElem}
<div key={'unreads'+ msg.uid} ref={ref => (this.unreadMarker = ref)} className="mv2 green2 flex items-center f9">
<hr className="ma0 w2 b--green2 bt-0" />
<div key={'unreads'+ msg.uid} ref={this.setUnreadMarker} className="mv2 green2 flex items-center f9">
<hr className="dn-s ma0 w2 b--green2 bt-0" />
<p className="mh4">
New messages below
</p>
@ -327,7 +386,7 @@ export class ChatScreen extends Component {
if (navigator.userAgent.includes("Firefox")) {
return (
<div className="overflow-y-scroll h-100" onScroll={this.onScroll} ref={e => { this.scrollContainer = e; }}>
<div className="relative overflow-y-scroll h-100" onScroll={this.onScroll} ref={e => { this.scrollContainer = e; }}>
<div
className="bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse"
style={{ resize: "vertical" }}
@ -358,7 +417,7 @@ export class ChatScreen extends Component {
else {
return (
<div
className="overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse"
className="overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse relative"
style={{ height: "100%", resize: "vertical" }}
onScroll={this.onScroll}
>
@ -409,10 +468,13 @@ export class ChatScreen extends Component {
: props.station.substr(1);
}
const unread = props.length - props.read;
const unread = props.length - state.read;
const unreadMsg = unread > 0 && messages[unread - 1];
const showUnreadNotice = props.length !== props.read && props.read === state.read;
return (
<div
key={props.station}
@ -448,11 +510,11 @@ export class ChatScreen extends Component {
api={props.api}
/>
</div>
{ !!unreadMsg && (
{ !!unreadMsg && showUnreadNotice && (
<UnreadNotice
unread={unread}
unreadMsg={unreadMsg}
onRead={() => props.api.chat.read(props.station)}
onRead={() => this.dismissUnread()}
/>
) }
{this.chatWindow(unread)}
@ -464,6 +526,8 @@ export class ChatScreen extends Component {
ownerContact={ownerContact}
envelopes={props.envelopes}
contacts={props.contacts}
onEnter={() => this.setState({ scrollLocked: false })}
s3={props.s3}
placeholder="Message..."
/>
</div>

View File

@ -1,129 +1,37 @@
import React, { Component } from 'react';
import _ from 'lodash';
import moment from 'moment';
import Mousetrap from 'mousetrap';
import cn from 'classnames';
import { UnControlled as CodeEditor } from 'react-codemirror2'
import { UnControlled as CodeEditor } from 'react-codemirror2';
import CodeMirror from 'codemirror';
import 'codemirror/mode/markdown/markdown';
import 'codemirror/addon/display/placeholder';
import { Sigil } from '/components/lib/icons/sigil';
import { ShipSearch } from '/components/lib/ship-search';
import { S3Upload } from '/components/lib/s3-upload';
import { uuid, uxToHex, hexToRgba } from '/lib/util';
import { uxToHex } from '/lib/util';
const MARKDOWN_CONFIG = {
name: "markdown",
name: 'markdown',
tokenTypeOverrides: {
header: "presentation",
quote: "presentation",
list1: "presentation",
list2: "presentation",
list3: "presentation",
hr: "presentation",
image: "presentation",
imageAltText: "presentation",
imageMarker: "presentation",
formatting: "presentation",
linkInline: "presentation",
linkEmail: "presentation",
linkText: "presentation",
linkHref: "presentation",
header: 'presentation',
quote: 'presentation',
list1: 'presentation',
list2: 'presentation',
list3: 'presentation',
hr: 'presentation',
image: 'presentation',
imageAltText: 'presentation',
imageMarker: 'presentation',
formatting: 'presentation',
linkInline: 'presentation',
linkEmail: 'presentation',
linkText: 'presentation',
linkHref: 'presentation'
}
}
// line height
const INPUT_LINE_HEIGHT = 28;
const INPUT_TOP_PADDING = 3;
function getAdvance(a, b) {
let res = '';
if(!a) {
return b;
}
for (let i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i] !== b[i]) {
return res;
}
res = res.concat(a[i]);
}
return res;
}
function ChatInputSuggestion({ ship, contacts, selected, onSelect }) {
let contact = contacts[ship];
let color = "#000000";
let sigilClass = "v-mid mix-blend-diff"
let nickname;
let nameStyle = {};
const isSelected = ship === selected;
if (contact) {
const hex = uxToHex(contact.color);
color = `#${hex}`;
nameStyle.color = hexToRgba(hex, .7);
nameStyle.textShadow = '0px 0px 0px #000';
nameStyle.filter = 'contrast(1.3) saturate(1.5)';
sigilClass = "v-mid";
nickname = contact.nickname;
}
return (
<div
onClick={() => onSelect(ship)}
className={cn(
'f8 pv1 ph3 pointer hover-bg-gray1-d hover-bg-gray4 relative flex items-center',
{
'white-d bg-gray0-d bg-white': !isSelected,
'black-d bg-gray1-d bg-gray4': isSelected,
}
)}
key={ship}
>
<Sigil
ship={'~' + ship}
size={24}
color={color}
classes={sigilClass}
/>
{ nickname && (
<p style={nameStyle} className="dib ml4 b" >{nickname}</p>)
}
<div className="mono gray2 ml4">
{'~' + ship}
</div>
<p className="nowrap ml4">
{status}
</p>
</div>
);
}
function ChatInputSuggestions({ suggestions, onSelect, selected, contacts }) {
return (
<div
style={{
bottom: '90%',
left: '48px'
}}
className={
'absolute black white-d bg-white bg-gray0-d ' +
'w7 pv3 z-1 mt1 ba b--gray1-d b--gray4'
}>
{suggestions.map(ship =>
(<ChatInputSuggestion
onSelect={onSelect}
key={ship}
selected={selected}
contacts={contacts}
ship={ship} />)
)}
</div>
);
}
};
export class ChatInput extends Component {
constructor(props) {
@ -131,8 +39,7 @@ export class ChatInput extends Component {
this.state = {
message: '',
patpSuggestions: [],
selectedSuggestion: null
patpSearch: null
};
this.textareaRef = React.createRef();
@ -141,18 +48,15 @@ export class ChatInput extends Component {
this.messageChange = this.messageChange.bind(this);
this.patpAutocomplete = this.patpAutocomplete.bind(this);
this.nextAutocompleteSuggestion = this.nextAutocompleteSuggestion.bind(this);
this.completePatp = this.completePatp.bind(this);
this.clearSuggestions = this.clearSuggestions.bind(this);
this.clearSearch = this.clearSearch.bind(this);
this.toggleCode = this.toggleCode.bind(this);
this.editor = null;
// perf testing:
/*let closure = () => {
/* let closure = () => {
let x = 0;
for (var i = 0; i < 30; i++) {
x++;
@ -174,21 +78,21 @@ export class ChatInput extends Component {
past: function(input) {
return input === 'just now'
? input
: input + ' ago'
: input + ' ago';
},
s : 'just now',
future: "in %s",
future: 'in %s',
ss : '%d sec',
m: "a minute",
mm: "%d min",
h: "an hr",
hh: "%d hrs",
d: "a day",
dd: "%d days",
M: "a month",
MM: "%d months",
y: "a year",
yy: "%d years"
m: 'a minute',
mm: '%d min',
h: 'an hr',
hh: '%d hrs',
d: 'a day',
dd: '%d days',
M: 'a month',
MM: '%d months',
y: 'a year',
yy: '%d years'
}
});
}
@ -206,56 +110,20 @@ export class ChatInput extends Component {
this.setState({ selectedSuggestion: patpSuggestions[idx] });
}
patpAutocomplete(message, fresh = false) {
patpAutocomplete(message) {
const match = /~([a-zA-Z\-]*)$/.exec(message);
if (!match ) {
this.setState({ patpSuggestions: [] })
this.setState({ patpSearch: null });
return;
}
const needle = match[1].toLowerCase();
const matchString = hay => {
hay = hay.toLowerCase();
return hay.startsWith(needle)
|| _.some(_.words(hay), s => s.startsWith(needle));
};
const contacts = _.chain(this.props.contacts)
.defaultTo({})
.map((details, ship) => ({...details, ship }))
.filter(({ nickname, ship }) => matchString(nickname) || matchString(ship))
.map('ship')
.value()
const suggestions = _.chain(this.props.envelopes)
.defaultTo([])
.map("author")
.uniq()
.reverse()
.filter(matchString)
.union(contacts)
.filter(s => s.length < 28) // exclude comets
.take(5)
.value();
let newState = {
patpSuggestions: suggestions,
selectedSuggestion: suggestions[0]
};
this.setState(newState);
this.setState({ patpSearch: match[1].toLowerCase() });
}
clearSuggestions() {
clearSearch() {
this.setState({
patpSuggestions: []
})
patpSearch: null
});
}
completePatp(suggestion) {
@ -271,23 +139,20 @@ export class ChatInput extends Component {
const lastCol = this.editor.getLineHandle(lastRow).text.length;
this.editor.setCursor(lastRow, lastCol);
this.setState({
patpSuggestions: []
patpSearch: null
});
}
messageChange(editor, data, value) {
const { patpSuggestions } = this.state;
if(patpSuggestions.length !== 0) {
const { patpSearch } = this.state;
if(patpSearch !== null) {
this.patpAutocomplete(value, false);
}
}
getLetterType(letter) {
if (letter.startsWith('/me')) {
letter = letter.slice(3);
if (letter.startsWith('/me ')) {
letter = letter.slice(4);
// remove insignificant leading whitespace.
// aces might be relevant to style.
while (letter[0] === '\n') {
@ -296,22 +161,21 @@ export class ChatInput extends Component {
return {
me: letter
}
};
} else if (this.isUrl(letter)) {
return {
url: letter
}
};
} else {
return {
text: letter
}
};
}
}
isUrl(string) {
try {
let websiteTest = new RegExp(''
+ /((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source
const websiteTest = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source)
);
return websiteTest.test(string);
} catch (e) {
@ -330,6 +194,8 @@ export class ChatInput extends Component {
return;
}
props.onEnter();
if(state.code) {
props.api.chat.message(props.station, `~${window.ship}`, Date.now(), {
code: {
@ -341,10 +207,10 @@ export class ChatInput extends Component {
return;
}
let message = [];
editorMessage.split(" ").map((each) => {
editorMessage.split(' ').map((each) => {
if (this.isUrl(each)) {
if (message.length > 0) {
message = message.join(" ");
message = message.join(' ');
message = this.getLetterType(message);
props.api.chat.message(
props.station,
@ -354,22 +220,20 @@ export class ChatInput extends Component {
);
message = [];
}
let URL = this.getLetterType(each);
const URL = this.getLetterType(each);
props.api.chat.message(
props.station,
`~${window.ship}`,
Date.now(),
URL
);
}
else {
} else {
return message.push(each);
}
})
});
if (message.length > 0) {
message = message.join(" ");
message = message.join(' ');
message = this.getLetterType(message);
props.api.chat.message(
props.station,
@ -380,11 +244,10 @@ export class ChatInput extends Component {
message = [];
}
// perf:
//setTimeout(this.closure, 2000);
// perf:
// setTimeout(this.closure, 2000);
this.editor.setValue('');
}
toggleCode() {
@ -395,7 +258,7 @@ export class ChatInput extends Component {
} else {
this.setState({ code: true });
this.editor.setOption('mode', null);
this.editor.setOption('placeholder', "Code...");
this.editor.setOption('placeholder', 'Code...');
}
const value = this.editor.getValue();
@ -404,19 +267,46 @@ export class ChatInput extends Component {
this.editor.setValue(' ');
this.editor.setValue('');
}
}
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;
let color = !!props.ownerContact
const color = props.ownerContact
? uxToHex(props.ownerContact.color) : '000000';
let sigilClass = !!props.ownerContact
? "" : "mix-blend-diff";
const sigilClass = props.ownerContact
? '' : 'mix-blend-diff';
const completeActive = this.state.patpSuggestions.length !== 0;
const img = (props.ownerContact && (props.ownerContact.avatar !== null))
? <img src={props.ownerContact.avatar} height={24} width={24} className="dib" />
: <Sigil
ship={window.ship}
size={24}
color={`#${color}`}
classes={sigilClass}
/>;
const candidates = _.chain(this.props.envelopes)
.defaultTo([])
.map('author')
.uniq()
.reverse()
.value();
const codeTheme = state.code ? ' code' : '';
@ -427,83 +317,80 @@ export class ChatInput extends Component {
lineWrapping: true,
scrollbarStyle: 'native',
cursorHeight: 0.85,
placeholder: state.code ? "Code..." : props.placeholder,
placeholder: state.code ? 'Code...' : props.placeholder,
extraKeys: {
Tab: (cm) =>
completeActive
? this.nextAutocompleteSuggestion()
: this.patpAutocomplete(cm.getValue(), true),
'Shift-Tab': (cm) =>
completeActive
? this.nextAutocompleteSuggestion(true)
: CodeMirror.Pass,
'Up': (cm) =>
completeActive
? this.nextAutocompleteSuggestion(true)
: CodeMirror.Pass,
'Escape': (cm) =>
completeActive
? this.clearSuggestions(true)
: CodeMirror.Pass,
'Down': (cm) =>
completeActive
? this.nextAutocompleteSuggestion()
: CodeMirror.Pass,
'Enter': (cm) =>
completeActive
? this.completePatp(state.selectedSuggestion)
: this.messageSubmit(),
'Shift-3': (cm) =>
this.toggleCode()
Tab: cm =>
this.patpAutocomplete(cm.getValue(), true),
'Enter': () => {
this.messageSubmit();
if (this.state.code) {
this.toggleCode();
}
},
'Shift-3': cm =>
cm.getValue().length === 0
? this.toggleCode()
: CodeMirror.Pass
}
};
return (
<div className="pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white bg-gray0-d relative"
style={{ flexGrow: 1 }}>
{state.patpSuggestions.length !== 0 && (
<ChatInputSuggestions
onSelect={this.completePatp}
suggestions={state.patpSuggestions}
selected={state.selectedSuggestion}
contacts={props.contacts}
/>
)}
style={{ flexGrow: 1 }}
>
<ShipSearch
popover
onSelect={this.completePatp}
onClear={this.clearSearch}
contacts={props.contacts}
candidates={candidates}
searchTerm={this.state.patpSearch}
cm={this.editor}
/>
<div
className="fl"
style={{
marginTop: 6,
flexBasis: 24,
height: 24
}}>
<Sigil
ship={window.ship}
size={24}
color={`#${color}`}
classes={sigilClass}
/>
}}
>
{img}
</div>
<div
className="fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center"
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 48px)' }}>
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}
>
<CodeEditor
options={options}
editorDidMount={editor => { this.editor = editor; }}
editorDidMount={(editor) => {
this.editor = editor;
if (!/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(
navigator.userAgent
)) {
editor.focus();
}
}}
onChange={(e, d, v) => this.messageChange(e, d, v)}
/>
</div>
<div style={{ height: '24px', width: '24px', flexBasis: 24, marginTop: 6 }}>
<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.code && 'invert(100%)', height: '100%', width: '100%' }}
onClick={this.toggleCode}
src="/~chat/img/CodeEval.png"
className="contrast-10-d bg-white bg-none-d"
className="contrast-10-d bg-white bg-none-d ba b--gray1-d br1"
/>
</div>
</div>
);
}

View File

@ -1,32 +1,48 @@
import React, { Component } from 'react';
import { sigil, reactRenderer } from 'urbit-sigil-js';
export class Sigil extends Component {
render() {
const { props } = this;
let classes = props.classes || "";
const classes = props.classes || '';
const rgb = {
r: parseInt(props.color.slice(1, 3), 16),
g: parseInt(props.color.slice(3, 5), 16),
b: parseInt(props.color.slice(5, 7), 16)
};
const brightness = ((299 * rgb.r) + (587 * rgb.g) + (114 * rgb.b)) / 1000;
const whiteBrightness = 255;
let foreground = 'white';
if ((whiteBrightness - brightness) < 50) {
foreground = 'black';
}
if (props.ship.length > 14) {
return (
<div
className={"bg-black dib " + classes}
style={{width: props.size, height: props.size}}>
</div>
className={'bg-black dib ' + classes}
style={{ width: props.size, height: props.size }}
></div>
);
} else {
return (
<div className={"dib " + classes} style={{ flexBasis: props.size, backgroundColor: props.color }}>
<div
className={'dib ' + classes}
style={{ flexBasis: props.size, backgroundColor: props.color }}
>
{sigil({
patp: props.ship,
renderer: reactRenderer,
size: props.size,
colors: [props.color, "white"]
colors: [props.color, foreground],
class: props.svgClass
})}
</div>
);
}
}
}

View File

@ -289,7 +289,7 @@ export class InviteSearch extends Component {
render() {
const { props, state } = this;
let searchDisabled = false;
let searchDisabled = props.disabled;
if (props.invites.groups) {
if (props.invites.groups.length > 0) {
searchDisabled = true;

View File

@ -1,11 +1,8 @@
import React, { Component } from 'react';
import classnames from 'classnames';
import { Sigil } from '/components/lib/icons/sigil';
import { uxToHex, cite } from '/lib/util';
export class MemberElement extends Component {
onRemove() {
const { props } = this;
props.api.groups.remove([`~${props.ship}`], props.path);
@ -24,7 +21,8 @@ export class MemberElement extends Component {
} else if (window.ship !== props.ship && window.ship === props.owner) {
actionElem = (
<a onClick={this.onRemove.bind(this)}
className="w-20 dib list-ship black white-d f8 pointer">
className="w-20 dib list-ship black white-d f8 pointer"
>
Ban
</a>
);
@ -34,20 +32,24 @@ export class MemberElement extends Component {
);
}
let name = !!props.contact
const name = props.contact
? `${props.contact.nickname} (${cite(props.ship)})` : `${cite(props.ship)}`;
let color = !!props.contact ? uxToHex(props.contact.color) : '000000';
const color = props.contact ? uxToHex(props.contact.color) : '000000';
const img = (props.contact && (props.contact.avatar !== null))
? <img src={props.contact.avatar} height={32} width={32} className="dib" />
: <Sigil ship={props.ship} size={32} color={`#${color}`} />;
return (
<div className="flex mb2">
<Sigil ship={props.ship} size={32} color={`#${color}`} />
{img}
<p className={
"w-70 mono list-ship dib v-mid black white-d ml2 nowrap f8"
}>{name}</p>
'w-70 mono list-ship dib v-mid black white-d ml2 nowrap f8'
}
>{name}</p>
{actionElem}
</div>
);
}
}

View File

@ -1,5 +1,7 @@
import React, { Component } from 'react';
import { Sigil } from '/components/lib/icons/sigil';
import { ProfileOverlay } from '/components/lib/profile-overlay';
import { OverlaySigil } from '/components/lib/overlay-sigil';
import classnames from 'classnames';
import { Route, Link } from 'react-router-dom'
import { uxToHex, cite, writeText } from '/lib/util';
@ -53,6 +55,7 @@ export class Message extends Component {
iframe.setAttribute('src', iframe.getAttribute('data-src'));
}
renderContent() {
const { props } = this;
let letter = props.msg.letter;
@ -62,21 +65,21 @@ export class Message extends Component {
(!!letter.code.output &&
letter.code.output.length && letter.code.output.length > 0) ?
(
<pre className="f7 clamp-attachment pa1 mt0 mb0">
<pre className="f7 clamp-attachment pa1 mt0 mb0 b--gray4 b--gray1-d bl br bb">
{letter.code.output[0].join('\n')}
</pre>
) : null;
return (
<span>
<pre className="f7 clamp-attachment pa1 mt0 mb0 bg-light-gray">
<div className="mv2">
<pre className="f7 clamp-attachment pa1 mt0 mb0 bg-light-gray b--gray4 b--gray1-d ba">
{letter.code.expression}
</pre>
{outputElement}
</span>
</div>
);
} else if ('url' in letter) {
let imgMatch =
/(jpg|img|png|gif|tiff|jpeg|JPG|IMG|PNG|TIFF|GIF|webp|WEBP|webm|WEBM)$/
/(jpg|img|png|gif|tiff|jpeg|JPG|IMG|PNG|TIFF|GIF|webp|WEBP|webm|WEBM|svg|SVG)$/
.exec(letter.url);
let youTubeRegex = new RegExp(''
+ /(?:https?:\/\/(?:[a-z]+.)?)/.source // protocol
@ -190,20 +193,20 @@ export class Message extends Component {
return (
<div
ref={this.containerRef}
className={
"w-100 f7 pl3 pt4 pr3 cf flex lh-copy " + " " + pending
}
style={{
minHeight: "min-content"
}}>
<div className="fl mr3 v-top bg-white bg-gray0-d">
<Sigil
ship={props.msg.author}
size={24}
color={color}
classes={sigilClass}
/>
</div>
<OverlaySigil
ship={props.msg.author}
contact={contact}
color={color}
sigilClass={sigilClass}
group={props.group}
className="fl pr3 v-top bg-white bg-gray0-d" />
<div
className="fr clamp-message white-d"
style={{ flexGrow: 1, marginTop: -8 }}>

View File

@ -0,0 +1,102 @@
import React, { Component } from 'react';
import { Sigil } from '/components/lib/icons/sigil';
import {
ProfileOverlay,
OVERLAY_HEIGHT
} from '/components/lib/profile-overlay';
export class OverlaySigil extends Component {
constructor() {
super();
this.state = {
clicked: false,
captured: false,
topSpace: 0,
bottomSpace: 0
};
this.containerRef = React.createRef();
this.profileShow = this.profileShow.bind(this);
this.profileHide = this.profileHide.bind(this);
this.updateContainerInterval = setInterval(
this.updateContainerOffset.bind(this),
1000
);
}
componentDidMount() {
this.updateContainerOffset();
}
componentWillUnmount() {
if (this.updateContainerInterval) {
clearInterval(this.updateContainerInterval);
this.updateContainerInterval = null;
}
}
profileShow() {
this.setState({ profileClicked: true });
}
profileHide() {
this.setState({ profileClicked: false });
}
updateContainerOffset() {
if (this.containerRef && this.containerRef.current) {
const parent = this.containerRef.current.offsetParent;
const { offsetTop } = this.containerRef.current;
let bottomSpace, topSpace;
if(navigator.userAgent.includes('Firefox')) {
topSpace = offsetTop - parent.scrollTop - OVERLAY_HEIGHT / 2;
bottomSpace = parent.clientHeight - topSpace - OVERLAY_HEIGHT;
} else {
topSpace = offsetTop + parent.scrollHeight - parent.clientHeight - parent.scrollTop;
bottomSpace = parent.clientHeight - topSpace - OVERLAY_HEIGHT;
}
this.setState({
topSpace,
bottomSpace
});
}
}
render() {
const { props, state } = this;
const img = (props.contact && (props.contact.avatar !== null))
? <img src={props.contact.avatar} height={24} width={24} className="dib" />
: <Sigil
ship={props.ship}
size={24}
color={props.color}
classes={props.sigilClass}
/>;
return (
<div
onClick={this.profileShow}
className={props.className + ' pointer relative'}
ref={this.containerRef}
style={{ height: '24px' }}
>
{state.profileClicked && (
<ProfileOverlay
ship={props.ship}
contact={props.contact}
color={props.color}
topSpace={state.topSpace}
bottomSpace={state.bottomSpace}
group={props.group}
onDismiss={this.profileHide}
/>
)}
{img}
</div>
);
}
}

View File

@ -0,0 +1,101 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { cite } from '/lib/util';
import { Sigil } from '/components/lib/icons/sigil';
export const OVERLAY_HEIGHT = 250;
export class ProfileOverlay extends Component {
constructor() {
super();
this.popoverRef = React.createRef();
this.onDocumentClick = this.onDocumentClick.bind(this);
}
componentDidMount() {
document.addEventListener('mousedown', this.onDocumentClick);
document.addEventListener('touchstart', this.onDocumentClick);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.onDocumentClick);
document.removeEventListener('touchstart', this.onDocumentClick);
}
onDocumentClick(event) {
const { popoverRef } = this;
// Do nothing if clicking ref's element or descendent elements
if (!popoverRef.current || popoverRef.current.contains(event.target)) {
return;
}
this.props.onDismiss();
}
render() {
const { contact, ship, color, topSpace, bottomSpace, group } = this.props;
let top, bottom;
if (topSpace < OVERLAY_HEIGHT / 2) {
top = '0px';
}
if (bottomSpace < OVERLAY_HEIGHT / 2) {
bottom = '0px';
}
if (!(top || bottom)) {
bottom = `-${Math.round(OVERLAY_HEIGHT / 2)}px`;
}
const containerStyle = { top, bottom, left: '100%' };
const isOwn = window.ship === ship;
const identityHref = group['group-path'].startsWith('/~/')
? '/~groups/me'
: `/~groups/view${group['group-path']}/${window.ship}`;
const img = (contact && (contact.avatar !== null))
? <img src={contact.avatar} height={160} width={160} className="brt2 dib" />
: <Sigil
ship={ship}
size={160}
color={color}
classes="brt2"
svgClass="brt2"
/>;
return (
<div
ref={this.popoverRef}
style={containerStyle}
className="flex-col shadow-6 br2 bg-white bg-gray0-d inter absolute z-1 f9 lh-solid"
>
<div style={{ height: '160px', width: '160px' }}>
{img}
</div>
<div className="pv3 pl3 pr2">
{contact && contact.nickname && (
<div className="b white-d">{contact.nickname}</div>
)}
<div className="mono gray2">{cite(`~${ship}`)}</div>
{!isOwn && (
<Link
to={`/~chat/new/dm/~${ship}`}
className="b--green0 b--green2-d b--solid ba green2 mt3 tc pa2 pointer db"
>
Send Message
</Link>
)}
{isOwn && (
<a
href={identityHref}
className="b--black b--white-d ba black white-d mt3 tc pa2 pointer db"
>
Edit Group Identity
</a>
)}
</div>
</div>
);
}
}

View File

@ -0,0 +1,96 @@
import React, { Component } from 'react'
import S3Client from '/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 (
!!creds &&
'endpoint' in creds &&
'accessKeyId' in creds &&
'secretAccessKey' in creds &&
creds.endpoint !== '' &&
creds.accessKeyId !== '' &&
creds.secretAccessKey !== '' &&
!!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; }
let files = this.inputRef.current.files;
if (files.length <= 0) { return; }
let file = files.item(0);
let 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 {
let 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

@ -0,0 +1,371 @@
import React, { Component } from 'react';
import _ from 'lodash';
import urbitOb from 'urbit-ob';
import Mousetrap from 'mousetrap';
import cn from 'classnames';
import { Sigil } from '/components/lib/icons/sigil';
import { hexToRgba, uxToHex, deSig } from '/lib/util';
function ShipSearchItem({ ship, contacts, selected, onSelect }) {
const contact = contacts[ship];
let color = '#000000';
let sigilClass = 'v-mid mix-blend-diff';
let nickname;
const nameStyle = {};
const isSelected = ship === selected;
if (contact) {
const hex = uxToHex(contact.color);
color = `#${hex}`;
nameStyle.color = hexToRgba(hex, 0.7);
nameStyle.textShadow = '0px 0px 0px #000';
nameStyle.filter = 'contrast(1.3) saturate(1.5)';
nameStyle.maxWidth = '200px';
sigilClass = 'v-mid';
nickname = contact.nickname;
}
return (
<div
onClick={() => onSelect(ship)}
className={cn(
'f9 pv1 ph3 pointer hover-bg-gray1-d hover-bg-gray4 relative flex items-center',
{
'white-d bg-gray0-d bg-white': !isSelected,
'black-d bg-gray1-d bg-gray4': isSelected
}
)}
key={ship}
>
<Sigil ship={'~' + ship} size={24} color={color} classes={sigilClass} />
{nickname && (
<p style={nameStyle} className="dib ml4 b truncate">
{nickname}
</p>
)}
<div className="mono gray2 gray4-d ml4">{'~' + ship}</div>
<p className="nowrap ml4">{status}</p>
</div>
);
}
export class ShipSearch extends Component {
constructor() {
super();
this.state = {
selected: null,
suggestions: [],
bound: false
};
this.keymap = {
Tab: cm =>
this.nextAutocompleteSuggestion(),
'Shift-Tab': cm =>
this.nextAutocompleteSuggestion(true),
'Up': cm =>
this.nextAutocompleteSuggestion(true),
'Escape': cm =>
this.props.onClear(),
'Down': cm =>
this.nextAutocompleteSuggestion(),
'Enter': (cm) => {
if(this.props.searchTerm !== null) {
this.props.onSelect(this.state.selected);
}
},
'Shift-3': cm =>
this.toggleCode()
};
}
componentDidMount() {
if(this.props.searchTerm !== null) {
this.updateSuggestions(true);
}
}
componentDidUpdate(prevProps) {
const { props, state } = this;
if(!state.bound && props.inputRef) {
this.bindShortcuts();
}
if(props.searchTerm === null) {
if(state.suggestions.length > 0) {
this.setState({ suggestions: [] });
}
this.unbindShortcuts();
return;
}
if (
props.searchTerm === null &&
props.searchTerm !== prevProps.searchTerm &&
props.searchTerm.startsWith(prevProps.searchTerm)
) {
this.updateSuggestions();
} else if (prevProps.searchTerm !== props.searchTerm) {
this.updateSuggestions(true);
}
}
updateSuggestions(isStale = false) {
const needle = this.props.searchTerm;
const matchString = (hay) => {
hay = hay.toLowerCase();
return (
hay.startsWith(needle) ||
_.some(_.words(hay), s => s.startsWith(needle))
);
};
let candidates = this.state.suggestions;
if (isStale || this.state.suggestions.length === 0) {
const contacts = _.chain(this.props.contacts)
.defaultTo({})
.map((details, ship) => ({ ...details, ship }))
.filter(
({ nickname, ship }) => matchString(nickname) || matchString(ship)
)
.map('ship')
.value();
const exactMatch = urbitOb.isValidPatp(`~${needle}`) ? [needle] : [];
candidates = _.chain(this.props.candidates)
.defaultTo([])
.union(contacts)
.union(exactMatch)
.value();
}
const suggestions = _.chain(candidates)
.filter(matchString)
.filter(s => s.length < 28) // exclude comets
.value();
this.bindShortcuts();
this.setState({ suggestions, selected: suggestions[0] });
}
bindCmShortcuts() {
if(!this.props.cm) {
return;
}
this.props.cm.addKeyMap(this.keymap);
}
unbindCmShortcuts() {
if(!this.props.cm) {
return;
}
this.props.cm.removeKeyMap(this.keymap);
}
bindShortcuts() {
if (this.state.bound) {
return;
}
if (!this.props.inputRef) {
return this.bindCmShortcuts();
}
this.setState({ bound: true });
if (!this.mousetrap) {
this.mousetrap = new Mousetrap(this.props.inputRef);
}
this.mousetrap.bind('enter', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.state.selected) {
this.unbindShortcuts();
this.props.onSelect(this.state.selected);
}
});
this.mousetrap.bind('tab', (e) => {
e.preventDefault();
e.stopPropagation();
this.nextAutocompleteSuggestion(false);
});
this.mousetrap.bind(['up', 'shift+tab'], (e) => {
e.preventDefault();
e.stopPropagation();
this.nextAutocompleteSuggestion(true);
});
this.mousetrap.bind('down', (e) => {
e.preventDefault();
e.stopPropagation();
this.nextAutocompleteSuggestion(false);
});
this.mousetrap.bind('esc', (e) => {
e.preventDefault();
e.stopPropagation();
this.props.onClear();
});
}
unbindShortcuts() {
if(!this.props.inputRef) {
this.unbindCmShortcuts();
}
if (!this.state.bound) {
return;
}
this.setState({ bound: false });
this.mousetrap.unbind('enter');
this.mousetrap.unbind('tab');
this.mousetrap.unbind(['up', 'shift+tab']);
this.mousetrap.unbind('down');
this.mousetrap.unbind('esc');
}
nextAutocompleteSuggestion(backward = false) {
const { suggestions } = this.state;
let idx = suggestions.findIndex(s => s === this.state.selected);
idx = backward ? idx - 1 : idx + 1;
idx = idx % Math.min(suggestions.length, 5);
if (idx < 0) {
idx = suggestions.length - 1;
}
this.setState({ selected: suggestions[idx] });
}
render() {
const { onSelect, contacts, popover, className } = this.props;
const { selected, suggestions } = this.state;
if (suggestions.length === 0) {
return null;
}
const popoverClasses = (popover && ' absolute ') || ' ';
return (
<div
style={
popover
? {
bottom: '90%',
left: '48px'
}
: {}
}
className={
'black white-d bg-white bg-gray0-d ' +
'w7 pv3 z-1 mt1 ba b--gray1-d b--gray4' +
popoverClasses +
className || ''
}
>
{suggestions.slice(0, 5).map(ship => (
<ShipSearchItem
onSelect={onSelect}
key={ship}
selected={selected}
contacts={contacts}
ship={ship}
/>
))}
</div>
);
}
}
export class ShipSearchInput extends Component {
constructor() {
super();
this.state = {
searchTerm: ''
};
this.inputRef = null;
this.popoverRef = null;
this.search = this.search.bind(this);
this.onClick = this.onClick.bind(this);
this.setInputRef = this.setInputRef.bind(this);
}
onClick(event) {
const { popoverRef } = this;
// Do nothing if clicking ref's element or descendent elements
if (!popoverRef || popoverRef.contains(event.target)) {
return;
}
this.props.onClear();
}
componentDidMount() {
document.addEventListener('mousedown', this.onClick);
document.addEventListener('touchstart', this.onClick);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.onClick);
document.removeEventListener('touchstart', this.onClick);
}
setInputRef(ref) {
this.inputRef = ref;
if(ref) {
ref.focus();
}
// update this.inputRef prop
this.forceUpdate();
}
search(e) {
const searchTerm = e.target.value;
this.setState({ searchTerm });
}
render() {
const { state, props } = this;
return (
<div
ref={ref => (this.popoverRef = ref)}
style={{ top: '150%', left: '-80px' }}
className="b--gray2 b--solid ba absolute bg-white bg-gray0-d"
>
<textarea
style={{ resize: 'none', maxWidth: '200px' }}
className="ma2 pa2 b--gray4 ba b--solid w7 db bg-gray0-d white-d"
rows={1}
autocapitalise="none"
autoFocus={
/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(
navigator.userAgent
)
? false
: true
}
placeholder="Search for a ship"
value={state.searchTerm}
onChange={this.search}
ref={this.setInputRef}
/>
<ShipSearch
contacts={props.contacts}
candidates={props.candidates}
searchTerm={deSig(state.searchTerm)}
inputRef={this.inputRef}
onSelect={props.onSelect}
onClear={props.onClear}
/>
</div>
);
}
}

View File

@ -0,0 +1,106 @@
import React, { Component } from "react";
import classnames from "classnames";
import { InviteSearch } from "./lib/invite-search";
import { Spinner } from "./lib/icons/icon-spinner";
import { Route, Link } from "react-router-dom";
import { uuid, isPatTa, deSig } from "/lib/util";
import urbitOb from "urbit-ob";
export class NewDmScreen extends Component {
constructor(props) {
super(props);
this.state = {
ship: null,
station: null,
awaiting: false
};
this.onClickCreate = this.onClickCreate.bind(this);
}
componentDidMount() {
const { props } = this;
if (props.autoCreate && urbitOb.isValidPatp(props.autoCreate)) {
this.setState(
{
ship: props.autoCreate.slice(1),
awaiting: true
},
this.onClickCreate
);
}
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
if (prevProps !== props) {
const { station } = this.state;
if (station && station in props.inbox) {
this.setState({ awaiting: false });
props.history.push(`/~chat/room${station}`);
}
}
}
onClickCreate() {
const { props, state } = this;
let station = `/~/~${window.ship}/dm--${state.ship}`;
let theirStation = `/~/~${state.ship}/dm--${window.ship}`;
if (station in props.inbox) {
props.history.push(`/~chat/room${station}`);
return;
}
if (theirStation in props.inbox) {
props.history.push(`/~chat/room${theirStation}`);
return;
}
this.setState(
{
station
},
() => {
let groupPath = station;
props.api.chatView.create(
`~${window.ship} <-> ~${state.ship}`,
"",
station,
groupPath,
"village",
state.ship !== window.ship ? [`~${state.ship}`] : [],
true
);
}
);
}
render() {
const { props, state } = this;
return (
<div
className={
"h-100 w-100 mw6 pa3 pt4 overflow-x-hidden " +
"bg-gray0-d white-d flex flex-column"
}
>
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
<Link to="/~chat/">{"⟵ All Chats"}</Link>
</div>
<h2 className="mb3 f8">New DM</h2>
<div className="w-100">
<Spinner
awaiting={this.state.awaiting}
classes="mt4"
text="Creating chat..."
/>
</div>
</div>
);
}
}

View File

@ -16,7 +16,7 @@ export class NewScreen extends Component {
idName: '',
groups: [],
ships: [],
security: 'village',
security: 'channel',
idError: false,
inviteError: false,
allowHistory: true,
@ -26,7 +26,6 @@ export class NewScreen extends Component {
this.titleChange = this.titleChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
this.securityChange = this.securityChange.bind(this);
this.allowHistoryChange = this.allowHistoryChange.bind(this);
this.setInvite = this.setInvite.bind(this);
this.createGroupChange = this.createGroupChange.bind(this);
@ -65,17 +64,6 @@ export class NewScreen extends Component {
});
}
securityChange(event) {
if (this.state.createGroup) {
return;
}
if (event.target.checked) {
this.setState({security: "village"});
} else if (!event.target.checked) {
this.setState({security: "channel"});
}
}
createGroupChange(event) {
if (event.target.checked) {
this.setState({
@ -85,6 +73,7 @@ export class NewScreen extends Component {
} else {
this.setState({
createGroup: !!event.target.checked,
security: 'channel'
});
}
}
@ -124,6 +113,10 @@ export class NewScreen extends Component {
}
});
if(state.ships.length === 1 && state.security === 'village' && !state.createGroup) {
props.history.push(`/~chat/new/dm/${aud[0]}`);
}
if (!isValid) {
this.setState({
inviteError: true,
@ -279,18 +272,6 @@ export class NewScreen extends Component {
setInvite={this.setInvite}
/>
{createGroupToggle}
<div className="mv7">
<input
type="checkbox"
style={{ WebkitAppearance: "none", width: 28 }}
className={inviteSwitchClasses}
onChange={this.securityChange}
/>
<span className="dib f9 white-d inter ml3">Invite Only Chat</span>
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
Chat participants must be invited to see chat content
</p>
</div>
<button
onClick={this.onClickCreate.bind(this)}
className={createClasses}>

View File

@ -13,6 +13,7 @@ import { MemberScreen } from '/components/member';
import { SettingsScreen } from '/components/settings';
import { NewScreen } from '/components/new';
import { JoinScreen } from '/components/join';
import { NewDmScreen } from '/components/new-dm';
export class Root extends Component {
@ -20,6 +21,7 @@ export class Root extends Component {
super(props);
this.state = store.state;
this.totalUnreads = 0;
store.setStateHandler(this.setState.bind(this));
}
@ -33,6 +35,7 @@ export class Root extends Component {
let messagePreviews = {};
let unreads = {};
let totalUnreads = 0;
Object.keys(state.inbox).forEach((stat) => {
let envelopes = state.inbox[stat].envelopes;
@ -42,14 +45,22 @@ export class Root extends Component {
messagePreviews[stat] = envelopes[0];
}
unreads[stat] =
state.inbox[stat].config.length > state.inbox[stat].config.read;
const unread = Math.max(state.inbox[stat].config.length - state.inbox[stat].config.read, 0)
unreads[stat] = !!unread;
if(unread) {
totalUnreads += unread;
}
});
if(totalUnreads !== this.totalUnreads) {
document.title = totalUnreads > 0 ? `Chat - (${totalUnreads})` : 'Chat';
this.totalUnreads = totalUnreads;
}
let invites = !!state.invites ? state.invites : {'/chat': {}, '/contacts': {}};
let contacts = !!state.contacts ? state.contacts : {};
let associations = !!state.associations ? state.associations : {chat: {}, contacts: {}};
let s3 = !!state.s3 ? state.s3 : {};
const renderChannelSidebar = (props, station) => (
<Sidebar
@ -92,6 +103,34 @@ export class Root extends Component {
);
}}
/>
<Route
exact
path="/~chat/new/dm/:ship"
render={props => {
const ship = props.match.params.ship;
return (
<Skeleton
associations={associations}
invites={invites}
sidebarHideOnMobile={true}
sidebar={renderChannelSidebar(props)}
sidebarShown={state.sidebarShown}
>
<NewDmScreen
api={api}
inbox={state.inbox || {}}
permissions={state.permissions || {}}
contacts={state.contacts || {}}
associations={associations.contacts}
chatSynced={state.chatSynced || {}}
autoCreate={ship}
{...props}
/>
</Skeleton>
);
}}
/>
<Route
exact
path="/~chat/new"
@ -206,6 +245,7 @@ export class Root extends Component {
inbox={state.inbox}
contacts={roomContacts}
permission={permission}
s3={s3}
pendingMessages={state.pendingMessages}
popout={popout}
sidebarShown={state.sidebarShown}

View File

@ -5,17 +5,35 @@ import Welcome from '/components/lib/welcome.js';
import { alphabetiseAssociations } from '../lib/util';
import { SidebarInvite } from '/components/lib/sidebar-invite';
import { GroupItem } from '/components/lib/group-item';
import { ShipSearchInput } from '/components/lib/ship-search';
export class Sidebar extends Component {
constructor() {
super();
this.state = {
dmOverlay: false
};
}
onClickNew() {
this.props.history.push('/~chat/new');
}
onClickDm() {
this.setState(({ dmOverlay }) => ({ dmOverlay: !dmOverlay }) )
}
onClickJoin() {
this.props.history.push('/~chat/join')
}
goDm(ship) {
this.setState({ dmOverlay: false }, () => {
this.props.history.push(`/~chat/new/dm/~${ship}`)
});
}
render() {
const { props, state } = this;
@ -97,6 +115,14 @@ export class Sidebar extends Component {
/>
)
}
const candidates = state.dmOverlay
? _.chain(this.props.contacts)
.values()
.map(_.keys)
.flatten()
.uniq()
.value()
: [];
return (
<div
@ -108,6 +134,25 @@ export class Sidebar extends Component {
onClick={this.onClickNew.bind(this)}>
New Chat
</a>
<div className="dib relative mr4">
{ state.dmOverlay && (
<ShipSearchInput
className="absolute"
contacts={{}}
candidates={candidates}
onSelect={this.goDm.bind(this)}
onClear={this.onClickDm.bind(this)}
/>
)}
<a
className="f9 pointer green2 gray4-d"
onClick={this.onClickDm.bind(this)}>
DM
</a>
</div>
<a
className="dib f9 pointer gray4-d"
onClick={this.onClickJoin.bind(this)}>

View File

@ -0,0 +1,53 @@
export default class S3Client {
constructor() {
this.s3 = null;
this.endpoint = "";
this.accessKeyId = "";
this.secretAccesskey = "";
}
setCredentials(endpoint, accessKeyId, secretAccessKey) {
if (!window.AWS) {
setTimeout(() => {
this.setCredentials(endpoint, accessKeyId, secretAccessKey);
}, 2000);
return;
}
this.endpoint = new window.AWS.Endpoint(endpoint);
this.accessKeyId = accessKeyId;
this.secretAccessKey = secretAccessKey;
this.s3 =
new window.AWS.S3({
endpoint: this.endpoint,
credentials: new window.AWS.Credentials({
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey
})
});
}
upload(bucket, filename, buffer) {
let params = {
Bucket: bucket,
Key: filename,
Body: buffer,
ACL: 'public-read'
};
return new Promise((resolve, reject) => {
if (!this.s3) {
reject({ error: 'S3 not initialized!' });
return;
}
this.s3.upload(params, (error, data) => {
if (error) {
reject({ error });
} else {
resolve(data);
}
});
});
}
}

View File

@ -0,0 +1,81 @@
import _ from 'lodash';
export class S3Reducer {
reduce(json, state) {
let data = _.get(json, 's3-update', false);
if (data) {
this.credentials(data, state);
this.configuration(data, state);
this.currentBucket(data, state);
this.addBucket(data, state);
this.removeBucket(data, state);
this.endpoint(data, state);
this.accessKeyId(data, state);
this.secretAccessKey(data, state);
}
}
credentials(json, state) {
let data = _.get(json, 'credentials', false);
if (data) {
state.s3.credentials = data;
}
}
configuration(json, state) {
let data = _.get(json, 'configuration', false);
if (data) {
state.s3.configuration = {
buckets: new Set(data.buckets),
currentBucket: data.currentBucket
};
}
}
currentBucket(json, state) {
let data = _.get(json, 'setCurrentBucket', false);
if (data) {
state.s3.configuration.currentBucket = data;
}
}
addBucket(json, state) {
let data = _.get(json, 'addBucket', false);
if (data) {
state.s3.configuration.buckets =
state.s3.configuration.buckets.add(data);
}
}
removeBucket(json, state) {
let data = _.get(json, 'removeBucket', false);
if (data) {
state.s3.configuration.buckets =
state.s3.configuration.buckets.delete(data);
}
}
endpoint(json, state) {
let data = _.get(json, 'setEndpoint', false);
if (data) {
state.s3.credentials.endpoint = data;
}
}
accessKeyId(json, state) {
let data = _.get(json, 'setAccessKeyId', false);
if (data) {
state.s3.credentials.accessKeyId = data;
}
}
secretAccessKey(json, state) {
let data = _.get(json, 'setSecretAccessKey', false);
if (data) {
state.s3.credentials.secretAccessKey = data;
}
}
}

View File

@ -4,6 +4,7 @@ import { ChatUpdateReducer } from '/reducers/chat-update';
import { InviteUpdateReducer } from '/reducers/invite-update';
import { PermissionUpdateReducer } from '/reducers/permission-update';
import { MetadataReducer } from '/reducers/metadata-update.js';
import { S3Reducer } from '/reducers/s3.js';
import { LocalReducer } from '/reducers/local.js';
@ -17,6 +18,7 @@ class Store {
this.chatUpdateReducer = new ChatUpdateReducer();
this.inviteUpdateReducer = new InviteUpdateReducer();
this.metadataReducer = new MetadataReducer();
this.s3Reducer = new S3Reducer();
this.localReducer = new LocalReducer();
this.setState = () => {};
}
@ -32,6 +34,7 @@ class Store {
chat: {},
contacts: {}
},
s3: {},
selectedGroups: [],
sidebarShown: true,
pendingMessages: new Map([]),
@ -58,6 +61,7 @@ class Store {
this.chatUpdateReducer.reduce(json, this.state);
this.inviteUpdateReducer.reduce(json, this.state);
this.metadataReducer.reduce(json, this.state);
this.s3Reducer.reduce(json, this.state);
this.localReducer.reduce(json, this.state);
this.setState(this.state);

View File

@ -54,6 +54,7 @@ export class Subscription {
this.subscribe('/primary', 'contact-view');
this.subscribe('/app-name/chat', 'metadata-store');
this.subscribe('/app-name/contacts', 'metadata-store');
this.subscribe('/all', 's3-store');
}
handleEvent(diff) {

View File

@ -11,13 +11,13 @@ class UrbitApi {
this.bindPaths = [];
this.contactHook = {
edit: this.contactEdit.bind(this),
remove: this.contactRemove.bind(this)
edit: this.contactEdit.bind(this)
};
this.contactView = {
create: this.contactCreate.bind(this),
delete: this.contactDelete.bind(this),
remove: this.contactRemove.bind(this),
share: this.contactShare.bind(this)
};
@ -104,16 +104,12 @@ class UrbitApi {
return this.contactViewAction({ delete: { path }});
}
contactHookAction(data) {
return this.action("contact-hook", "contact-action", data);
contactRemove(path, ship) {
return this.contactViewAction({ remove: { path, ship } });
}
contactRemove(path, ship) {
return this.contactHookAction({
remove: {
path, ship
}
});
contactHookAction(data) {
return this.action("contact-hook", "contact-action", data);
}
contactEdit(path, ship, editField) {
@ -125,7 +121,7 @@ class UrbitApi {
{notes: ''}
{color: 'fff'} // with no 0x prefix
{avatar: null}
{avatar: {p: length, q: bytestream}}
{avatar: {url: ''}}
*/
return this.contactHookAction({
edit: {

View File

@ -2,10 +2,11 @@ import React, { Component } from 'react';
import { Sigil } from './icons/sigil';
import { api } from '/api';
import { Route, Link } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { EditElement } from '/components/lib/edit-element';
import { Spinner } from './icons/icon-spinner';
import { uxToHex } from '/lib/util';
import { S3Upload } from '/components/lib/s3-upload';
export class ContactCard extends Component {
constructor(props) {
@ -17,9 +18,10 @@ export class ContactCard extends Component {
emailToSet: null,
phoneToSet: null,
websiteToSet: null,
avatarToSet: null,
notesToSet: null,
awaiting: false,
type: "Saving to group"
type: 'Saving to group'
};
this.editToggle = this.editToggle.bind(this);
this.sigilColorSet = this.sigilColorSet.bind(this);
@ -27,10 +29,12 @@ export class ContactCard extends Component {
this.emailToSet = this.emailToSet.bind(this);
this.phoneToSet = this.phoneToSet.bind(this);
this.websiteToSet = this.websiteToSet.bind(this);
this.avatarToSet = this.avatarToSet.bind(this);
this.notesToSet = this.notesToSet.bind(this);
this.setField = this.setField.bind(this);
this.shareWithGroup = this.shareWithGroup.bind(this);
this.removeFromGroup = this.removeFromGroup.bind(this);
this.removeSelfFromGroup = this.removeSelfFromGroup.bind(this);
this.removeOtherFromGroup = this.removeOtherFromGroup.bind(this);
}
componentDidUpdate(prevProps) {
@ -43,6 +47,7 @@ export class ContactCard extends Component {
emailToSet: null,
phoneToSet: null,
websiteToSet: null,
avatarToSet: null,
notesToSet: null
});
return;
@ -50,10 +55,9 @@ export class ContactCard extends Component {
}
editToggle() {
const { props } = this;
let editSwitch = this.state.edit;
editSwitch = !editSwitch;
this.setState({edit: editSwitch});
this.setState({ edit: editSwitch });
}
emailToSet(value) {
@ -76,178 +80,211 @@ export class ContactCard extends Component {
this.setState({ websiteToSet: value });
}
avatarToSet(value) {
this.setState({ avatarToSet: value });
}
sigilColorSet(event) {
this.setState({ colorToSet: event.target.value });
}
shipParser(ship) {
switch (ship.length) {
case 3: return "Galaxy";
case 6: return "Star";
case 13: return "Planet";
case 56: return "Comet";
default: return "Unknown";
case 3: return 'Galaxy';
case 6: return 'Star';
case 13: return 'Planet';
case 56: return 'Comet';
default: return 'Unknown';
}
}
setField(field) {
const { props, state } = this;
let ship = "~" + props.ship;
let emailTest = new RegExp(''
+ /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*/.source
const ship = '~' + props.ship;
const emailTest = new RegExp(String(/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*/.source)
+ /@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/.source
);
let phoneTest = new RegExp(''
+ /^\s*(?:\+?(\d{1,3}))?/.source
const phoneTest = new RegExp(String(/^\s*(?:\+?(\d{1,3}))?/.source)
+ /([-. (]*(\d{3})[-. )]*)?((\d{3})[-. ]*(\d{2,4})(?:[-.x ]*(\d+))?)\s*$/.source
);
let websiteTest = new RegExp(''
+ /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}/.source
const websiteTest = new RegExp(String(/[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}/.source)
+ /\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/.source
);
switch (field) {
case "color": {
let currentColor = (props.contact.color) ? props.contact.color : "000000";
currentColor = uxToHex(currentColor);
let hexExp = /([0-9A-Fa-f]{6})/
let hexTest = hexExp.exec(this.state.colorToSet);
if (hexTest && (hexTest[1] !== currentColor) && !props.share) {
this.setState({ awaiting: true, type: "Saving to group" }, (() => {
api.contactEdit(props.path, `~${props.ship}`, { color: hexTest[1] }).then(() => {
case 'avatar': {
if (
(state.avatarToSet === '') ||
(
Boolean(props.contact.avatar) &&
state.avatarToSet === props.contact.avatar
)
) {
return false;
}
const avatarTestResult = websiteTest.exec(state.avatarToSet);
if (avatarTestResult) {
this.setState({
awaiting: true,
type: 'Saving to group'
}, (() => {
console.log(state.avatarToSet);
api.contactEdit(props.path, ship, {
avatar: {
url: state.avatarToSet
}
}).then(() => {
this.setState({ awaiting: false });
});
}))
}));
}
break;
}
case "email": {
case 'color': {
let currentColor = (props.contact.color) ? props.contact.color : '000000';
currentColor = uxToHex(currentColor);
const hexExp = /([0-9A-Fa-f]{6})/;
const hexTest = hexExp.exec(this.state.colorToSet);
if (hexTest && (hexTest[1] !== currentColor) && !props.share) {
this.setState({ awaiting: true, type: 'Saving to group' }, (() => {
api.contactEdit(props.path, `~${props.ship}`, { color: hexTest[1] }).then(() => {
this.setState({ awaiting: false });
});
}));
}
break;
}
case 'email': {
if (
(state.emailToSet === "") ||
(state.emailToSet === '') ||
(state.emailToSet === props.contact.email)
) {
return false;
}
let emailTestResult = emailTest.exec(state.emailToSet);
const emailTestResult = emailTest.exec(state.emailToSet);
if (emailTestResult) {
this.setState({ awaiting: true, type: "Saving to group" }, (() => {
this.setState({ awaiting: true, type: 'Saving to group' }, (() => {
api.contactEdit(props.path, ship, { email: state.emailToSet }).then(() => {
this.setState({awaiting: false});
this.setState({ awaiting: false });
});
}))
}));
}
break;
}
case "nickname": {
case 'nickname': {
if (
(state.nickNameToSet === "") ||
(state.nickNameToSet === '') ||
(state.nickNameToSet === props.contact.nickname)
) {
return false;
}
this.setState({ awaiting: true, type: "Saving to group" }, (() => {
this.setState({ awaiting: true, type: 'Saving to group' }, (() => {
api.contactEdit(props.path, ship, { nickname: state.nickNameToSet }).then(() => {
this.setState({ awaiting: false });
});
}))
}));
break;
}
case "notes": {
case 'notes': {
if (
(state.notesToSet === "") ||
(state.notesToSet === '') ||
(state.notesToSet === props.contact.notes)
) {
return false;
}
this.setState({ awaiting: true, type: "Saving to group" }, (() => {
this.setState({ awaiting: true, type: 'Saving to group' }, (() => {
api.contactEdit(props.path, ship, { notes: state.notesToSet }).then(() => {
this.setState({ awaiting: false });
});
}))
}));
break;
}
case "phone": {
case 'phone': {
if (
(state.phoneToSet === "") ||
(state.phoneToSet === '') ||
(state.phoneToSet === props.contact.phone)
) {
return false;
}
let phoneTestResult = phoneTest.exec(state.phoneToSet);
const phoneTestResult = phoneTest.exec(state.phoneToSet);
if (phoneTestResult) {
this.setState({ awaiting: true, type: "Saving to group" }, (() => {
this.setState({ awaiting: true, type: 'Saving to group' }, (() => {
api.contactEdit(props.path, ship, { phone: state.phoneToSet }).then(() => {
this.setState({ awaiting: false });
});
}))
}));
}
break;
}
case "website": {
case 'website': {
if (
(state.websiteToSet === "") ||
(state.websiteToSet === '') ||
(state.websiteToSet === props.contact.website)
) {
return false;
}
let websiteTestResult = websiteTest.exec(state.websiteToSet);
const websiteTestResult = websiteTest.exec(state.websiteToSet);
if (websiteTestResult) {
this.setState({ awaiting: true, type: "Saving to group" }, (() => {
this.setState({ awaiting: true, type: 'Saving to group' }, (() => {
api.contactEdit(props.path, ship, { website: state.websiteToSet }).then(() => {
this.setState({ awaiting: false });
});
}))
}));
}
break;
}
case "removeAvatar": {
this.setState({ awaiting: true, type: "Removing from group" }, (() => {
api.contactEdit(props.path, ship, { avatar: null }).then(() => {
this.setState({ awaiting: false });
});
}))
break;
}
case "removeEmail": {
this.setState({ emailToSet: "", awaiting: true, type: "Removing from group" }, (() => {
api.contactEdit(props.path, ship, { email: "" }).then(() => {
this.setState({awaiting: false});
case 'removeEmail': {
this.setState({ emailToSet: '', awaiting: true, type: 'Removing from group' }, (() => {
api.contactEdit(props.path, ship, { email: '' }).then(() => {
this.setState({ awaiting: false });
});
}));
break;
}
case "removeNickname": {
this.setState({ nicknameToSet: "", awaiting: true, type: "Removing from group" }, (() => {
api.contactEdit(props.path, ship, { nickname: "" }).then(() => {
this.setState({awaiting: false});
case 'removeNickname': {
this.setState({ nicknameToSet: '', awaiting: true, type: 'Removing from group' }, (() => {
api.contactEdit(props.path, ship, { nickname: '' }).then(() => {
this.setState({ awaiting: false });
});
}));
break;
}
case "removePhone": {
this.setState({ phoneToSet: "", awaiting: true, type: "Removing from group" }, (() => {
api.contactEdit(props.path, ship, { phone: "" }).then(() => {
this.setState({awaiting: false});
case 'removePhone': {
this.setState({ phoneToSet: '', awaiting: true, type: 'Removing from group' }, (() => {
api.contactEdit(props.path, ship, { phone: '' }).then(() => {
this.setState({ awaiting: false });
});
}));
break;
}
case "removeWebsite": {
this.setState({ websiteToSet: "", awaiting: true, type: "Removing from group" }, (() => {
api.contactEdit(props.path, ship, { website: "" }).then(() => {
this.setState({awaiting: false});
case 'removeWebsite': {
this.setState({ websiteToSet: '', awaiting: true, type: 'Removing from group' }, (() => {
api.contactEdit(props.path, ship, { website: '' }).then(() => {
this.setState({ awaiting: false });
});
}));
break;
}
case "removeNotes": {
this.setState({ notesToSet: "", awaiting: true, type: "Removing from group" }, (() => {
api.contactEdit(props.path, ship, { notes: "" }).then(() => {
this.setState({awaiting: false});
case 'removeAvatar': {
this.setState({
avatarToSet: null,
awaiting: true,
type: 'Removing from group'
}, (() => {
api.contactEdit(props.path, ship, { avatar: null }).then(() => {
this.setState({ awaiting: false });
});
}));
break;
}
case 'removeNotes': {
this.setState({ notesToSet: '', awaiting: true, type: 'Removing from group' }, (() => {
api.contactEdit(props.path, ship, { notes: '' }).then(() => {
this.setState({ awaiting: false });
});
}));
break;
@ -264,11 +301,12 @@ export class ContactCard extends Component {
shareWithGroup() {
const { props, state } = this;
let defaultVal = props.share ? {
const defaultVal = props.share ? {
nickname: props.rootIdentity.nickname,
email: props.rootIdentity.email,
phone: props.rootIdentity.phone,
website: props.rootIdentity.website,
avatar: !!props.rootIdentity.avatar ? { url: props.rootIdentity.avatar } : null,
notes: props.rootIdentity.notes,
color: uxToHex(props.rootIdentity.color)
} : {
@ -276,39 +314,44 @@ export class ContactCard extends Component {
email: props.contact.email,
phone: props.contact.phone,
website: props.contact.website,
avatar: !!props.contact.avatar ? { url: props.contact.avatar } : null,
notes: props.contact.notes,
color: props.contact.color
};
let contact = {
const contact = {
nickname: this.pickFunction(state.nickNameToSet, defaultVal.nickname),
email: this.pickFunction(state.emailToSet, defaultVal.email),
phone: this.pickFunction(state.phoneToSet, defaultVal.phone),
website: this.pickFunction(state.websiteToSet, defaultVal.website),
notes: this.pickFunction(state.notesToSet, defaultVal.notes),
color: this.pickFunction(state.colorToSet, defaultVal.color),
avatar: null
avatar: this.pickFunction(
!!state.avatarToSet ? { url: state.avatarToSet } : null,
defaultVal.avatar
)
};
this.setState({awaiting: true, type: "Sharing with group"}, (() => {
this.setState({ awaiting: true, type: 'Sharing with group' }, (() => {
api.contactView.share(
`~${props.ship}`, props.path, `~${window.ship}`, contact
).then(() => {
props.history.push(`/~groups/view${props.path}/${window.ship}`)
props.history.push(`/~groups/view${props.path}/${window.ship}`);
});
}))
}));
}
removeFromGroup() {
removeSelfFromGroup() {
const { props } = this;
// share empty contact so that we can remove ourselves from group
// if we haven't shared yet
let contact = {
nickname: "",
email: "",
phone: "",
website: "",
notes: "",
color: "000000",
const contact = {
nickname: '',
email: '',
phone: '',
website: '',
notes: '',
color: '000000',
avatar: null
};
@ -316,24 +359,46 @@ export class ContactCard extends Component {
`~${props.ship}`, props.path, `~${window.ship}`, contact
);
this.setState({awaiting: true, type: "Removing from group"}, (() => {
api.contactHook.remove(props.path, `~${props.ship}`).then(() => {
let destination = (props.ship === window.ship)
? "" : props.path;
this.setState({awaiting: false});
props.history.push(`/~groups${destination}`);
this.setState({ awaiting: true, type: 'Removing from group' }, (() => {
api.contactView.delete(props.path).then(() => {
this.setState({ awaiting: false });
props.history.push(`/~groups`);
});
}))
}));
}
removeOtherFromGroup() {
const { props } = this;
this.setState({ awaiting: true, type: 'Removing from group' }, (() => {
api.contactView.remove(props.path, `~${props.ship}`).then(() => {
this.setState({ awaiting: false });
props.history.push(`/~groups${props.path}`);
});
}));
}
uploadSuccess(url) {
this.setState({
avatarToSet: url
}, () => {
this.setField('avatar');
});
}
uploadError(error) {
// no-op for now
}
renderEditCard() {
const { props, state } = this;
// if this is our first edit in a new group, propagate from root identity
let defaultValue = props.share ? {
const defaultValue = props.share ? {
nickname: props.rootIdentity.nickname,
email: props.rootIdentity.email,
phone: props.rootIdentity.phone,
website: props.rootIdentity.website,
avatar: props.rootIdentity.avatar,
notes: props.rootIdentity.notes,
color: props.rootIdentity.color
} : {
@ -341,67 +406,83 @@ export class ContactCard extends Component {
email: props.contact.email,
phone: props.contact.phone,
website: props.contact.website,
avatar: props.contact.avatar,
notes: props.contact.notes,
color: props.contact.color
};
let shipType = this.shipParser(props.ship);
const shipType = this.shipParser(props.ship);
let defaultColor = !!defaultValue.color ? defaultValue.color : "000000";
let defaultColor = defaultValue.color ? defaultValue.color : '000000';
defaultColor = uxToHex(defaultColor);
let currentColor = !!state.colorToSet ? state.colorToSet : defaultColor;
let currentColor = state.colorToSet ? state.colorToSet : defaultColor;
currentColor = uxToHex(currentColor);
let sigilColor = "";
let hasAvatar =
'avatar' in props.contact && props.contact.avatar !== "TODO";
const avatar = ('avatar' in props.contact && props.contact.avatar !== null)
? <img className="dib h-auto"
width={128}
src={props.contact.avatar}
/>
: <span className="dn"></span>;
if (!hasAvatar) {
sigilColor = (
<div className="tl mt4 mb4 w-auto ml-auto mr-auto"
style={{ width: "fit-content" }}>
<p className="f9 gray2 lh-copy">Sigil Color</p>
<textarea
className={"b--gray4 b--gray2-d black white-d bg-gray0-d f7 ba db pl2 " +
"focus-b--black focus-b--white-d"}
onChange={this.sigilColorSet}
defaultValue={defaultColor}
key={"default" + defaultColor}
onBlur={(() => this.setField("color"))}
style={{
resize: "none",
height: 40,
paddingTop: 10,
width: 114
}}>
</textarea>
</div>
);
}
let removeImage = hasAvatar ? (
<div>
<button className="f9 black pointer db"
onClick={() => this.setField("removeAvatar")}>
Remove photo
</button>
</div>
) : "";
let avatar = (hasAvatar)
? <img className="dib h-auto" width={128} src={props.contact.avatar} />
: <Sigil
ship={props.ship}
size={128}
color={"#" + currentColor}
key={"avatar" + currentColor} />;
const imageSetter = (!props.share) ? (
<span className="db">
<p className="f9 gray2 db pb1">Avatar image url</p>
<span className="cf db">
<span className="w-20 fl pt1">
<S3Upload
className="fr pr3"
configuration={props.s3.configuration}
credentials={props.s3.credentials}
uploadSuccess={this.uploadSuccess.bind(this)}
uploadError={this.uploadError.bind(this)}
/>
</span>
<EditElement
className="fr w-80"
defaultValue={defaultValue.avatar}
onChange={this.avatarToSet}
onDeleteClick={() => this.setField('removeAvatar')}
onSaveClick={() => this.setField('avatar')}
showButtons={!props.share}
/>
</span>
</span>
) : (<span className="dn"></span>);
return (
<div className="w-100 mt8 flex justify-center pa4 pt8 pt0-l pa0-xl pt4-xl pb8">
<div className="w-100 mw6 tc">
{avatar}
{sigilColor}
{removeImage}
{imageSetter}
<Sigil
ship={props.ship}
size={128}
color={'#' + currentColor}
key={'avatar' + currentColor}
/>
<div className="tl mt4 mb4 w-auto ml-auto mr-auto"
style={{ width: 'fit-content' }}
>
<p className="f9 gray2 lh-copy">Sigil Color</p>
<textarea
className={'b--gray4 b--gray2-d black white-d bg-gray0-d f7 ba db pl2 ' +
'focus-b--black focus-b--white-d'}
onChange={this.sigilColorSet}
defaultValue={defaultColor}
key={'default' + defaultColor}
onKeyPress={ e => !e.key.match(/[0-9a-f]/i) ? e.preventDefault() : null}
onBlur={(() => this.setField('color'))}
maxLength={6}
style={{
resize: 'none',
height: 40,
paddingTop: 10,
width: 114
}}
>
</textarea>
</div>
<div className="w-100 pt8 pb8 lh-copy tl">
<p className="f9 gray2">Ship Name</p>
<p className="f8 mono">~{props.ship}</p>
@ -412,38 +493,43 @@ export class ContactCard extends Component {
title="Nickname"
defaultValue={defaultValue.nickname}
onChange={this.nickNameToSet}
onDeleteClick={() => this.setField("removeNickname")}
onSaveClick={() => this.setField("nickname")}
showButtons={!props.share} />
onDeleteClick={() => this.setField('removeNickname')}
onSaveClick={() => this.setField('nickname')}
showButtons={!props.share}
/>
<EditElement
title="Email"
defaultValue={defaultValue.email}
onChange={this.emailToSet}
onDeleteClick={() => this.setField("removeEmail")}
onSaveClick={() => this.setField("email")}
showButtons={!props.share} />
onDeleteClick={() => this.setField('removeEmail')}
onSaveClick={() => this.setField('email')}
showButtons={!props.share}
/>
<EditElement
title="Phone"
defaultValue={defaultValue.phone}
onChange={this.phoneToSet}
onDeleteClick={() => this.setField("removePhone")}
onSaveClick={() => this.setField("phone")}
showButtons={!props.share} />
onDeleteClick={() => this.setField('removePhone')}
onSaveClick={() => this.setField('phone')}
showButtons={!props.share}
/>
<EditElement
title="Website"
defaultValue={defaultValue.website}
onChange={this.websiteToSet}
onDeleteClick={() => this.setField("removeWebsite")}
onSaveClick={() => this.setField("website")}
showButtons={!props.share} />
onDeleteClick={() => this.setField('removeWebsite')}
onSaveClick={() => this.setField('website')}
showButtons={!props.share}
/>
<EditElement
title="Notes"
defaultValue={defaultValue.notes}
onChange={this.notesToSet}
onDeleteClick={() => this.setField("removeNotes")}
onSaveClick={() => this.setField("notes")}
onDeleteClick={() => this.setField('removeNotes')}
onSaveClick={() => this.setField('notes')}
resizable={true}
showButtons={!props.share} />
showButtons={!props.share}
/>
</div>
</div>
</div>
@ -452,22 +538,23 @@ export class ContactCard extends Component {
renderCard() {
const { props } = this;
let shipType = this.shipParser(props.ship);
let currentColor = props.contact.color ? props.contact.color : "0x0";
let hexColor = uxToHex(currentColor);
const shipType = this.shipParser(props.ship);
const currentColor = props.contact.color ? props.contact.color : '0x0';
const hexColor = uxToHex(currentColor);
let avatar =
('avatar' in props.contact && props.contact.avatar !== "TODO") ?
const avatar =
('avatar' in props.contact && props.contact.avatar !== null) ?
<img className="dib h-auto" width={128} src={props.contact.avatar} /> :
<Sigil
ship={props.ship}
size={128}
color={"#" + hexColor}
key={hexColor} />;
color={'#' + hexColor}
key={hexColor}
/>;
let websiteHref =
(props.contact.website && props.contact.website.includes("://")) ?
props.contact.website : "http://" + props.contact.website;
const websiteHref =
(props.contact.website && props.contact.website.includes('://')) ?
props.contact.website : 'http://' + props.contact.website;
return (
<div className="w-100 mt8 flex justify-center pa4 pt8 pt0-l pa0-xl pt4-xl">
@ -480,39 +567,41 @@ export class ContactCard extends Component {
<p className="f8">{shipType}</p>
<hr className="mv8 gray4 b--gray4 bb-0 b--solid" />
<div>
{ !!props.contact.nickname ? (
{ props.contact.nickname ? (
<div>
<p className="f9 gray2">Nickname</p>
<p className="f8">{props.contact.nickname}</p>
</div>
) : null
}
{ !!props.contact.email ? (
{ props.contact.email ? (
<div>
<p className="f9 mt6 gray2">Email</p>
<p className="f8">{props.contact.email}</p>
</div>
) : null
}
{ !!props.contact.phone ? (
{ props.contact.phone ? (
<div>
<p className="f9 mt6 gray2">Phone</p>
<p className="f8">{props.contact.phone}</p>
</div>
) : null
}
{ !!props.contact.website ? (
{ props.contact.website ? (
<div>
<p className="f9 mt6 gray2">website</p>
<a target="_blank"
rel="noopener noreferrer"
className="bb b--black f8"
href={websiteHref}>
href={websiteHref}
>
{props.contact.website}
</a>
</div>
) : null
}
{ !!props.contact.notes ? (
{ props.contact.notes ? (
<div>
<p className="f9 mt6 gray2">notes</p>
<p className="f8">{props.contact.notes}</p>
@ -530,32 +619,33 @@ export class ContactCard extends Component {
const { props, state } = this;
let editInfoText =
state.edit ? "Finish" : "Edit";
state.edit ? 'Finish' : 'Edit';
if (props.share && state.edit) {
editInfoText = "Share";
editInfoText = 'Share';
}
let ourOpt = (props.ship === window.ship) ? "dib" : "dn";
const ourOpt = (props.ship === window.ship) ? 'dib' : 'dn';
let adminOpt =
const adminOpt =
((props.path.includes(`~${window.ship}/`)) || ((props.ship === window.ship) &&
!(props.path.includes('/~/default'))))
? "dib" : "dn";
? 'dib' : 'dn';
let meLink = (props.path === "/~/default")
? `/~groups` : `/~groups/detail${props.path}`;
const meLink = (props.path === '/~/default')
? '/~groups' : `/~groups/detail${props.path}`;
let card = state.edit ? this.renderEditCard() : this.renderCard();
const card = state.edit ? this.renderEditCard() : this.renderCard();
return (
<div className="w-100 h-100 overflow-hidden">
<div
className={
"flex justify-between w-100 bg-white bg-gray0-d " +
"bb b--gray4 b--gray1-d "
}>
'flex justify-between w-100 bg-white bg-gray0-d ' +
'bb b--gray4 b--gray1-d '
}
>
<div className="f9 mv4 mh3 pt1 dib w-100">
<Link to={meLink}>
{"⟵"}
{'⟵'}
</Link>
</div>
<div className="flex">
@ -568,20 +658,22 @@ export class ContactCard extends Component {
}
}}
className={
`white-d bg-gray0-d mv4 mh3 f9 pa1 pointer flex-shrink-0 ` +
'white-d bg-gray0-d mv4 mh3 f9 pa1 pointer flex-shrink-0 ' +
ourOpt
}>
}
>
{editInfoText}
</button>
</div>
<button
className={
`bg-gray0-d mv4 mh3 pa1 f9 red2 pointer flex-shrink-0 ` + adminOpt
'bg-gray0-d mv4 mh3 pa1 f9 red2 pointer flex-shrink-0 ' + adminOpt
}
onClick={this.removeFromGroup}>
onClick={props.ship === window.ship ? this.removeSelfFromGroup : this.removeOtherFromGroup}
>
{props.ship === window.ship
? "Leave Group"
: "Remove from Group"}
? 'Leave Group'
: 'Remove from Group'}
</button>
</div>
<div className="h-100 w-100 overflow-x-hidden pb8 white-d">{card}</div>

View File

@ -3,33 +3,39 @@ import { Route, Link } from 'react-router-dom';
import { Sigil } from '../lib/icons/sigil';
import { uxToHex, cite } from '../../lib/util';
export class ContactItem extends Component {
render() {
const { props } = this;
let selectedClass = (props.selected) ? "bg-gray4 bg-gray1-d" : "";
let hexColor = uxToHex(props.color);
let name = (props.nickname) ? props.nickname : cite(props.ship);
const selectedClass = (props.selected) ? 'bg-gray4 bg-gray1-d' : '';
const hexColor = uxToHex(props.color);
const name = (props.nickname) ? props.nickname : cite(props.ship);
const prefix = props.share ? 'share' : 'view';
const suffix = !props.share ? `/${props.ship}` : '';
const img = (props.avatar !== null)
? <img className="dib" src={props.avatar} height={32} width={32} />
: <Sigil
ship={props.ship}
color={'#' + hexColor}
size={32}
key={`${props.ship}.sidebar.${hexColor}`}
/>;
let prefix = props.share ? 'share' : 'view';
let suffix = !props.share ? `/${props.ship}` : '';
return (
<Link to={`/~groups/${prefix}` + props.path + suffix}>
<div className=
{"pl4 pt1 pb1 f9 flex justify-start content-center " + selectedClass}
{'pl4 pt1 pb1 f9 flex justify-start content-center ' + selectedClass}
>
<Sigil
ship={props.ship}
color={"#" + hexColor}
size={32}
key={`${props.ship}.sidebar.${hexColor}`} />
{img}
<p
className={
"f9 w-70 dib v-mid ml2 nowrap " +
((props.nickname) ? "" : "mono")}
'f9 w-70 dib v-mid ml2 nowrap ' +
((props.nickname) ? '' : 'mono')}
style={{ paddingTop: 6 }}
title={props.ship}>
title={props.ship}
>
{name}
</p>
</div>

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { Route, Link } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { ContactItem } from '/components/lib/contact-item';
import { ShareSheet } from '/components/lib/share-sheet';
import { Sigil } from '../lib/icons/sigil';
@ -11,29 +11,30 @@ export class ContactSidebar extends Component {
super(props);
this.state = {
awaiting: false
}
};
}
render() {
const { props } = this;
let group = new Set(Array.from(props.group));
let responsiveClasses =
props.activeDrawer === "contacts" ? "db" : "dn db-ns";
const group = new Set(Array.from(props.group));
const responsiveClasses =
props.activeDrawer === 'contacts' ? 'db' : 'dn db-ns';
let me = (window.ship in props.contacts)
const me = (window.ship in props.contacts)
? props.contacts[window.ship]
: (window.ship in props.defaultContacts)
? props.defaultContacts[window.ship]
: { color: '0x0', nickname: null };
: { color: '0x0', nickname: null, avatar: null };
let shareSheet =
const shareSheet =
!(window.ship in props.contacts) ?
( <ShareSheet
ship={window.ship}
nickname={me.nickname}
avatar={me.avatar}
color={me.color}
path={props.path}
selected={props.path + "/" + window.ship === props.selectedContact}
selected={props.path + '/' + window.ship === props.selectedContact}
/>
) : (
<>
@ -41,27 +42,29 @@ export class ContactSidebar extends Component {
<ContactItem
ship={window.ship}
nickname={me.nickname}
avatar={me.avatar}
color={me.color}
path={props.path}
selected={props.path + "/" + window.ship === props.selectedContact}
selected={props.path + '/' + window.ship === props.selectedContact}
/>
</>
);
group.delete(window.ship);
let contactItems =
const contactItems =
Object.keys(props.contacts)
.filter(c => c !== window.ship)
.map((contact) => {
group.delete(contact);
let path = props.path + "/" + contact;
let obj = props.contacts[contact];
const path = props.path + '/' + contact;
const obj = props.contacts[contact];
return (
<ContactItem
key={contact}
ship={contact}
nickname={obj.nickname}
color={obj.color}
avatar={obj.avatar}
path={props.path}
selected={path === props.selectedContact}
share={false}
@ -69,62 +72,68 @@ export class ContactSidebar extends Component {
);
});
let adminOpt = (props.path.includes(`~${window.ship}/`))
? "dib" : "dn";
const adminOpt = (props.path.includes(`~${window.ship}/`))
? 'dib' : 'dn';
let groupItems =
const groupItems =
Array.from(group).map((member) => {
return (
<div
key={member}
className={"pl4 pt1 pb1 f9 flex justify-start content-center " +
"bg-white bg-gray0-d relative"}>
className={'pl4 pt1 pb1 f9 flex justify-start content-center ' +
'bg-white bg-gray0-d relative'}
>
<Sigil
ship={member}
color="#000000"
size={32}
classes="mix-blend-diff"
/>
/>
<p className="f9 w-70 dib v-mid ml2 nowrap mono truncate"
style={{ paddingTop: 6, color: '#aaaaaa' }}
title={member}>
title={member}
>
{cite(member)}
</p>
<p className={"v-mid f9 mh3 red2 pointer " + adminOpt}
style={{paddingTop: 6}}
<p className={'v-mid f9 mh3 red2 pointer ' + adminOpt}
style={{ paddingTop: 6 }}
onClick={() => {
this.setState({awaiting: true}, (() => {
this.setState({ awaiting: true }, (() => {
props.api.groupRemove(props.path, [`~${member}`])
.then(() => {
this.setState({awaiting: false})
})
}))
}}>
this.setState({ awaiting: false });
});
}));
}}
>
Remove
</p>
</div>
);
});
let detailHref = `/~groups/detail${props.path}`
const detailHref = `/~groups/detail${props.path}`;
return (
<div className={"bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100 " +
"flex-basis-100-s flex-basis-30-ns mw5-m mw5-l mw5-xl relative " +
"overflow-hidden flex-shrink-0 " + responsiveClasses}>
<div className={'bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100 ' +
'flex-basis-100-s flex-basis-30-ns mw5-m mw5-l mw5-xl relative ' +
'overflow-hidden flex-shrink-0 ' + responsiveClasses}
>
<div className="pt3 pb5 pl3 f8 db dn-m dn-l dn-xl">
<Link to="/~groups/">{"⟵ All Groups"}</Link>
<Link to="/~groups/">{'⟵ All Groups'}</Link>
</div>
<div className="overflow-auto h-100">
<Link
to={"/~groups/add" + props.path}
to={'/~groups/add' + props.path}
className={((props.path.includes(window.ship))
? "dib"
: "dn")}>
? 'dib'
: 'dn')}
>
<p className="f9 pl4 pt0 pt4-m pt4-l pt4-xl green2 bn">Add to Group</p>
</Link>
<Link to={detailHref}
className="dib dn-m dn-l dn-xl f9 pl4 pt0 pt4-m pt4-l pt4-xl gray2 bn">Channels</Link>
className="dib dn-m dn-l dn-xl f9 pl4 pt0 pt4-m pt4-l pt4-xl gray2 bn"
>Channels</Link>
{shareSheet}
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">Members</h2>
{contactItems}

View File

@ -23,8 +23,10 @@ export class EditElement extends Component {
? { resize: "vertical", height: 40, paddingTop: 10 }
: { resize: "none", height: 40, paddingTop: 10 }
let classes = !!props.className ? "pb4 " + props.className : "pb4";
return (
<div className="pb4">
<div className={classes}>
<p className="f9 gray2">{props.title}</p>
<div className="w-100 flex">
<textarea

View File

@ -1,28 +1,44 @@
import React, { Component } from "react";
import { sigil, reactRenderer } from "urbit-sigil-js";
import React, { Component } from 'react';
import { sigil, reactRenderer } from 'urbit-sigil-js';
export class Sigil extends Component {
render() {
const { props } = this;
let classes = props.classes || "";
const classes = props.classes || '';
const rgb = {
r: parseInt(props.color.slice(1, 3), 16),
g: parseInt(props.color.slice(3, 5), 16),
b: parseInt(props.color.slice(5, 7), 16)
};
const brightness = ((299 * rgb.r) + (587 * rgb.g) + (114 * rgb.b)) / 1000;
const whiteBrightness = 255;
let foreground = 'white';
if ((whiteBrightness - brightness) < 50) {
foreground = 'black';
}
if (props.ship.length > 14) {
return (
<div
className={"bg-black dib " + classes}
style={{ width: props.size, height: props.size }}></div>
className={'bg-black dib ' + classes}
style={{ width: props.size, height: props.size }}
></div>
);
} else {
return (
<div
className={"dib " + classes}
style={{ flexBasis: 32, backgroundColor: props.color }}>
className={'dib ' + classes}
style={{ flexBasis: props.size, backgroundColor: props.color }}
>
{sigil({
patp: props.ship,
renderer: reactRenderer,
size: props.size,
colors: [props.color, "white"]
colors: [props.color, foreground]
})}
</div>
);

View File

@ -0,0 +1,96 @@
import React, { Component } from 'react'
import S3Client from '/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 (
!!creds &&
'endpoint' in creds &&
'accessKeyId' in creds &&
'secretAccessKey' in creds &&
creds.endpoint !== '' &&
creds.accessKeyId !== '' &&
creds.secretAccessKey !== '' &&
!!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; }
let files = this.inputRef.current.files;
if (files.length <= 0) { return; }
let file = files.item(0);
let 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 {
let 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="/~groups/img/ImageUpload.png"
width="32"
height="32"
onClick={this.onClick.bind(this)} />
</div>
);
}
}
}

View File

@ -16,6 +16,7 @@ export class ShareSheet extends Component {
<p className="pt4 pb2 pl4 pr4 f8 gray2 f9">Group Identity</p>
<ContactItem
key={props.ship}
avatar={props.avatar}
ship={props.ship}
nickname={props.nickname}
color={props.color}

View File

@ -33,7 +33,9 @@ export class Root extends Component {
let defaultContacts =
(!!state.contacts && '/~/default' in state.contacts) ?
state.contacts['/~/default'] : {};
let groups = !!state.groups ? state.groups : {};
let s3 = !!state.s3 ? state.s3 : {};
let invites =
(!!state.invites && '/contacts' in state.invites) ?
@ -211,6 +213,7 @@ export class Root extends Component {
ship={window.ship}
share={true}
rootIdentity={rootIdentity}
s3={s3}
/>
</Skeleton>
);
@ -259,6 +262,7 @@ export class Root extends Component {
path={groupPath}
ship={props.match.params.contact}
rootIdentity={rootIdentity}
s3={s3}
/>
</Skeleton>
);
@ -283,6 +287,7 @@ export class Root extends Component {
path="/~/default"
contact={me}
ship={window.ship}
s3={s3}
/>
</Skeleton>
);

View File

@ -0,0 +1,53 @@
export default class S3Client {
constructor() {
this.s3 = null;
this.endpoint = "";
this.accessKeyId = "";
this.secretAccesskey = "";
}
setCredentials(endpoint, accessKeyId, secretAccessKey) {
if (!window.AWS) {
setTimeout(() => {
this.setCredentials(endpoint, accessKeyId, secretAccessKey);
}, 2000);
return;
}
this.endpoint = new window.AWS.Endpoint(endpoint);
this.accessKeyId = accessKeyId;
this.secretAccessKey = secretAccessKey;
this.s3 =
new window.AWS.S3({
endpoint: this.endpoint,
credentials: new window.AWS.Credentials({
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey
})
});
}
upload(bucket, filename, buffer) {
let params = {
Bucket: bucket,
Key: filename,
Body: buffer,
ACL: 'public-read'
};
return new Promise((resolve, reject) => {
if (!this.s3) {
reject({ error: 'S3 not initialized!' });
return;
}
this.s3.upload(params, (error, data) => {
if (error) {
reject({ error });
} else {
resolve(data);
}
});
});
}
}

View File

@ -0,0 +1,81 @@
import _ from 'lodash';
export class S3Reducer {
reduce(json, state) {
let data = _.get(json, 's3-update', false);
if (data) {
this.credentials(data, state);
this.configuration(data, state);
this.currentBucket(data, state);
this.addBucket(data, state);
this.removeBucket(data, state);
this.endpoint(data, state);
this.accessKeyId(data, state);
this.secretAccessKey(data, state);
}
}
credentials(json, state) {
let data = _.get(json, 'credentials', false);
if (data) {
state.s3.credentials = data;
}
}
configuration(json, state) {
let data = _.get(json, 'configuration', false);
if (data) {
state.s3.configuration = {
buckets: new Set(data.buckets),
currentBucket: data.currentBucket
};
}
}
currentBucket(json, state) {
let data = _.get(json, 'setCurrentBucket', false);
if (data) {
state.s3.configuration.currentBucket = data;
}
}
addBucket(json, state) {
let data = _.get(json, 'addBucket', false);
if (data) {
state.s3.configuration.buckets =
state.s3.configuration.buckets.add(data);
}
}
removeBucket(json, state) {
let data = _.get(json, 'removeBucket', false);
if (data) {
state.s3.configuration.buckets =
state.s3.configuration.buckets.delete(data);
}
}
endpoint(json, state) {
let data = _.get(json, 'setEndpoint', false);
if (data) {
state.s3.credentials.endpoint = data;
}
}
accessKeyId(json, state) {
let data = _.get(json, 'setAccessKeyId', false);
if (data) {
state.s3.credentials.accessKeyId = data;
}
}
secretAccessKey(json, state) {
let data = _.get(json, 'setSecretAccessKey', false);
if (data) {
state.s3.credentials.secretAccessKey = data;
}
}
}

View File

@ -4,19 +4,13 @@ import { GroupUpdateReducer } from '/reducers/group-update';
import { InviteUpdateReducer } from '/reducers/invite-update';
import { PermissionUpdateReducer } from '/reducers/permission-update';
import { MetadataReducer } from '/reducers/metadata-update.js';
import { S3Reducer } from '/reducers/s3.js';
import { LocalReducer } from '/reducers/local.js';
class Store {
constructor() {
this.state = {
contacts: {},
groups: {},
associations: {},
permissions: {},
invites: {},
selectedGroups: []
};
this.state = this.initialState();
this.initialReducer = new InitialReducer();
this.groupUpdateReducer = new GroupUpdateReducer();
@ -24,10 +18,23 @@ class Store {
this.contactUpdateReducer = new ContactUpdateReducer();
this.inviteUpdateReducer = new InviteUpdateReducer();
this.metadataReducer = new MetadataReducer();
this.s3Reducer = new S3Reducer();
this.localReducer = new LocalReducer();
this.setState = () => {};
}
initialState() {
return {
contacts: {},
groups: {},
associations: {},
permissions: {},
invites: {},
s3: {},
selectedGroups: []
};
}
setStateHandler(setState) {
this.setState = setState;
}
@ -35,6 +42,11 @@ class Store {
handleEvent(data) {
let json = data.data;
if ('clear' in json && json.clear) {
this.setState(this.initialState());
return;
}
console.log(json);
this.initialReducer.reduce(json, this.state);
this.groupUpdateReducer.reduce(json, this.state);
@ -42,6 +54,7 @@ class Store {
this.contactUpdateReducer.reduce(json, this.state);
this.inviteUpdateReducer.reduce(json, this.state);
this.metadataReducer.reduce(json, this.state);
this.s3Reducer.reduce(json, this.state);
this.localReducer.reduce(json, this.state);
this.setState(this.state);

View File

@ -5,48 +5,72 @@ import urbitOb from 'urbit-ob';
export class Subscription {
constructor() {
this.firstRoundComplete = false;
this.secondRoundComplete = false;
}
start() {
if (api.authTokens) {
this.initializeContacts();
this.firstRound();
window.urb.setOnChannelError(this.onChannelError.bind(this));
} else {
console.error("~~~ ERROR: Must set api.authTokens before operation ~~~");
}
}
initializeContacts() {
api.bind('/primary', 'PUT', api.authTokens.ship, 'contact-view',
onChannelError(err) {
console.error('event source error: ', err);
console.log('initiating new channel');
this.firstRoundComplete = false;
this.secondRoundComplete = false;
setTimeout(2000, () => {
store.handleEvent({
data: { clear : true}
});
this.start();
});
}
subscribe(path, app) {
api.bind(path, 'PUT', api.authTokens.ship, app,
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this));
api.bind('/primary', 'PUT', api.authTokens.ship, 'invite-view',
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this));
api.bind('/all', 'PUT', api.authTokens.ship, 'group-store',
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this));
api.bind('/all', 'PUT', api.authTokens.ship, 'metadata-store',
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this));
(err) => {
console.log(err);
this.subscribe(path, app);
},
() => {
this.subscribe(path, app);
});
}
firstRound() {
this.subscribe('/primary', 'contact-view');
}
secondRound() {
this.subscribe('/all', 'group-store');
this.subscribe('/all', 'metadata-store');
}
thirdRound() {
this.subscribe('/synced', 'contact-hook');
this.subscribe('/primary', 'invite-view');
this.subscribe('/all', 's3-store');
}
handleEvent(diff) {
if (!this.firstRoundComplete) {
this.firstRoundComplete = true;
this.secondRound();
} else if (!this.secondRoundComplete) {
this.secondRoundComplete = true;
this.thirdRound();
}
store.handleEvent(diff);
}
handleError(err) {
console.error(err);
}
handleQuitSilently(quit) {
// no-op
}
handleQuitAndResubscribe(quit) {
// TODO: resubscribe
}
}
export let subscription = new Subscription();

View File

@ -17,7 +17,7 @@ export class Sigil extends Component {
return (
<div
className={"dib " + classes}
style={{ flexBasis: 16, backgroundColor: props.color }}>
style={{ flexBasis: props.size, backgroundColor: props.color }}>
{sigil({
patp: props.ship,
renderer: reactRenderer,

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'
import React, { Component } from 'react';
import { Sigil } from './icons/sigil';
import { cite } from '../../lib/util';
import moment from 'moment';
@ -13,7 +13,7 @@ export class CommentItem extends Component {
componentDidMount() {
this.updateTimeSinceNewestMessageInterval = setInterval( () => {
this.setState({timeSinceComment: this.getTimeSinceComment()});
this.setState({ timeSinceComment: this.getTimeSinceComment() });
}, 60000);
}
@ -25,30 +25,35 @@ export class CommentItem extends Component {
}
getTimeSinceComment() {
return !!this.props.time ?
return this.props.time ?
moment.unix(this.props.time / 1000).from(moment.utc())
: '';
}
render() {
let props = this.props;
const props = this.props;
let member = this.props.member || false;
const member = props.member || false;
let pending = !!this.props.pending ? "o-60" : "";
const pending = props.pending ? 'o-60' : '';
const img = (props.avatar)
? <img src={props.avatar} height={36} width={36} className="dib" />
: <Sigil
ship={'~' + props.ship}
size={36}
color={'#' + props.color}
classes={(member ? 'mix-blend-diff' : '')}
/>;
return (
<div className={"w-100 pv3 " + pending}>
<div className={'w-100 pv3 ' + pending}>
<div className="flex bg-white bg-gray0-d">
<Sigil
ship={"~" + props.ship}
size={36}
color={"#" + props.color}
classes={(member ? "mix-blend-diff" : "")}
/>
{img}
<p className="gray2 f9 flex items-center ml2">
<span className={"black white-d " + props.nameClass}
title={props.ship}>
<span className={'black white-d ' + props.nameClass}
title={props.ship}
>
{props.nickname ? props.nickname : cite(props.ship)}
</span>
<span className="ml2">
@ -58,8 +63,8 @@ export class CommentItem extends Component {
</div>
<p className="inter f8 pv3 white-d">{props.content}</p>
</div>
)
);
}
}
export default CommentItem
export default CommentItem;

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'
import React, { Component } from 'react';
import { CommentItem } from './comment-item';
import { CommentsPagination } from './comments-pagination';
@ -12,12 +12,12 @@ export class Comments extends Component {
}
componentDidMount() {
let page = this.props.commentPage;
const page = this.props.commentPage;
if (!this.props.comments ||
!this.props.comments[page] ||
this.props.comments.local[page]
) {
this.setState({requested: this.props.commentPage});
this.setState({ requested: this.props.commentPage });
api.getCommentsPage(
this.props.resourcePath,
this.props.url,
@ -26,35 +26,34 @@ export class Comments extends Component {
}
render() {
let props = this.props;
const props = this.props;
let page = props.commentPage;
const page = props.commentPage;
let commentsObj = !!props.comments
const commentsObj = props.comments
? props.comments
: {};
let commentsPage = !!commentsObj[page]
const commentsPage = commentsObj[page]
? commentsObj[page]
: {};
let total = !!props.comments
const total = props.comments
? props.comments.totalPages
: 1;
let commentsList = Object.keys(commentsPage)
const commentsList = Object.keys(commentsPage)
.map((entry) => {
const commentObj = commentsPage[entry];
const { ship, time, udon } = commentObj;
let commentObj = commentsPage[entry]
let { ship, time, udon } = commentObj;
let contacts = !!props.contacts
const contacts = props.contacts
? props.contacts
: {};
const {nickname, color, member} = getContactDetails(contacts[ship]);
const { nickname, color, member, avatar } = getContactDetails(contacts[ship]);
let nameClass = nickname ? "inter" : "mono";
const nameClass = nickname ? 'inter' : 'mono';
return(
<CommentItem
@ -65,10 +64,11 @@ export class Comments extends Component {
nickname={nickname}
nameClass={nameClass}
color={color}
avatar={avatar}
member={member}
/>
)
})
);
});
return (
<div>
{commentsList}
@ -80,10 +80,11 @@ export class Comments extends Component {
linkIndex={props.linkIndex}
url={props.url}
commentPage={props.commentPage}
total={total}/>
total={total}
/>
</div>
)
);
}
}
export default Comments;
export default Comments;

View File

@ -1,28 +1,44 @@
import React, { Component } from "react";
import { sigil, reactRenderer } from "urbit-sigil-js";
import React, { Component } from 'react';
import { sigil, reactRenderer } from 'urbit-sigil-js';
export class Sigil extends Component {
render() {
const { props } = this;
let classes = props.classes || "";
const classes = props.classes || '';
const rgb = {
r: parseInt(props.color.slice(1, 3), 16),
g: parseInt(props.color.slice(3, 5), 16),
b: parseInt(props.color.slice(5, 7), 16)
};
const brightness = ((299 * rgb.r) + (587 * rgb.g) + (114 * rgb.b)) / 1000;
const whiteBrightness = 255;
let foreground = 'white';
if ((whiteBrightness - brightness) < 50) {
foreground = 'black';
}
if (props.ship.length > 14) {
return (
<div
className={"bg-black dib " + classes}
style={{ width: props.size, height: props.size }}></div>
className={'bg-black dib ' + classes}
style={{ width: props.size, height: props.size }}
></div>
);
} else {
return (
<div
className={"dib " + classes}
style={{ flexBasis: props.size, backgroundColor: props.color }}>
className={'dib ' + classes}
style={{ flexBasis: props.size, backgroundColor: props.color }}
>
{sigil({
patp: props.ship,
renderer: reactRenderer,
size: props.size,
colors: [props.color, "white"]
colors: [props.color, foreground]
})}
</div>
);

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'
import React, { Component } from 'react';
import moment from 'moment';
import { Sigil } from '/components/lib/icons/sigil';
@ -16,7 +16,7 @@ export class LinkItem extends Component {
componentDidMount() {
this.updateTimeSinceNewestMessageInterval = setInterval( () => {
this.setState({timeSinceLinkPost: this.getTimeSinceLinkPost()});
this.setState({ timeSinceLinkPost: this.getTimeSinceLinkPost() });
}, 60000);
}
@ -28,7 +28,7 @@ export class LinkItem extends Component {
}
getTimeSinceLinkPost() {
return !!this.props.timestamp ?
return this.props.timestamp ?
moment.unix(this.props.timestamp / 1000).from(moment.utc())
: '';
}
@ -38,61 +38,68 @@ export class LinkItem extends Component {
}
render() {
const props = this.props;
let props = this.props;
const mono = (props.nickname) ? 'inter white-d' : 'mono white-d';
let mono = (props.nickname) ? "inter white-d" : "mono white-d";
let URLparser = new RegExp(/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/);
const URLparser = new RegExp(/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/);
let hostname = URLparser.exec(props.url);
const seenState = props.seen
? "gray2"
: "green2 pointer";
? 'gray2'
: 'green2 pointer';
const seenAction = props.seen
? ()=>{}
: this.markPostAsSeen
? () => {}
: this.markPostAsSeen;
if (hostname) {
hostname = hostname[4];
}
let comments = props.comments + " comment" + ((props.comments === 1) ? "" : "s");
const comments = props.comments + ' comment' + ((props.comments === 1) ? '' : 's');
let member = this.props.member || false;
const member = this.props.member || false;
const img = (this.props.avatar)
? <img src={this.props.avatar} height={38} width={38} className="dib" />
: <Sigil
ship={'~' + props.ship}
size={38}
color={'#' + props.color}
classes={(member ? 'mix-blend-diff' : '')}
/>;
return (
<div className="w-100 pv3 flex bg-white bg-gray0-d">
<Sigil
ship={"~" + props.ship}
size={38}
color={"#" + props.color}
classes={(member ? "mix-blend-diff" : "")}
/>
{img}
<div className="flex flex-column ml2 flex-auto">
<a href={props.url}
className="w-100 flex"
target="_blank"
onClick={this.markPostAsSeen}>
onClick={this.markPostAsSeen}
>
<p className="f8 truncate">{props.title}
</p>
<span className="gray2 dib v-btm ml2 f8 flex-shrink-0">{hostname} </span>
</a>
<div className="w-100 pt1">
<span className={"f9 pr2 dib " + mono}
title={props.ship}>
<span className={'f9 pr2 dib ' + mono}
title={props.ship}
>
{(props.nickname)
? props.nickname
: cite(props.ship)}
</span>
<span
className={seenState + " f9 inter pr3 dib"}
onClick={this.markPostAsSeen}>
className={seenState + ' f9 inter pr3 dib'}
onClick={this.markPostAsSeen}
>
{this.state.timeSinceLinkPost}
</span>
<Link to=
{makeRoutePath(props.resourcePath, props.popout, props.page, props.url, props.linkIndex)}
onClick={this.markPostAsSeen}>
onClick={this.markPostAsSeen}
>
<span className="f9 inter gray2 dib">
{comments}
</span>
@ -100,8 +107,8 @@ export class LinkItem extends Component {
</div>
</div>
</div>
)
);
}
}
export default LinkItem
export default LinkItem;

View File

@ -1,14 +1,10 @@
import React, { Component } from 'react';
import classnames from 'classnames';
import { Sigil } from '/components/lib/icons/sigil';
import { uxToHex, cite } from '/lib/util';
export class MemberElement extends Component {
onRemove() {
const { props } = this;
//TODO don't really need to use link-view here, but should we anyway?
api.groups.remove(props.groupPath, [`~${props.ship}`]);
}
@ -25,7 +21,8 @@ export class MemberElement extends Component {
} else if (props.amOwner && window.ship !== props.ship) {
actionElem = (
<a onClick={this.onRemove.bind(this)}
className="w-20 dib list-ship black white-d f8 pointer">
className="w-20 dib list-ship black white-d f8 pointer"
>
Ban
</a>
);
@ -35,16 +32,21 @@ export class MemberElement extends Component {
);
}
let name = !!props.contact
const name = props.contact
? `${props.contact.nickname} (${cite(props.ship)})`
: `${cite(props.ship)}`;
let color = !!props.contact ? uxToHex(props.contact.color) : '000000';
const color = props.contact ? uxToHex(props.contact.color) : '000000';
const img = props.contact.avatar
? <img src={props.contact.avatar} height={32} width={32} className="dib" />
: <Sigil ship={props.ship} size={32} color={`#${color}`} />;
return (
<div className="flex mb2">
<Sigil ship={props.ship} size={32} color={`#${color}`} />
<p className={"w-70 mono list-ship dib v-mid black white-d ml2 nowrap f8"}
title={props.ship}>
{img}
<p className={'w-70 mono list-ship dib v-mid black white-d ml2 nowrap f8'}
title={props.ship}
>
{name}
</p>
{actionElem}

View File

@ -1,9 +1,9 @@
import React, { Component } from 'react'
import React, { Component } from 'react';
import { LinksTabBar } from './lib/links-tabbar';
import { LinkPreview } from './lib/link-detail-preview';
import { SidebarSwitcher } from '/components/lib/icons/icon-sidebar-switch.js';
import { api } from '../api';
import { Route, Link } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { Comments } from './lib/comments';
import { Spinner } from './lib/icons/icon-spinner';
import { LoadingScreen } from './loading';
@ -14,7 +14,7 @@ export class LinkDetail extends Component {
constructor(props) {
super(props);
this.state = {
comment: "",
comment: '',
data: props.data,
commentFocus: false,
pending: new Set(),
@ -43,14 +43,14 @@ export class LinkDetail extends Component {
if (this.props.url !== prevProps.url) {
this.updateData(this.props.data);
}
if (prevProps.comments && prevProps.comments["0"] &&
this.props.comments && this.props.comments["0"]) {
let prevFirstComment = prevProps.comments["0"][0];
let thisFirstComment = this.props.comments["0"][0];
if (prevProps.comments && prevProps.comments['0'] &&
this.props.comments && this.props.comments['0']) {
const prevFirstComment = prevProps.comments['0'][0];
const thisFirstComment = this.props.comments['0'][0];
if ((prevFirstComment && prevFirstComment.udon) &&
(thisFirstComment && thisFirstComment.udon)) {
if (this.state.pending.has(thisFirstComment.udon)) {
let pending = this.state.pending;
const pending = this.state.pending;
pending.delete(thisFirstComment.udon);
this.setState({
pending: pending
@ -61,9 +61,9 @@ export class LinkDetail extends Component {
}
onClickPost() {
let url = this.props.url || "";
const url = this.props.url || '';
let pending = this.state.pending;
const pending = this.state.pending;
pending.add(this.state.comment);
this.setState({ pending: pending, disabled: true });
@ -72,9 +72,8 @@ export class LinkDetail extends Component {
url,
this.state.comment
).then(() => {
this.setState({ comment: "", disabled: false });
this.setState({ comment: '', disabled: false });
});
}
setComment(event) {
@ -82,37 +81,37 @@ export class LinkDetail extends Component {
}
render() {
let props = this.props;
const props = this.props;
const data = this.state.data || props.data;
if (!data.ship) {
return <LoadingScreen/>;
return <LoadingScreen />;
}
let ship = data.ship || "zod";
let title = data.title || "";
let url = data.url || "";
const ship = data.ship || 'zod';
const title = data.title || '';
const url = data.url || '';
const commentCount = props.comments
? props.comments.totalItems
: data.commentCount || 0;
let comments = commentCount + " comment" + (commentCount === 1 ? "" : "s");
const comments = commentCount + ' comment' + (commentCount === 1 ? '' : 's');
const { nickname } = getContactDetails(props.contacts[ship]);
let activeClasses = this.state.comment
? "black white-d pointer"
: "gray2 b--gray2";
const activeClasses = this.state.comment
? 'black white-d pointer'
: 'gray2 b--gray2';
let focus = (this.state.commentFocus)
? "b--black b--white-d"
: "b--gray4 b--gray2-d";
const focus = (this.state.commentFocus)
? 'b--black b--white-d'
: 'b--gray4 b--gray2-d';
let our = getContactDetails(props.contacts[window.ship]);
const our = getContactDetails(props.contacts[window.ship]);
let pendingArray = Array.from(this.state.pending).map((com, i) => {
const pendingArray = Array.from(this.state.pending).map((com, i) => {
return(
<CommentItem
key={i}
@ -124,23 +123,25 @@ export class LinkDetail extends Component {
member={our.member}
time={new Date().getTime()}
/>
)
})
);
});
return (
<div className="h-100 w-100 overflow-hidden flex flex-column">
<div
className={"pl4 pt2 flex relative overflow-x-scroll " +
"overflow-x-auto-l overflow-x-auto-xl flex-shrink-0 " +
"bb bn-m bn-l bn-xl b--gray4"}
style={{ height: 48 }}>
className={'pl4 pt2 flex relative overflow-x-scroll ' +
'overflow-x-auto-l overflow-x-auto-xl flex-shrink-0 ' +
'bb bn-m bn-l bn-xl b--gray4'}
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}
/>
<Link
className="dib f9 fw4 pt2 gray2 lh-solid"
to={makeRoutePath(props.resourcePath, props.popout, props.page)}>
to={makeRoutePath(props.resourcePath, props.popout, props.page)}
>
{`<- ${props.resource.metadata.title}`}
</Link>
<LinksTabBar {...props} popout={props.popout} resourcePath={props.resourcePath} />
@ -159,19 +160,19 @@ export class LinkDetail extends Component {
time={this.state.data.time}
/>
<div className="relative">
<div className={"relative ba br1 mt6 mb6 " + focus}>
<div className={'relative ba br1 mt6 mb6 ' + focus}>
<textarea
className="w-100 bg-gray0-d white-d f8 pa2 pr8"
style={{
resize: "none",
resize: 'none',
height: 75
}}
placeholder="Leave a comment on this link"
onChange={this.setComment}
onKeyDown={e => {
onKeyDown={(e) => {
if (
(e.getModifierState("Control") || e.metaKey) &&
e.key === "Enter"
(e.getModifierState('Control') || e.metaKey) &&
e.key === 'Enter'
) {
this.onClickPost();
}
@ -182,14 +183,15 @@ export class LinkDetail extends Component {
/>
<button
className={
"f8 bg-gray0-d ml2 absolute " + activeClasses
'f8 bg-gray0-d ml2 absolute ' + activeClasses
}
disabled={!this.state.comment || this.state.disabled}
onClick={this.onClickPost.bind(this)}
style={{
bottom: 12,
right: 8
}}>
}}
>
Post
</button>
</div>

View File

@ -1,16 +1,15 @@
import React, { Component } from 'react'
import React, { Component } from 'react';
import { LoadingScreen } from './loading';
import { MessageScreen } from '/components/lib/message-screen';
import { LinksTabBar } from './lib/links-tabbar';
import { SidebarSwitcher } from '/components/lib/icons/icon-sidebar-switch.js';
import { Route, Link } from "react-router-dom";
import { Route, Link } from 'react-router-dom';
import { LinkItem } from '/components/lib/link-item.js';
import { LinkSubmit } from '/components/lib/link-submit.js';
import { Pagination } from '/components/lib/pagination.js';
import { makeRoutePath, getContactDetails } from '../lib/util';
//TODO Avatar support once it's in
export class Links extends Component {
constructor(props) {
super(props);
@ -38,44 +37,44 @@ export class Links extends Component {
}
render() {
let props = this.props;
const props = this.props;
if (!props.resource.metadata.title) {
return <LoadingScreen/>;
return <LoadingScreen />;
}
let linkPage = props.page;
const linkPage = props.page;
let links = !!props.links[linkPage]
const links = props.links[linkPage]
? props.links[linkPage]
: {};
let currentPage = !!props.page
const currentPage = props.page
? Number(props.page)
: 0;
let totalPages = !!props.links
const totalPages = props.links
? Number(props.links.totalPages)
: 1;
let LinkList = (<LoadingScreen/>);
let LinkList = (<LoadingScreen />);
if (props.links && props.links.totalItems === 0) {
LinkList = (
<MessageScreen text="Start by posting a link to this collection."/>
<MessageScreen text="Start by posting a link to this collection." />
);
} else if (Object.keys(links).length > 0) {
LinkList = Object.keys(links)
.map((linkIndex) => {
let linksObj = props.links[linkPage];
let { title, url, time, ship } = linksObj[linkIndex];
const linksObj = props.links[linkPage];
const { title, url, time, ship } = linksObj[linkIndex];
const seen = props.seen[url];
let members = {};
const members = {};
const commentCount = props.comments[url]
? props.comments[url].totalItems
: linksObj[linkIndex].commentCount || 0;
const {nickname, color, member} = getContactDetails(props.contacts[ship]);
const { nickname, color, member, avatar } = getContactDetails(props.contacts[ship]);
return (
<LinkItem
@ -89,33 +88,38 @@ export class Links extends Component {
nickname={nickname}
ship={ship}
color={color}
avatar={avatar}
member={member}
comments={commentCount}
resourcePath={props.resourcePath}
popout={props.popout}
/>
)
);
});
}
return (
<div
className="h-100 w-100 overflow-hidden flex flex-column">
className="h-100 w-100 overflow-hidden flex flex-column"
>
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: "1rem" }}>
<Link to="/~link">{"⟵ All Channels"}</Link>
style={{ height: '1rem' }}
>
<Link to="/~link">{'⟵ All Channels'}</Link>
</div>
<div
className={`pl4 pt2 flex relative overflow-x-scroll
overflow-x-auto-l overflow-x-auto-xl flex-shrink-0
bb b--gray4 b--gray1-d bg-gray0-d`}
style={{ height: 48 }}>
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}/>
popout={props.popout}
/>
<Link to={makeRoutePath(props.resourcePath, props.popout, props.page)} className="pt2">
<h2 className={`dib f9 fw4 lh-solid v-top`}>
<h2 className={'dib f9 fw4 lh-solid v-top'}>
{props.resource.metadata.title}
</h2>
</Link>
@ -123,12 +127,13 @@ export class Links extends Component {
{...props}
popout={props.popout}
page={props.page}
resourcePath={props.resourcePath}/>
resourcePath={props.resourcePath}
/>
</div>
<div className="w-100 mt6 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
<div className="flex">
<LinkSubmit resourcePath={props.resourcePath}/>
<LinkSubmit resourcePath={props.resourcePath} />
</div>
<div className="pb4">
{LinkList}
@ -144,8 +149,8 @@ export class Links extends Component {
</div>
</div>
</div>
)
);
}
}
export default Links;
export default Links;

View File

@ -24,6 +24,7 @@ export class Root extends Component {
constructor(props) {
super(props);
this.totalUnseen = 0;
this.state = store.state;
store.setStateHandler(this.setState.bind(this));
}
@ -42,8 +43,23 @@ export class Root extends Component {
const associations = !!state.associations ? state.associations : {link: {}, contacts: {}};
let links = !!state.links ? state.links : {};
let comments = !!state.comments ? state.comments : {};
const seen = !!state.seen ? state.seen : {};
const totalUnseen = _.reduce(
seen,
(acc, links) => acc + _.reduce(links, (total, hasSeen) => total + (hasSeen ? 0 : 1), 0),
0
);
if(totalUnseen !== this.totalUnseen) {
document.title = totalUnseen !== 0 ? `Links - (${totalUnseen})` : 'Links';
this.totalUnseen = totalUnseen;
}
const invites = state.invites ?
state.invites : {};

View File

@ -1,6 +1,3 @@
import _ from 'lodash';
import classnames from 'classnames';
export function makeRoutePath(
resource, popout = false, page = 0, url = null, index = 0, compage = 0
) {
@ -19,7 +16,8 @@ export function makeRoutePath(
}
export function amOwnerOfGroup(groupPath) {
if (!groupPath) return false;
if (!groupPath)
return false;
const groupOwner = /(\/~)?\/~([a-z-]{3,})\/.*/.exec(groupPath)[2];
return (window.ship === groupOwner);
}
@ -28,12 +26,13 @@ export function getContactDetails(contact) {
const member = !contact;
contact = contact || {
'nickname': '',
'avatar': 'TODO',
'avatar': null,
'color': '0x0'
};
const nickname = contact.nickname || '';
const color = uxToHex(contact.color || '0x0');
return {nickname, color, member};
const avatar = contact.avatar || null;
return { nickname, color, member, avatar };
}
// encodes string into base64url,
@ -55,8 +54,8 @@ export function base64urlDecode(string) {
}
export function isPatTa(str) {
const r = /^[a-z,0-9,\-,\.,_,~]+$/.exec(str)
return !!r;
const r = /^[a-z,0-9,\-,\.,_,~]+$/.exec(str);
return Boolean(r);
}
// encode the string into @ta-safe format, using logic from +wood.
@ -86,7 +85,7 @@ export function stringToTa(string) {
) {
add = char;
} else {
//TODO behavior for unicode doesn't match +wood's,
// TODO behavior for unicode doesn't match +wood's,
// but we can probably get away with that for now.
add = '~' + charCode.toString(16) + '.';
}
@ -103,13 +102,13 @@ export function stringToTa(string) {
(javascript Date object)
*/
export function daToDate(st) {
var dub = function(n) {
return parseInt(n) < 10 ? "0" + parseInt(n) : n.toString();
const dub = function(n) {
return parseInt(n) < 10 ? '0' + parseInt(n) : n.toString();
};
var da = st.split('..');
var bigEnd = da[0].split('.');
var lilEnd = da[1].split('.');
var ds = `${bigEnd[0].slice(1)}-${dub(bigEnd[1])}-${dub(bigEnd[2])}T${dub(lilEnd[0])}:${dub(lilEnd[1])}:${dub(lilEnd[2])}Z`;
const da = st.split('..');
const bigEnd = da[0].split('.');
const lilEnd = da[1].split('.');
const ds = `${bigEnd[0].slice(1)}-${dub(bigEnd[1])}-${dub(bigEnd[2])}T${dub(lilEnd[0])}:${dub(lilEnd[1])}:${dub(lilEnd[2])}Z`;
return new Date(ds);
}
@ -121,8 +120,8 @@ export function daToDate(st) {
*/
export function dateToDa(d, mil) {
  var fil = function(n) {
    return n >= 10 ? n : "0" + n;
  const fil = function(n) {
    return n >= 10 ? n : '0' + n;
  };
  return (
    `~${d.getUTCFullYear()}.` +
@ -131,7 +130,7 @@ export function dateToDa(d, mil) {
    `${fil(d.getUTCHours())}.` +
    `${fil(d.getUTCMinutes())}.` +
    `${fil(d.getUTCSeconds())}` +
`${mil ? "..0000" : ""}`
`${mil ? '..0000' : ''}`
  );
}
@ -149,41 +148,41 @@ export function uxToHex(ux) {
// trim patps to match dojo, chat-cli
export function cite(ship) {
let patp = ship, shortened = "";
if (patp.startsWith("~")) {
let patp = ship, shortened = '';
if (patp.startsWith('~')) {
patp = patp.substr(1);
}
// comet
if (patp.length === 56) {
shortened = "~" + patp.slice(0, 6) + "_" + patp.slice(50, 56);
shortened = '~' + patp.slice(0, 6) + '_' + patp.slice(50, 56);
return shortened;
}
// moon
if (patp.length === 27) {
shortened = "~" + patp.slice(14, 20) + "^" + patp.slice(21, 27);
shortened = '~' + patp.slice(14, 20) + '^' + patp.slice(21, 27);
return shortened;
}
return `~${patp}`;
}
export function alphabetiseAssociations(associations) {
let result = {};
const result = {};
Object.keys(associations).sort((a, b) => {
let aName = a.substr(1);
let bName = b.substr(1);
if (associations[a].metadata && associations[a].metadata.title) {
aName = associations[a].metadata.title !== ""
aName = associations[a].metadata.title !== ''
? associations[a].metadata.title
: a.substr(1);
}
if (associations[b].metadata && associations[b].metadata.title) {
bName = associations[b].metadata.title !== ""
bName = associations[b].metadata.title !== ''
? associations[b].metadata.title
: b.substr(1);
}
return aName.toLowerCase().localeCompare(bName.toLowerCase());
}).map((each) => {
result[each] = associations[each];
})
});
return result;
}
}

View File

@ -70,9 +70,11 @@ export class LinkUpdateReducer {
// stub in a comment count, which is more or less guaranteed to be 0
data.pages = data.pages.map(submission => {
submission.commentCount = 0;
state.seen[path][submission.url] = false;
return submission;
});
// add the new submissions to state, update totals
state.links[path] = this._addNewItems(
data.pages, state.links[path]

View File

@ -242,7 +242,7 @@ a {
display: none;
}
.md h1, .md h2, .md h3, .md h4, .md h5, .md p, .md a, .md ul, .md blockquote,.md code,.md pre {
.md h1, .md h2, .md h3, .md h4, .md h5, .md p, .md a, .md ul, .md ol, .md blockquote,.md code,.md pre {
font-size: 14px;
margin-bottom: 16px;
}
@ -265,7 +265,7 @@ a {
.md code, .md pre {
font-family: "Source Code Pro", mono;
}
.md ul>li {
.md ul>li, .md ol>li {
line-height: 1.5;
}
.md a {

View File

@ -3,10 +3,9 @@ import moment from 'moment';
import { Sigil } from './icons/sigil';
import { CommentInput } from './comment-input';
import { uxToHex, cite } from '../../lib/util';
import { Spinner } from './icons/icon-spinner';
export class CommentItem extends Component {
constructor(props){
constructor(props) {
super(props);
this.state = {
@ -20,7 +19,7 @@ export class CommentItem extends Component {
past: function(input) {
return input === 'just now'
? input
: input + ' ago'
: input + ' ago';
},
s : 'just now',
future : 'in %s',
@ -33,15 +32,14 @@ export class CommentItem extends Component {
M : '1 month',
MM : '%d months',
y : '1 year',
yy : '%d years',
yy : '%d years'
}
});
}
commentEdit() {
let commentPath = Object.keys(this.props.comment)[0];
let commentBody = this.props.comment[commentPath].content;
const commentPath = Object.keys(this.props.comment)[0];
const commentBody = this.props.comment[commentPath].content;
this.setState({ commentBody });
this.props.onEdit();
}
@ -53,7 +51,7 @@ export class CommentItem extends Component {
commentChange(e) {
this.setState({
commentBody: e.target.value
})
});
}
onUpdate() {
@ -61,28 +59,39 @@ export class CommentItem extends Component {
}
render() {
let pending = !!this.props.pending ? "o-60" : "";
let commentData = this.props.comment[Object.keys(this.props.comment)[0]];
let content = commentData.content.split("\n").map((line, i)=> {
const pending = this.props.pending ? 'o-60' : '';
const commentData = this.props.comment[Object.keys(this.props.comment)[0]];
const content = commentData.content.split('\n').map((line, i) => {
return (
<p className="mb2" key={i}>{line}</p>
)
);
});
let date = moment(commentData["date-created"]).fromNow();
const date = moment(commentData['date-created']).fromNow();
let contact = !!(commentData.author.substr(1) in this.props.contacts)
const contact = commentData.author.substr(1) in this.props.contacts
? this.props.contacts[commentData.author.substr(1)] : false;
let name = commentData.author;
let color = "#000000";
let classes = "mix-blend-diff";
let color = '#000000';
let classes = 'mix-blend-diff';
let avatar = null;
if (contact) {
name = (contact.nickname.length > 0)
? contact.nickname : commentData.author;
color = `#${uxToHex(contact.color)}`;
classes = "";
classes = '';
avatar = contact.avatar;
}
const img = (avatar !== null)
? <img src={avatar} height={24} width={24} className="dib" />
: <Sigil
ship={commentData.author}
size={24}
color={color}
classes={classes}
/>;
if (name === commentData.author) {
name = cite(commentData.author);
}
@ -93,17 +102,13 @@ export class CommentItem extends Component {
|| window.ship !== commentData.author.slice(1);
return (
<div className={"mb8 " + pending}>
<div className={'mb8 ' + pending}>
<div className="flex mv3 bg-white bg-gray0-d">
<Sigil
ship={commentData.author}
size={24}
color={color}
classes={classes}
/>
<div className={"f9 mh2 pt1 " +
(contact.nickname ? null : "mono")}
title={commentData.author}>
{img}
<div className={'f9 mh2 pt1 ' +
(contact.nickname ? null : 'mono')}
title={commentData.author}
>
{name}
</div>
<div className="f9 gray3 pt1">{date}</div>
@ -121,11 +126,14 @@ export class CommentItem extends Component {
<div className="f8 lh-solid mb2">
{ !editing && content }
{ editing && (
<CommentInput style={{resize:'vertical'}}
ref={(el) => {this.focusTextArea(el)}}
<CommentInput style={{ resize:'vertical' }}
ref={(el) => {
this.focusTextArea(el);
}}
onChange={this.commentChange}
value={this.state.commentBody}
onSubmit={this.onUpdate.bind(this)}>
onSubmit={this.onUpdate.bind(this)}
>
</CommentInput>
)}
</div>
@ -139,10 +147,9 @@ export class CommentItem extends Component {
</div>
</div>
)}
</div>
)
);
}
}
export default CommentItem
export default CommentItem;

View File

@ -1,32 +1,47 @@
import React, { Component } from 'react';
import { sigil, reactRenderer } from 'urbit-sigil-js';
export class Sigil extends Component {
render() {
const { props } = this;
let classes = props.classes || "";
const classes = props.classes || '';
const rgb = {
r: parseInt(props.color.slice(1, 3), 16),
g: parseInt(props.color.slice(3, 5), 16),
b: parseInt(props.color.slice(5, 7), 16)
};
const brightness = ((299 * rgb.r) + (587 * rgb.g) + (114 * rgb.b)) / 1000;
const whiteBrightness = 255;
let foreground = 'white';
if ((whiteBrightness - brightness) < 50) {
foreground = 'black';
}
if (props.ship.length > 14) {
return (
<div
className={"bg-black dib " + classes}
style={{ width: props.size, height: props.size }}>
</div>
className={'bg-black dib ' + classes}
style={{ width: props.size, height: props.size }}
></div>
);
} else {
return (
<div className={"dib " + classes} style={{ flexBasis: props.size, backgroundColor: props.color }}>
<div
className={'dib ' + classes}
style={{ backgroundColor: props.color }}
>
{sigil({
patp: props.ship,
renderer: reactRenderer,
size: props.size,
colors: [props.color, "white"]
colors: [props.color, foreground]
})}
</div>
);
}
}
}

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