mirror of
https://github.com/urbit/shrub.git
synced 2024-11-28 22:33:06 +03:00
Merge branch 'release/next-userspace'
This commit is contained in:
commit
c7f8af1fd2
@ -195,7 +195,6 @@
|
||||
=/ pax t.t.t.t.site.url
|
||||
=/ envelopes (envelope-scry [(scot %ud start) (scot %ud end) pax])
|
||||
%- json-response:gen
|
||||
%- json-to-octs
|
||||
%- update:enjs:store
|
||||
[%messages pax start end envelopes]
|
||||
==
|
||||
|
@ -148,9 +148,7 @@
|
||||
::
|
||||
=; json=(unit json)
|
||||
?~ json not-found:gen
|
||||
%- json-response:gen
|
||||
=, html
|
||||
(as-octt:mimes (en-json u.json))
|
||||
(json-response:gen u.json)
|
||||
=, enjs:format
|
||||
?+ site ~
|
||||
:: /apps.json: {appname: running?}
|
||||
|
@ -450,16 +450,29 @@
|
||||
|^
|
||||
?> (team:title our.bowl src.bowl)
|
||||
?+ path (on-peek:def path)
|
||||
[%x %keys ~] ``noun+!>(~(key by graphs))
|
||||
[%x %tags ~] ``noun+!>(~(key by tag-queries))
|
||||
[%x %tag-queries ~] ``noun+!>(tag-queries)
|
||||
[%x %keys ~]
|
||||
:- ~ :- ~ :- %graph-update
|
||||
!>(`update:store`[%0 now.bowl [%keys ~(key by graphs)]])
|
||||
::
|
||||
[%x %tags ~]
|
||||
:- ~ :- ~ :- %graph-update
|
||||
!>(`update:store`[%0 now.bowl [%tags ~(key by tag-queries)]])
|
||||
::
|
||||
[%x %tag-queries ~]
|
||||
:- ~ :- ~ :- %graph-update
|
||||
!>(`update:store`[%0 now.bowl [%tag-queries tag-queries]])
|
||||
::
|
||||
[%x %graph @ @ ~]
|
||||
=/ =ship (slav %p i.t.t.path)
|
||||
=/ =term i.t.t.t.path
|
||||
=/ result=(unit marked-graph:store)
|
||||
(~(get by graphs) [ship term])
|
||||
?~ result [~ ~]
|
||||
``noun+!>(u.result)
|
||||
:- ~ :- ~ :- %graph-update
|
||||
!> ^- update:store
|
||||
:+ %0
|
||||
now.bowl
|
||||
[%add-graph [ship term] `graph:store`p.u.result q.u.result]
|
||||
::
|
||||
[%x %graph-subset @ @ @ @ ~]
|
||||
=/ =ship (slav %p i.t.t.path)
|
||||
@ -469,7 +482,16 @@
|
||||
=/ graph=(unit marked-graph:store)
|
||||
(~(get by graphs) [ship term])
|
||||
?~ graph [~ ~]
|
||||
``noun+!>(`graph:store`(subset:orm p.u.graph start end))
|
||||
:- ~ :- ~ :- %graph-update
|
||||
!> ^- update:store
|
||||
:+ %0 now.bowl
|
||||
:+ %add-nodes
|
||||
[ship term]
|
||||
%- ~(gas by *(map index:store node:store))
|
||||
%+ turn (tap:orm `graph:store`(subset:orm p.u.graph start end))
|
||||
|= [=atom =node:store]
|
||||
^- [index:store node:store]
|
||||
[~[atom] node]
|
||||
::
|
||||
[%x %node @ @ @ *]
|
||||
=/ =ship (slav %p i.t.t.path)
|
||||
@ -478,28 +500,13 @@
|
||||
(turn t.t.t.t.path |=(=cord (slav %ud cord)))
|
||||
=/ node=(unit node:store) (get-node ship term index)
|
||||
?~ node [~ ~]
|
||||
``noun+!>(u.node)
|
||||
::
|
||||
[%x %post @ @ @ *]
|
||||
=/ =ship (slav %p i.t.t.path)
|
||||
=/ =term i.t.t.t.path
|
||||
=/ =index:store
|
||||
(turn t.t.t.t.path |=(=cord (slav %ud cord)))
|
||||
=/ node=(unit node:store) (get-node ship term index)
|
||||
?~ node [~ ~]
|
||||
``noun+!>(post.u.node)
|
||||
::
|
||||
[%x %node-children @ @ @ *]
|
||||
=/ =ship (slav %p i.t.t.path)
|
||||
=/ =term i.t.t.t.path
|
||||
=/ =index:store
|
||||
(turn t.t.t.t.path |=(=cord (slav %ud cord)))
|
||||
=/ node=(unit node:store) (get-node ship term index)
|
||||
?~ node [~ ~]
|
||||
?- -.children.u.node
|
||||
%empty [~ ~]
|
||||
%graph ``noun+!>(p.children.u.node)
|
||||
==
|
||||
:- ~ :- ~ :- %graph-update
|
||||
!> ^- update:store
|
||||
:+ %0
|
||||
now.bowl
|
||||
:+ %add-nodes
|
||||
[ship term]
|
||||
(~(gas by *(map index:store node:store)) [index u.node] ~)
|
||||
::
|
||||
[%x %node-children-subset @ @ @ @ @ *]
|
||||
=/ =ship (slav %p i.t.t.path)
|
||||
@ -512,7 +519,18 @@
|
||||
?~ node [~ ~]
|
||||
?- -.children.u.node
|
||||
%empty [~ ~]
|
||||
%graph ``noun+!>(`graph:store`(subset:orm p.children.u.node start end))
|
||||
%graph
|
||||
:- ~ :- ~ :- %graph-update
|
||||
!> ^- update:store
|
||||
:+ %0
|
||||
now.bowl
|
||||
:+ %add-nodes
|
||||
[ship term]
|
||||
%- ~(gas by *(map index:store node:store))
|
||||
%+ turn (tap:orm `graph:store`(subset:orm p.children.u.node start end))
|
||||
|= [=atom =node:store]
|
||||
^- [index:store node:store]
|
||||
[(snoc index atom) node]
|
||||
==
|
||||
::
|
||||
[%x %update-log @ @ ~]
|
||||
|
@ -113,7 +113,7 @@
|
||||
++ json-response
|
||||
|= [eyre-id=@ta jon=json]
|
||||
^- (list card)
|
||||
(give-simple-payload:app eyre-id (json-response:gen (json-to-octs jon)))
|
||||
(give-simple-payload:app eyre-id (json-response:gen jon))
|
||||
::
|
||||
++ give-rpc-notification
|
||||
|= res=out:notification:lsp-sur
|
||||
|
@ -136,7 +136,7 @@
|
||||
::
|
||||
:_ this
|
||||
%+ give-simple-payload:app eyre-id.u.job.state
|
||||
(json-response:gen (json-to-octs jon))
|
||||
(json-response:gen jon)
|
||||
::
|
||||
++ take-sole-effect
|
||||
|= fec=sole-effect
|
||||
@ -186,7 +186,7 @@
|
||||
%+ give-simple-payload:app eyre-id.u.job.state
|
||||
?- -.u.out
|
||||
%json
|
||||
(json-response:gen (json-to-octs json.u.out))
|
||||
(json-response:gen json.u.out)
|
||||
::
|
||||
%mime
|
||||
=/ headers
|
||||
|
@ -2351,7 +2351,6 @@
|
||||
:: all notebooks, short form
|
||||
[[[~ %json] [%'publish-view' %notebooks ~]] ~]
|
||||
%- json-response:gen
|
||||
%- json-to-octs
|
||||
(notebooks-map:enjs our.bol books)
|
||||
::
|
||||
:: notes pagination
|
||||
@ -2370,7 +2369,6 @@
|
||||
?~ length
|
||||
not-found:gen
|
||||
%- json-response:gen
|
||||
%- json-to-octs
|
||||
:- %o
|
||||
(notes-page:enjs notes.u.book u.start u.length)
|
||||
::
|
||||
@ -2394,7 +2392,6 @@
|
||||
?~ length
|
||||
not-found:gen
|
||||
%- json-response:gen
|
||||
%- json-to-octs
|
||||
(comments-page:enjs comments.u.note u.start u.length)
|
||||
::
|
||||
:: single notebook with initial 50 notes in short form, as json
|
||||
@ -2413,7 +2410,7 @@
|
||||
(~(put by p.notebook-json) %subscribers (get-subscribers-json book-name))
|
||||
=. p.notebook-json
|
||||
(~(put by p.notebook-json) %writers (get-writers-json u.host book-name))
|
||||
(json-response:gen (json-to-octs (pairs notebook+notebook-json ~)))
|
||||
(json-response:gen (pairs notebook+notebook-json ~))
|
||||
::
|
||||
:: single note, with initial 50 comments, as json
|
||||
[[[~ %json] [%'publish-view' @ @ @ ~]] ~]
|
||||
@ -2428,7 +2425,7 @@
|
||||
?~ note not-found:gen
|
||||
=/ jon=json
|
||||
o+(note-presentation:enjs u.book note-name u.note)
|
||||
(json-response:gen (json-to-octs jon))
|
||||
(json-response:gen jon)
|
||||
==
|
||||
::
|
||||
--
|
||||
|
@ -247,20 +247,24 @@
|
||||
|%
|
||||
++ decode
|
||||
%- of
|
||||
:~ [%add-graph add-graph]
|
||||
[%remove-graph remove-graph]
|
||||
[%add-nodes add-nodes]
|
||||
:~ [%add-nodes add-nodes]
|
||||
[%remove-nodes remove-nodes]
|
||||
[%add-signatures add-signatures]
|
||||
[%remove-signatures remove-signatures]
|
||||
::
|
||||
[%add-graph add-graph]
|
||||
[%remove-graph remove-graph]
|
||||
::
|
||||
[%add-tag add-tag]
|
||||
[%remove-tag remove-tag]
|
||||
::
|
||||
[%archive-graph archive-graph]
|
||||
[%unarchive-graph unarchive-graph]
|
||||
[%run-updates run-updates]
|
||||
::
|
||||
[%keys keys]
|
||||
[%tags tags]
|
||||
[%tag-queries tag-queries]
|
||||
[%run-updates run-updates]
|
||||
==
|
||||
::
|
||||
++ add-graph
|
||||
|
@ -1,3 +1,23 @@
|
||||
:: lib/pull-hook: helper for creating a push hook
|
||||
::
|
||||
:: lib/pull-hook is a helper for automatically pulling data from a
|
||||
:: corresponding push-hook to a store.
|
||||
::
|
||||
:: ## Interfacing notes:
|
||||
::
|
||||
:: The inner door may interact with the library by producing cards.
|
||||
:: Do not pass any cards on a wire beginning with /helper as these
|
||||
:: wires are reserved by this library. Any watches/pokes/peeks not
|
||||
:: listed below will be routed to the inner door.
|
||||
::
|
||||
:: ## Subscription paths
|
||||
::
|
||||
:: /tracking: The set of resources we are pulling
|
||||
::
|
||||
:: ## Pokes
|
||||
::
|
||||
:: %pull-hook-action: Add/remove a resource from pulling.
|
||||
::
|
||||
/- *pull-hook
|
||||
/+ default-agent, resource
|
||||
::
|
||||
@ -5,12 +25,24 @@
|
||||
|%
|
||||
+$ card card:agent:gall
|
||||
::
|
||||
:: $config: configuration for the pull hook
|
||||
::
|
||||
:: .store-name: name of the store to send subscription updates to.
|
||||
:: .update-mark: mark that updates will be tagged with
|
||||
:: .push-hook-name: name of the corresponding push-hook
|
||||
::
|
||||
+$ config
|
||||
$: store-name=term
|
||||
update=mold
|
||||
update-mark=term
|
||||
push-hook-name=term
|
||||
==
|
||||
::
|
||||
:: $state-0: state for the pull hook
|
||||
::
|
||||
:: .tracking: a map of resources we are pulling, and the ships that
|
||||
:: we are pulling them from.
|
||||
:: .inner-state: state given to internal door
|
||||
::
|
||||
+$ state-0
|
||||
$: %0
|
||||
@ -37,7 +69,29 @@
|
||||
|* config
|
||||
$_ ^|
|
||||
|_ bowl:gall
|
||||
:: +on-pull-nack: handle failed pull subscription
|
||||
::
|
||||
:: This arm is called when a pull subscription fails. lib/pull-hook
|
||||
:: will automatically delete the resource from .tracking by the
|
||||
:: time this arm is called.
|
||||
::
|
||||
++ on-pull-nack
|
||||
|~ [resource tang]
|
||||
*[(list card) _^|(..on-init)]
|
||||
:: +on-pull-kick: produce any additional resubscribe path
|
||||
::
|
||||
:: If non-null, the produced path is appended to the original
|
||||
:: subscription path. This should be used to encode extra
|
||||
:: information onto the path in order to reduce the payload of a
|
||||
:: kick and resubscribe.
|
||||
::
|
||||
:: If null, a resubscribe is not attempted
|
||||
::
|
||||
++ on-pull-kick
|
||||
|~ resource
|
||||
*(unit path)
|
||||
::
|
||||
:: from agent:gall
|
||||
++ on-init
|
||||
*[(list card) _^|(..on-init)]
|
||||
::
|
||||
@ -75,26 +129,6 @@
|
||||
++ on-fail
|
||||
|~ [term tang]
|
||||
*[(list card) _^|(..on-init)]
|
||||
:: +on-pull-nack: handle failed pull subscription
|
||||
::
|
||||
:: This arm is called when a pull subscription fails.
|
||||
::
|
||||
++ on-pull-nack
|
||||
|~ [resource tang]
|
||||
*[(list card) _^|(..on-init)]
|
||||
:: +on-pull-kick: produce any additional resubscribe path
|
||||
::
|
||||
:: If non-null, the produced path is appended to the original
|
||||
:: subscription path. This should be used to encode extra
|
||||
:: information onto the path in order to reduce the payload of a
|
||||
:: kick and resubscribe.
|
||||
::
|
||||
:: If null, a resubscribe is not attempted
|
||||
::
|
||||
++ on-pull-kick
|
||||
|~ resource
|
||||
*(unit path)
|
||||
:: ::
|
||||
--
|
||||
++ agent
|
||||
|* =config
|
||||
@ -209,7 +243,10 @@
|
||||
=^ cards pull-hook
|
||||
(on-fail:og term tang)
|
||||
[cards this]
|
||||
++ on-peek on-peek:def
|
||||
++ on-peek
|
||||
|= =path
|
||||
^- (unit (unit cage))
|
||||
(on-peek:og path)
|
||||
--
|
||||
|_ =bowl:gall
|
||||
+* og ~(. pull-hook bowl)
|
||||
|
@ -1,8 +1,41 @@
|
||||
:: lib/push-hook: helper for creating a push hook
|
||||
::
|
||||
:: lib/push-hook is a helper for automatically pushing data from a
|
||||
:: local store to the corresponding pull-hook on remote ships. It also
|
||||
:: proxies remote pokes to the store.
|
||||
::
|
||||
:: ## Interfacing notes:
|
||||
::
|
||||
:: The inner door may interact with the library by producing cards.
|
||||
:: Do not pass any cards on a wire beginning with /helper as these
|
||||
:: wires are reserved by this library. Any watches/pokes/peeks not
|
||||
:: listed below will be routed to the inner door.
|
||||
::
|
||||
:: ## Subscription paths
|
||||
::
|
||||
:: /resource/[resource]: Receive initial state and updates to
|
||||
:: .resource. .resource should be encoded with en-path:resource from
|
||||
:: /lib/resource. Facts on this path will be of mark
|
||||
:: update-mark.config
|
||||
::
|
||||
:: ## Pokes
|
||||
::
|
||||
:: %push-hook-action: Add/remove a resource from pushing.
|
||||
:: [update-mark.config]: A poke to proxy to the local store
|
||||
::
|
||||
/- *push-hook
|
||||
/+ default-agent, resource
|
||||
|%
|
||||
+$ card card:agent:gall
|
||||
::
|
||||
:: $config: configuration for the push hook
|
||||
::
|
||||
:: .store-name: name of the store to proxy pokes and
|
||||
:: subscriptions to
|
||||
:: .store-path: subscription path to receive updates on
|
||||
:: .update-mark: mark that updates will be tagged with
|
||||
:: .pull-hook-name: name of the corresponding pull-hook
|
||||
::
|
||||
+$ config
|
||||
$: store-name=term
|
||||
store-path=path
|
||||
@ -10,6 +43,12 @@
|
||||
update-mark=term
|
||||
pull-hook-name=term
|
||||
==
|
||||
::
|
||||
:: $state-0: state for the push hook
|
||||
::
|
||||
:: .sharing: resources that the push hook is proxying
|
||||
:: .inner-state: state given to internal door
|
||||
::
|
||||
+$ state-0
|
||||
$: %0
|
||||
sharing=(set resource)
|
||||
@ -21,6 +60,48 @@
|
||||
$_ ^|
|
||||
|_ bowl:gall
|
||||
::
|
||||
:: +resource-for-update: get affected resource from an update
|
||||
::
|
||||
:: Given a vase of the update, the mark of which is
|
||||
:: update-mark.config, produce the affected resource, if any.
|
||||
::
|
||||
++ resource-for-update
|
||||
|~ vase
|
||||
*(unit resource)
|
||||
::
|
||||
:: +take-update: handle update from store
|
||||
::
|
||||
:: Given an update from the store, do other things after proxying
|
||||
:: the update
|
||||
::
|
||||
++ take-update
|
||||
|~ vase
|
||||
*[(list card) _^|(..on-init)]
|
||||
:: +should-proxy-update: should forward update to store
|
||||
::
|
||||
:: If %.y is produced, then the update is forwarded to the local
|
||||
:: store. If %.n is produced then the update is not forwarded and
|
||||
:: the poke fails.
|
||||
::
|
||||
++ should-proxy-update
|
||||
|~ vase
|
||||
*?
|
||||
:: +initial-watch: produce initial state for a subscription
|
||||
::
|
||||
:: .resource is the resource being subscribed to.
|
||||
:: .path is any additional information in the subscription wire.
|
||||
:: This would typically be used to encode state that the subscriber
|
||||
:: already has. For example, a chat client might encode
|
||||
:: the number of messages that it already has, or the date it last
|
||||
:: received an update.
|
||||
::
|
||||
:: If +initial-watch crashes, the subscription fails.
|
||||
::
|
||||
++ initial-watch
|
||||
|~ [path resource]
|
||||
*vase
|
||||
:: from agent:gall
|
||||
::
|
||||
++ on-init
|
||||
*[(list card) _^|(..on-init)]
|
||||
::
|
||||
@ -58,35 +139,6 @@
|
||||
++ on-fail
|
||||
|~ [term tang]
|
||||
*[(list card) _^|(..on-init)]
|
||||
:: +resource-for-update: get affected resource from an update
|
||||
++ resource-for-update
|
||||
|~ vase
|
||||
*(unit resource)
|
||||
::
|
||||
:: +on-update: handle update from store
|
||||
::
|
||||
:: Do extra stuff on store update
|
||||
++ take-update
|
||||
|~ vase
|
||||
*[(list card) _^|(..on-init)]
|
||||
:: +should-proxy-update: should forward update to store
|
||||
::
|
||||
:: If %.y is produced, then the update is forwarded to the local
|
||||
:: store. If %.n is produced then the update is not forwarded and
|
||||
:: the poke fails.
|
||||
::
|
||||
++ should-proxy-update
|
||||
|~ vase
|
||||
*?
|
||||
:: +initial-watch: produce initial state for a subscription
|
||||
::
|
||||
:: .resource is the resource being subscribed to.
|
||||
:: .path is any additional information in the subscription wire
|
||||
::
|
||||
++ initial-watch
|
||||
|~ [path resource]
|
||||
*vase
|
||||
::
|
||||
--
|
||||
++ agent
|
||||
|* =config
|
||||
|
@ -92,9 +92,9 @@
|
||||
[[200 [['content-type' 'text/javascript'] max-1-da ~]] `octs]
|
||||
::
|
||||
++ json-response
|
||||
|= =octs
|
||||
|= =json
|
||||
^- simple-payload:http
|
||||
[[200 ['content-type' 'application/json']~] `octs]
|
||||
[[200 ['content-type' 'application/json']~] `(json-to-octs json)]
|
||||
::
|
||||
++ css-response
|
||||
|= =octs
|
||||
|
30
pkg/arvo/ted/diff.hoon
Normal file
30
pkg/arvo/ted/diff.hoon
Normal file
@ -0,0 +1,30 @@
|
||||
/- spider
|
||||
/+ strandio
|
||||
=, strand=strand:spider
|
||||
^- thread:spider
|
||||
|= arg=vase
|
||||
=/ m (strand ,vase)
|
||||
^- form:m
|
||||
|^
|
||||
=+ !<([=a=path =b=path ~] arg)
|
||||
=/ a-mark=mark -:(flop a-path)
|
||||
=/ b-mark=mark -:(flop b-path)
|
||||
?. =(a-mark b-mark)
|
||||
(strand-fail:strandio %files-not-same-type ~)
|
||||
=/ a-beam (need (de-beam:format a-path))
|
||||
;< =a=cage bind:m (get-file a-path)
|
||||
;< =b=cage bind:m (get-file b-path)
|
||||
;< =dais:clay bind:m (build-mark:strandio -.a-beam a-mark)
|
||||
(pure:m (~(diff dais q.a-cage) q.b-cage))
|
||||
::
|
||||
++ get-file
|
||||
|= =path
|
||||
=/ m (strand ,cage)
|
||||
^- form:m
|
||||
=/ beam (need (de-beam:format path))
|
||||
;< =riot:clay bind:m
|
||||
(warp:strandio p.beam q.beam ~ %sing %x r.beam (flop s.beam))
|
||||
?~ riot
|
||||
(strand-fail:strandio %file-not-found >path< ~)
|
||||
(pure:m r.u.riot)
|
||||
--
|
43
pkg/interface/package-lock.json
generated
43
pkg/interface/package-lock.json
generated
@ -6383,11 +6383,6 @@
|
||||
"p-is-promise": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"memoize-one": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz",
|
||||
"integrity": "sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA=="
|
||||
},
|
||||
"memory-fs": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
|
||||
@ -6807,6 +6802,11 @@
|
||||
"tslib": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
|
||||
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
|
||||
},
|
||||
"node-forge": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz",
|
||||
@ -7058,6 +7058,14 @@
|
||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
|
||||
"dev": true
|
||||
},
|
||||
"oembed-parser": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/oembed-parser/-/oembed-parser-1.4.1.tgz",
|
||||
"integrity": "sha512-1KqnfrXF3TiAQhJ9+vv3dEtMhPSVSOT9D9XPqLjEtaQg5liPc3LQ65YjgKHo7Z/YY/kmZ1PDb5gMcOxxCPPdBA==",
|
||||
"requires": {
|
||||
"node-fetch": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"omit-deep": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/omit-deep/-/omit-deep-0.3.0.tgz",
|
||||
@ -7937,6 +7945,14 @@
|
||||
"xtend": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"react-oembed-container": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-oembed-container/-/react-oembed-container-1.0.0.tgz",
|
||||
"integrity": "sha512-YppvCDgxZkn6qgwAIpxRtmMtxaMpau8yQhm8nzmH7yHpDapmHxzakXvQke5qPfmdYyYW4CsKDfVfGoX14NvQkw==",
|
||||
"requires": {
|
||||
"prop-types": "^15.6.0"
|
||||
}
|
||||
},
|
||||
"react-router": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz",
|
||||
@ -7973,13 +7989,13 @@
|
||||
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz",
|
||||
"integrity": "sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg=="
|
||||
},
|
||||
"react-window": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.5.tgz",
|
||||
"integrity": "sha512-HeTwlNa37AFa8MDZFZOKcNEkuF2YflA0hpGPiTT9vR7OawEt+GZbfM6wqkBahD3D3pUjIabQYzsnY/BSJbgq6Q==",
|
||||
"react-virtuoso": {
|
||||
"version": "0.20.0",
|
||||
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-0.20.0.tgz",
|
||||
"integrity": "sha512-h+U6t/+m91AzfUe6bBfaacdLLJl1y8v7CfcXwPgQ/Dic+vNlgQmi6cIKTq18zuF+kI8Q7QN0ojIeqPHWbU8TZA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"memoize-one": ">=3.1.1 <6"
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"readable-stream": {
|
||||
@ -8260,6 +8276,11 @@
|
||||
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
|
||||
"dev": true
|
||||
},
|
||||
"resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
|
||||
|
@ -20,6 +20,7 @@
|
||||
"moment": "^2.20.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mousetrap-global-bind": "^1.1.0",
|
||||
"oembed-parser": "^1.4.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.5.2",
|
||||
"react-codemirror2": "^6.0.1",
|
||||
@ -29,8 +30,9 @@
|
||||
"react-dom": "^16.8.6",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-markdown": "^4.3.1",
|
||||
"react-oembed-container": "^1.0.0",
|
||||
"react-router-dom": "^5.0.0",
|
||||
"react-window": "^1.8.5",
|
||||
"react-virtuoso": "^0.20.0",
|
||||
"remark-disable-tokenizers": "^1.0.24",
|
||||
"style-loader": "^1.2.1",
|
||||
"styled-components": "^5.1.0",
|
||||
|
@ -12,7 +12,7 @@ export default class ChatApi extends BaseApi<StoreState> {
|
||||
* Fetch backlog
|
||||
*/
|
||||
fetchMessages(start: number, end: number, path: Path) {
|
||||
fetch(`/chat-view/paginate/${start}/${end}${path}`)
|
||||
return fetch(`/chat-view/paginate/${start}/${end}${path}`)
|
||||
.then(response => response.json())
|
||||
.then((json) => {
|
||||
this.store.handleEvent({
|
||||
|
@ -11,6 +11,7 @@ import GroupsApi from './groups';
|
||||
import LaunchApi from './launch';
|
||||
import LinksApi from './links';
|
||||
import PublishApi from './publish';
|
||||
import GraphApi from './graph';
|
||||
import S3Api from './s3';
|
||||
|
||||
export default class GlobalApi extends BaseApi<StoreState> {
|
||||
@ -24,10 +25,15 @@ export default class GlobalApi extends BaseApi<StoreState> {
|
||||
links = new LinksApi(this.ship, this.channel, this.store);
|
||||
publish = new PublishApi(this.ship, this.channel, this.store);
|
||||
s3 = new S3Api(this.ship, this.channel, this.store);
|
||||
graph = new GraphApi(this.ship, this.channel, this.store);
|
||||
|
||||
|
||||
constructor(public ship: Patp, public channel: any, public store: GlobalStore) {
|
||||
super(ship,channel,store);
|
||||
constructor(
|
||||
public ship: Patp,
|
||||
public channel: any,
|
||||
public store: GlobalStore
|
||||
) {
|
||||
super(ship, channel, store);
|
||||
}
|
||||
}
|
||||
|
||||
|
132
pkg/interface/src/logic/api/graph.ts
Normal file
132
pkg/interface/src/logic/api/graph.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import BaseApi from './base';
|
||||
import { StoreState } from '../store/type';
|
||||
import { Patp, Path, PatpNoSig } from '~/types/noun';
|
||||
|
||||
|
||||
export const createPost = (contents: Object[], parentIndex: string = '') => {
|
||||
return {
|
||||
author: `~${window.ship}`,
|
||||
index: parentIndex + '/' + Date.now(),
|
||||
'time-sent': Date.now(),
|
||||
contents,
|
||||
hash: null,
|
||||
signatures: []
|
||||
};
|
||||
};
|
||||
|
||||
export default class GraphApi extends BaseApi<StoreState> {
|
||||
|
||||
private storeAction(action: any): Promise<any> {
|
||||
return this.action('graph-store', 'graph-update', action)
|
||||
}
|
||||
|
||||
addGraph(ship: Patp, name: string, graph: any, mark: any) {
|
||||
this.storeAction({
|
||||
'add-graph': {
|
||||
resource: { ship, name },
|
||||
graph,
|
||||
mark
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeGraph(ship: Patp, name: string) {
|
||||
this.storeAction({
|
||||
'remove-graph': {
|
||||
resource: { ship, name }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addPost(ship: Patp, name: string, post: Object) {
|
||||
let nodes = {};
|
||||
nodes[post.index] = {
|
||||
post,
|
||||
children: { empty: null }
|
||||
};
|
||||
|
||||
this.storeAction({
|
||||
'add-nodes': {
|
||||
resource: { ship, name },
|
||||
nodes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addNodes(ship: Patp, name: string, nodes: Object) {
|
||||
this.storeAction({
|
||||
'add-nodes': {
|
||||
resource: { ship, name },
|
||||
nodes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeNodes(ship: Patp, name: string, indices: string[]) {
|
||||
this.storeAction({
|
||||
'remove-nodes': {
|
||||
resource: { ship, name },
|
||||
indices
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getKeys() {
|
||||
this.scry<any>('graph-store', '/keys')
|
||||
.then((keys) => {
|
||||
this.store.handleEvent({
|
||||
data: keys
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getTags() {
|
||||
this.scry<any>('graph-store', '/tags')
|
||||
.then((tags) => {
|
||||
this.store.handleEvent({
|
||||
data: tags
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getTagQueries() {
|
||||
this.scry<any>('graph-store', '/tag-queries')
|
||||
.then((tagQueries) => {
|
||||
this.store.handleEvent({
|
||||
data: tagQueries
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getGraph(ship: string, resource: string) {
|
||||
this.scry<any>('graph-store', `/graph/${ship}/${resource}`)
|
||||
.then((graph) => {
|
||||
this.store.handleEvent({
|
||||
data: graph
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getGraphSubset(ship: string, resource: string, start: string, end: start) {
|
||||
this.scry<any>(
|
||||
'graph-store',
|
||||
`/graph-subset/${ship}/${resource}/${end}/${start}`
|
||||
).then((subset) => {
|
||||
this.store.handleEvent({
|
||||
data: subset
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getNode(ship: string, resource: string, index: string) {
|
||||
this.scry<any>(
|
||||
'graph-store',
|
||||
`/node/${ship}/${resource}/${index}`
|
||||
).then((node) => {
|
||||
this.store.handleEvent({
|
||||
data: node
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -44,8 +44,8 @@ export default class LinksApi extends BaseApi<StoreState> {
|
||||
this.fetchLink(
|
||||
endpoint,
|
||||
(res) => {
|
||||
if (res.data.submission) {
|
||||
callback(res.data.submission);
|
||||
if (res.data?.['link-update']?.submission) {
|
||||
callback(res.data?.['link-update']?.submission);
|
||||
} else {
|
||||
console.error('unexpected submission response', res);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import BaseApi from "./base";
|
||||
import { StoreState } from "../store/type";
|
||||
import { BackgroundConfig } from "../types/local-update";
|
||||
import { BackgroundConfig, LocalUpdateRemoteContentPolicy } from "../types/local-update";
|
||||
|
||||
export default class LocalApi extends BaseApi<StoreState> {
|
||||
getBaseHash() {
|
||||
@ -69,6 +69,16 @@ export default class LocalApi extends BaseApi<StoreState> {
|
||||
});
|
||||
}
|
||||
|
||||
setRemoteContentPolicy(policy: LocalUpdateRemoteContentPolicy) {
|
||||
this.store.handleEvent({
|
||||
data: {
|
||||
local: {
|
||||
remoteContentPolicy: policy
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dehydrate() {
|
||||
this.store.dehydrate();
|
||||
}
|
||||
|
@ -82,6 +82,17 @@ export default class PublishApi extends BaseApi {
|
||||
return this.action('publish', 'publish-action', act);
|
||||
}
|
||||
|
||||
groupify(bookId: string, group: Path | null) {
|
||||
return this.publishAction({
|
||||
groupify: {
|
||||
book: bookId,
|
||||
target: group,
|
||||
inclusive: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
newBook(bookId: string, title: string, description: string, group?: Path) {
|
||||
const groupInfo = group ? { 'group-path': group,
|
||||
invitees: [],
|
||||
|
@ -1,10 +1,12 @@
|
||||
import defaultApps from './default-apps';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
|
||||
const indexes = new Map([
|
||||
['commands', []],
|
||||
['subscriptions', []],
|
||||
['groups', []],
|
||||
['apps', []]
|
||||
['apps', []],
|
||||
['other', []]
|
||||
]);
|
||||
|
||||
// result schematic
|
||||
@ -41,8 +43,6 @@ const commandIndex = function () {
|
||||
}
|
||||
});
|
||||
|
||||
commands.push(result('Profile', '/~profile', 'profile', null));
|
||||
|
||||
return commands;
|
||||
};
|
||||
|
||||
@ -54,6 +54,9 @@ const appIndex = function (apps) {
|
||||
.filter((e) => {
|
||||
return apps[e]?.type?.basic;
|
||||
})
|
||||
.sort((a,b) => {
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((e) => {
|
||||
const obj = result(
|
||||
apps[e].type.basic.title,
|
||||
@ -70,6 +73,14 @@ const appIndex = function (apps) {
|
||||
return applications;
|
||||
};
|
||||
|
||||
const otherIndex = function() {
|
||||
const other = [];
|
||||
other.push(result('Profile and Settings', '/~profile/identity', 'profile', null));
|
||||
other.push(result('Log Out', '/~/logout', 'logout', null));
|
||||
|
||||
return other;
|
||||
};
|
||||
|
||||
export default function index(associations, apps) {
|
||||
// all metadata from all apps is indexed
|
||||
// into subscriptions and groups
|
||||
@ -99,7 +110,7 @@ export default function index(associations, apps) {
|
||||
title,
|
||||
`/~${app}${each['app-path']}`,
|
||||
app.charAt(0).toUpperCase() + app.slice(1),
|
||||
shipStart.slice(0, shipStart.indexOf('/'))
|
||||
cite(shipStart.slice(0, shipStart.indexOf('/')))
|
||||
);
|
||||
groups.push(obj);
|
||||
} else {
|
||||
@ -107,7 +118,7 @@ export default function index(associations, apps) {
|
||||
title,
|
||||
`/~${each['app-name']}/join${each['app-path']}`,
|
||||
app.charAt(0).toUpperCase() + app.slice(1),
|
||||
shipStart.slice(0, shipStart.indexOf('/'))
|
||||
(associations?.contacts?.[each['group-path']]?.metadata?.title || null)
|
||||
);
|
||||
subscriptions.push(obj);
|
||||
}
|
||||
@ -118,6 +129,7 @@ export default function index(associations, apps) {
|
||||
indexes.set('subscriptions', subscriptions);
|
||||
indexes.set('groups', groups);
|
||||
indexes.set('apps', appIndex(apps));
|
||||
indexes.set('other', otherIndex());
|
||||
|
||||
return indexes;
|
||||
};
|
||||
|
67
pkg/interface/src/logic/lib/tokenizeMessage.js
Normal file
67
pkg/interface/src/logic/lib/tokenizeMessage.js
Normal file
@ -0,0 +1,67 @@
|
||||
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source));
|
||||
|
||||
const isUrl = (string) => {
|
||||
try {
|
||||
return URL_REGEX.test(string);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizeMessage = (text) => {
|
||||
let messages = [];
|
||||
let message = [];
|
||||
let isInCodeBlock = false;
|
||||
let endOfCodeBlock = false;
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
if (index !== 0) {
|
||||
message.push('\n');
|
||||
}
|
||||
// A line of backticks enters and exits a codeblock
|
||||
if (line.startsWith('```')) {
|
||||
// But we need to check if we've ended a codeblock
|
||||
endOfCodeBlock = isInCodeBlock;
|
||||
isInCodeBlock = (!isInCodeBlock);
|
||||
} else {
|
||||
endOfCodeBlock = false;
|
||||
}
|
||||
|
||||
if (isInCodeBlock || endOfCodeBlock) {
|
||||
message.push(line);
|
||||
} else {
|
||||
line.split(/\s/).forEach((str) => {
|
||||
if (
|
||||
(str.startsWith('`') && str !== '`')
|
||||
|| (str === '`' && !isInCodeBlock)
|
||||
) {
|
||||
isInCodeBlock = true;
|
||||
} else if (
|
||||
(str.endsWith('`') && str !== '`')
|
||||
|| (str === '`' && isInCodeBlock)
|
||||
) {
|
||||
isInCodeBlock = false;
|
||||
}
|
||||
|
||||
if (isUrl(str) && !isInCodeBlock) {
|
||||
if (message.length > 0) {
|
||||
// If we're in the middle of a message, add it to the stack and reset
|
||||
messages.push(message);
|
||||
message = [];
|
||||
}
|
||||
messages.push([str]);
|
||||
message = [];
|
||||
} else {
|
||||
message.push(str);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (message.length) {
|
||||
// Add any remaining message
|
||||
messages.push(message);
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
export { tokenizeMessage as default, isUrl, URL_REGEX };
|
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
|
||||
export function useWaitForProps<P>(props: P, timeout: number) {
|
||||
export function useWaitForProps<P>(props: P, timeout: number = 0) {
|
||||
const [resolve, setResolve] = useState<() => void>(() => () => {});
|
||||
const [ready, setReady] = useState<(p: P) => boolean | undefined>();
|
||||
|
||||
@ -24,9 +24,11 @@ export function useWaitForProps<P>(props: P, timeout: number) {
|
||||
setReady(() => r);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
setResolve(() => resolve);
|
||||
setTimeout(() => {
|
||||
reject(new Error("Timed out"));
|
||||
}, timeout);
|
||||
if(timeout > 0) {
|
||||
setTimeout(() => {
|
||||
reject(new Error("Timed out"));
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
},
|
||||
[setResolve, setReady, timeout]
|
||||
|
@ -1,8 +1,9 @@
|
||||
import _ from 'lodash';
|
||||
import { StoreState } from '../../../store/type';
|
||||
import { StoreState } from '~/logic/store/type';
|
||||
import { Cage } from '~/types/cage';
|
||||
import { ChatUpdate } from '~/types/chat-update';
|
||||
import { ChatHookUpdate } from '~/types/chat-hook-update';
|
||||
import { Envelope } from "~/types/chat-update";
|
||||
|
||||
type ChatState = Pick<StoreState, 'chatInitialized' | 'chatSynced' | 'inbox' | 'pendingMessages'>;
|
||||
|
||||
@ -49,8 +50,11 @@ export default class ChatReducer<S extends ChatState> {
|
||||
messages(json: ChatUpdate, state: S) {
|
||||
const data = _.get(json, 'messages', false);
|
||||
if (data) {
|
||||
state.inbox[data.path].envelopes =
|
||||
state.inbox[data.path].envelopes.concat(data.envelopes);
|
||||
state.inbox[data.path].envelopes = _.unionBy(
|
||||
state.inbox[data.path].envelopes,
|
||||
data.envelopes,
|
||||
(envelope: Envelope) => envelope.uid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
164
pkg/interface/src/logic/reducers/graph-update.js
Normal file
164
pkg/interface/src/logic/reducers/graph-update.js
Normal file
@ -0,0 +1,164 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
|
||||
export const GraphReducer = (json, state) => {
|
||||
const data = _.get(json, 'graph-update', false);
|
||||
if (data) {
|
||||
keys(data, state);
|
||||
addGraph(data, state);
|
||||
removeGraph(data, state);
|
||||
addNodes(data, state);
|
||||
removeNodes(data, state);
|
||||
}
|
||||
};
|
||||
|
||||
const keys = (json, state) => {
|
||||
const data = _.get(json, 'keys', false);
|
||||
if (data) {
|
||||
state.graphKeys = new Set(data.map((res) => {
|
||||
let resource = res.ship + '/' + res.name;
|
||||
|
||||
if (!(resource in state.graphs)) {
|
||||
state.graphs[resource] = new Map();
|
||||
}
|
||||
|
||||
return resource;
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const addGraph = (json, state) => {
|
||||
|
||||
const _processNode = (node) => {
|
||||
// is empty
|
||||
if (!node.children) {
|
||||
node.children = new Map();
|
||||
return node;
|
||||
}
|
||||
|
||||
// is graph
|
||||
let converted = new Map();
|
||||
for (let i in node.children) {
|
||||
let item = node.children[i];
|
||||
let index = item[0].split('/').slice(1).map((ind) => {
|
||||
return parseInt(ind, 10);
|
||||
});
|
||||
|
||||
if (index.length === 0) { break; }
|
||||
|
||||
converted.set(
|
||||
index[index.length - 1],
|
||||
_processNode(item[1])
|
||||
);
|
||||
}
|
||||
node.children = converted;
|
||||
return node;
|
||||
};
|
||||
|
||||
const data = _.get(json, 'add-graph', false);
|
||||
if (data) {
|
||||
if (!('graphs' in state)) {
|
||||
state.graphs = {};
|
||||
}
|
||||
|
||||
let resource = data.resource.ship + '/' + data.resource.name;
|
||||
state.graphs[resource] = new Map();
|
||||
|
||||
for (let i in data.graph) {
|
||||
let item = data.graph[i];
|
||||
let index = item[0].split('/').slice(1).map((ind) => {
|
||||
return parseInt(ind, 10);
|
||||
});
|
||||
|
||||
if (index.length === 0) { break; }
|
||||
|
||||
let node = _processNode(item[1]);
|
||||
state.graphs[resource].set(index[index.length - 1], node);
|
||||
}
|
||||
state.graphKeys.add(resource);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const removeGraph = (json, state) => {
|
||||
const data = _.get(json, 'remove-graph', false);
|
||||
if (data) {
|
||||
if (!('graphs' in state)) {
|
||||
state.graphs = {};
|
||||
}
|
||||
let resource = data.resource.ship + '/' + data.resource.name;
|
||||
delete state.graphs[resource];
|
||||
}
|
||||
};
|
||||
|
||||
const addNodes = (json, state) => {
|
||||
const _addNode = (graph, index, node) => {
|
||||
// set child of graph
|
||||
if (index.length === 1) {
|
||||
graph.set(index[0], node);
|
||||
return graph;
|
||||
}
|
||||
|
||||
// set parent of graph
|
||||
let parNode = graph.get(index[0]);
|
||||
if (!parNode) {
|
||||
console.error('parent node does not exist, cannot add child');
|
||||
return;
|
||||
}
|
||||
parNode.children = _addNode(parNode.children, index.slice(1), node);
|
||||
graph.set(index[0], parNode);
|
||||
return graph;
|
||||
};
|
||||
|
||||
const data = _.get(json, 'add-nodes', false);
|
||||
if (data) {
|
||||
if (!('graphs' in state)) { return; }
|
||||
|
||||
let resource = data.resource.ship + '/' + data.resource.name;
|
||||
if (!(resource in state.graphs)) { return; }
|
||||
|
||||
for (let i in data.nodes) {
|
||||
let item = data.nodes[i];
|
||||
if (item[0].split('/').length === 0) { return; }
|
||||
|
||||
let index = item[0].split('/').slice(1).map((ind) => {
|
||||
return parseInt(ind, 10);
|
||||
});
|
||||
|
||||
if (index.length === 0) { return; }
|
||||
|
||||
// TODO: support adding nodes with children
|
||||
item[1].children = new Map();
|
||||
|
||||
state.graphs[resource] = _addNode(
|
||||
state.graphs[resource],
|
||||
index,
|
||||
item[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeNodes = (json, state) => {
|
||||
const data = _.get(json, 'remove-nodes', false);
|
||||
if (data) {
|
||||
console.log(data);
|
||||
if (!(data.resource in state.graphs)) { return; }
|
||||
|
||||
data.indices.forEach((index) => {
|
||||
console.log(index);
|
||||
if (index.split('/').length === 0) { return; }
|
||||
let indexArr = index.split('/').slice(1).map((ind) => {
|
||||
return parseInt(ind, 10);
|
||||
});
|
||||
|
||||
if (indexArr.length === 1) {
|
||||
state.graphs[data.resource].delete(indexArr[0]);
|
||||
} else {
|
||||
// TODO: recursive
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -78,6 +78,7 @@ export default class GroupReducer<S extends GroupState> {
|
||||
this.addGroup(data, state);
|
||||
this.removeGroup(data, state);
|
||||
this.changePolicy(data, state);
|
||||
this.expose(data, state);
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,6 +188,15 @@ export default class GroupReducer<S extends GroupState> {
|
||||
}
|
||||
}
|
||||
|
||||
expose(json: GroupUpdate, state: S) {
|
||||
if( 'expose' in json && state) {
|
||||
const { resource } = json.expose;
|
||||
const resourcePath = resourceAsPath(resource);
|
||||
state.groups[resourcePath].hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private inviteChangePolicy(diff: InvitePolicyDiff, policy: InvitePolicy) {
|
||||
if ('addInvites' in diff) {
|
||||
const { addInvites } = diff;
|
||||
|
@ -3,7 +3,7 @@ import { StoreState } from '~/store/type';
|
||||
import { Cage } from '~/types/cage';
|
||||
import { LocalUpdate, BackgroundConfig } from '~/types/local-update';
|
||||
|
||||
type LocalState = Pick<StoreState, 'sidebarShown' | 'omniboxShown' | 'baseHash' | 'hideAvatars' | 'hideNicknames' | 'background' | 'dark' | 'suspendedFocus'>;
|
||||
type LocalState = Pick<StoreState, 'sidebarShown' | 'omniboxShown' | 'baseHash' | 'hideAvatars' | 'hideNicknames' | 'background' | 'dark' | 'suspendedFocus' | 'remoteContentPolicy'>;
|
||||
|
||||
export default class LocalReducer<S extends LocalState> {
|
||||
rehydrate(state: S) {
|
||||
@ -18,7 +18,7 @@ export default class LocalReducer<S extends LocalState> {
|
||||
}
|
||||
|
||||
dehydrate(state: S) {
|
||||
const json = _.pick(state, ['hideNicknames' , 'hideAvatars' , 'background']);
|
||||
const json = _.pick(state, ['hideNicknames' , 'hideAvatars' , 'background', 'remoteContentPolicy']);
|
||||
localStorage.setItem('localReducer', JSON.stringify(json));
|
||||
}
|
||||
reduce(json: Cage, state: S) {
|
||||
@ -31,6 +31,7 @@ export default class LocalReducer<S extends LocalState> {
|
||||
this.hideAvatars(data, state)
|
||||
this.hideNicknames(data, state)
|
||||
this.omniboxShown(data, state);
|
||||
this.remoteContentPolicy(data, state);
|
||||
}
|
||||
}
|
||||
baseHash(obj: LocalUpdate, state: S) {
|
||||
@ -70,6 +71,12 @@ export default class LocalReducer<S extends LocalState> {
|
||||
}
|
||||
}
|
||||
|
||||
remoteContentPolicy(obj: LocalUpdate, state: S) {
|
||||
if('remoteContentPolicy' in obj) {
|
||||
state.remoteContentPolicy = obj.remoteContentPolicy;
|
||||
}
|
||||
}
|
||||
|
||||
hideAvatars(obj: LocalUpdate, state: S) {
|
||||
if('hideAvatars' in obj) {
|
||||
state.hideAvatars = obj.hideAvatars;
|
||||
|
@ -6,6 +6,7 @@ import InviteReducer from '../reducers/invite-update';
|
||||
import LinkReducer from '../reducers/link-update';
|
||||
import ListenReducer from '../reducers/listen-update';
|
||||
import LocalReducer from '../reducers/local';
|
||||
import S3Reducer from '../reducers/s3-update';
|
||||
|
||||
import BaseStore from './base';
|
||||
|
||||
@ -21,6 +22,7 @@ export default class LinksStore extends BaseStore {
|
||||
this.localReducer = new LocalReducer();
|
||||
this.linkReducer = new LinkReducer();
|
||||
this.listenReducer = new ListenReducer();
|
||||
this.s3Reducer = new S3Reducer();
|
||||
}
|
||||
|
||||
initialState() {
|
||||
@ -37,6 +39,7 @@ export default class LinksStore extends BaseStore {
|
||||
comments: {},
|
||||
seen: {},
|
||||
permissions: {},
|
||||
s3: {},
|
||||
sidebarShown: true
|
||||
};
|
||||
}
|
||||
@ -50,6 +53,7 @@ export default class LinksStore extends BaseStore {
|
||||
this.localReducer.reduce(data, this.state);
|
||||
this.linkReducer.reduce(data, this.state);
|
||||
this.listenReducer.reduce(data, this.state);
|
||||
this.s3Reducer.reduce(data, this.state);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ import { Cage } from '~/types/cage';
|
||||
import ContactReducer from '../reducers/contact-update';
|
||||
import LinkUpdateReducer from '../reducers/link-update';
|
||||
import S3Reducer from '../reducers/s3-update';
|
||||
import { GraphReducer } from '../reducers/graph-update';
|
||||
import GroupReducer from '../reducers/group-update';
|
||||
import PermissionReducer from '../reducers/permission-update';
|
||||
import PublishUpdateReducer from '../reducers/publish-update';
|
||||
@ -53,6 +54,12 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
suspendedFocus: null,
|
||||
baseHash: null,
|
||||
background: undefined,
|
||||
remoteContentPolicy: {
|
||||
imageShown: true,
|
||||
audioShown: true,
|
||||
videoShown: true,
|
||||
oembedShown: true,
|
||||
},
|
||||
hideAvatars: false,
|
||||
hideNicknames: false,
|
||||
invites: {},
|
||||
@ -64,6 +71,8 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
},
|
||||
groups: {},
|
||||
groupKeys: new Set(),
|
||||
graphs: {},
|
||||
graphKeys: new Set(),
|
||||
launch: {
|
||||
firstTime: false,
|
||||
tileOrdering: [],
|
||||
@ -106,5 +115,6 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
this.launchReducer.reduce(data, this.state);
|
||||
this.linkListenReducer.reduce(data, this.state);
|
||||
this.connReducer.reduce(data, this.state);
|
||||
GraphReducer(data, this.state);
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import { Permissions } from '~/types/permission-update';
|
||||
import { LaunchState, WeatherState } from '~/types/launch-update';
|
||||
import { LinkComments, LinkCollections, LinkSeen } from '~/types/link-update';
|
||||
import { ConnectionStatus } from '~/types/connection';
|
||||
import { BackgroundConfig } from '~/types/local-update';
|
||||
import { BackgroundConfig, LocalUpdateRemoteContentPolicy } from '~/types/local-update';
|
||||
|
||||
export interface StoreState {
|
||||
// local state
|
||||
@ -22,6 +22,7 @@ export interface StoreState {
|
||||
connection: ConnectionStatus;
|
||||
baseHash: string | null;
|
||||
background: BackgroundConfig;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
// invite state
|
||||
@ -35,6 +36,8 @@ export interface StoreState {
|
||||
groupKeys: Set<Path>;
|
||||
permissions: Permissions;
|
||||
s3: S3State;
|
||||
graphs: Object;
|
||||
graphKeys: Set<String>;
|
||||
|
||||
|
||||
// App specific states
|
||||
|
@ -27,12 +27,18 @@ const groupSubscriptions: AppSubscription[] = [
|
||||
['/synced', 'contact-hook']
|
||||
];
|
||||
|
||||
type AppName = 'publish' | 'chat' | 'link' | 'groups';
|
||||
const graphSubscriptions: AppSubscription[] = [
|
||||
['/keys', 'graph-store'],
|
||||
['/updates', 'graph-store']
|
||||
];
|
||||
|
||||
type AppName = 'publish' | 'chat' | 'link' | 'groups' | 'graph';
|
||||
const appSubscriptions: Record<AppName, AppSubscription[]> = {
|
||||
chat: chatSubscriptions,
|
||||
publish: publishSubscriptions,
|
||||
link: linkSubscriptions,
|
||||
groups: groupSubscriptions
|
||||
groups: groupSubscriptions,
|
||||
graph: graphSubscriptions
|
||||
};
|
||||
|
||||
export default class GlobalSubscription extends BaseSubscription<StoreState> {
|
||||
@ -40,8 +46,10 @@ export default class GlobalSubscription extends BaseSubscription<StoreState> {
|
||||
chat: [],
|
||||
publish: [],
|
||||
link: [],
|
||||
groups: []
|
||||
groups: [],
|
||||
graph: []
|
||||
};
|
||||
|
||||
start() {
|
||||
this.subscribe('/all', 'invite-store');
|
||||
this.subscribe('/groups', 'group-store');
|
||||
@ -67,7 +75,8 @@ export default class GlobalSubscription extends BaseSubscription<StoreState> {
|
||||
console.log(`${app} already started`);
|
||||
return;
|
||||
}
|
||||
this.openSubscriptions[app] = appSubscriptions[app].map(([path, agent]) => this.subscribe(path, agent));
|
||||
this.openSubscriptions[app] =
|
||||
appSubscriptions[app].map(([path, agent]) => this.subscribe(path, agent));
|
||||
}
|
||||
|
||||
stopApp(app: AppName) {
|
||||
|
@ -73,6 +73,10 @@ export interface Envelope {
|
||||
letter: Letter;
|
||||
}
|
||||
|
||||
export type IMessage = Envelope & {
|
||||
pending?: boolean
|
||||
};
|
||||
|
||||
interface LetterText {
|
||||
text: string;
|
||||
}
|
||||
|
@ -1,12 +1,3 @@
|
||||
export type LocalUpdate =
|
||||
LocalUpdateSidebarToggle
|
||||
| LocalUpdateSetDark
|
||||
| LocalUpdateBaseHash
|
||||
| LocalUpdateBackgroundConfig
|
||||
| LocalUpdateHideAvatars
|
||||
| LocalUpdateHideNicknames
|
||||
| LocalUpdateSetOmniboxShown;
|
||||
|
||||
interface LocalUpdateSidebarToggle {
|
||||
sidebarToggle: boolean;
|
||||
}
|
||||
@ -31,7 +22,16 @@ interface LocalUpdateHideNicknames {
|
||||
hideNicknames: boolean;
|
||||
}
|
||||
|
||||
export type BackgroundConfig = BackgroundConfigUrl | BackgroundConfigColor | undefined;
|
||||
interface LocalUpdateSetOmniboxShown {
|
||||
omniboxShown: boolean;
|
||||
}
|
||||
|
||||
export interface LocalUpdateRemoteContentPolicy {
|
||||
imageShown: boolean;
|
||||
audioShown: boolean;
|
||||
videoShown: boolean;
|
||||
oembedShown: boolean;
|
||||
}
|
||||
|
||||
interface BackgroundConfigUrl {
|
||||
type: 'url';
|
||||
@ -43,6 +43,14 @@ interface BackgroundConfigColor {
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface LocalUpdateSetOmniboxShown {
|
||||
omniboxShown: boolean;
|
||||
}
|
||||
export type BackgroundConfig = BackgroundConfigUrl | BackgroundConfigColor | undefined;
|
||||
|
||||
export type LocalUpdate =
|
||||
LocalUpdateSidebarToggle
|
||||
| LocalUpdateSetDark
|
||||
| LocalUpdateBaseHash
|
||||
| LocalUpdateBackgroundConfig
|
||||
| LocalUpdateHideAvatars
|
||||
| LocalUpdateHideNicknames
|
||||
| LocalUpdateSetOmniboxShown
|
||||
| LocalUpdateRemoteContentPolicy;
|
@ -1,7 +1,7 @@
|
||||
import { hot } from 'react-hot-loader/root';
|
||||
import 'react-hot-loader';
|
||||
import * as React from 'react';
|
||||
import { BrowserRouter as Router, Route, withRouter, Switch } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, withRouter } from 'react-router-dom';
|
||||
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
|
||||
import { sigil as sigiljs, stringRenderer } from 'urbit-sigil-js';
|
||||
import Helmet from 'react-helmet';
|
||||
@ -16,8 +16,7 @@ import dark from './themes/old-dark';
|
||||
|
||||
import { Content } from './components/Content';
|
||||
import StatusBar from './components/StatusBar';
|
||||
import Omnibox from './components/Omnibox';
|
||||
import ErrorComponent from './components/Error';
|
||||
import Omnibox from './components/leap/Omnibox';
|
||||
|
||||
import GlobalStore from '~/logic/store/store';
|
||||
import GlobalSubscription from '~/logic/subscription/global';
|
||||
@ -36,7 +35,7 @@ const Root = styled.div`
|
||||
background-size: cover;
|
||||
` : p.background?.type === 'color' ? `
|
||||
background-color: ${p.background.color};
|
||||
` : ``
|
||||
` : ''
|
||||
}
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
@ -135,7 +134,8 @@ class App extends React.Component {
|
||||
ship={this.ship}
|
||||
api={this.api}
|
||||
subscription={this.subscription}
|
||||
{...state} />
|
||||
{...state}
|
||||
/>
|
||||
</Router>
|
||||
</Root>
|
||||
</ThemeProvider>
|
||||
@ -143,6 +143,5 @@ class App extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default process.env.NODE_ENV === 'production' ? App : hot(App);
|
||||
|
||||
|
@ -7,7 +7,6 @@ import './css/custom.css';
|
||||
import { Skeleton } from './components/skeleton';
|
||||
import { Sidebar } from './components/sidebar';
|
||||
import { ChatScreen } from './components/chat';
|
||||
import { MemberScreen } from './components/member';
|
||||
import { SettingsScreen } from './components/settings';
|
||||
import { NewScreen } from './components/new';
|
||||
import { JoinScreen } from './components/join';
|
||||
@ -89,7 +88,8 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
|
||||
pendingMessages,
|
||||
groups,
|
||||
hideAvatars,
|
||||
hideNicknames
|
||||
hideNicknames,
|
||||
remoteContentPolicy
|
||||
} = props;
|
||||
|
||||
const renderChannelSidebar = (props, station?) => (
|
||||
@ -108,7 +108,7 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<Helmet defer={false}>
|
||||
<title>{totalUnreads > 0 ? `(${totalUnreads}) ` : ''}OS1 - Chat</title>
|
||||
</Helmet>
|
||||
<Switch>
|
||||
@ -231,56 +231,57 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
|
||||
envelopes: []
|
||||
};
|
||||
|
||||
let roomContacts = {};
|
||||
const associatedGroup =
|
||||
station in associations['chat'] &&
|
||||
'group-path' in associations.chat[station]
|
||||
? associations.chat[station]['group-path']
|
||||
: '';
|
||||
let roomContacts = {};
|
||||
const associatedGroup =
|
||||
station in associations['chat'] &&
|
||||
'group-path' in associations.chat[station]
|
||||
? associations.chat[station]['group-path']
|
||||
: '';
|
||||
|
||||
if (associations.chat[station] && associatedGroup in contacts) {
|
||||
roomContacts = contacts[associatedGroup];
|
||||
}
|
||||
if (associations.chat[station] && associatedGroup in contacts) {
|
||||
roomContacts = contacts[associatedGroup];
|
||||
}
|
||||
|
||||
const association =
|
||||
station in associations['chat'] ? associations.chat[station] : {};
|
||||
const association =
|
||||
station in associations['chat'] ? associations.chat[station] : {};
|
||||
|
||||
const group = groups[association['group-path']] || groupBunts.group();
|
||||
const group = groups[association['group-path']] || groupBunts.group();
|
||||
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
const popout = props.match.url.includes('/popout/');
|
||||
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
return (
|
||||
<Skeleton
|
||||
associations={associations}
|
||||
invites={invites}
|
||||
sidebarHideOnMobile={true}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebar={renderChannelSidebar(props, station)}
|
||||
>
|
||||
<ChatScreen
|
||||
chatSynced={chatSynced || {}}
|
||||
station={station}
|
||||
association={association}
|
||||
api={api}
|
||||
read={mailbox.config.read}
|
||||
mailboxSize={mailbox.config.length}
|
||||
envelopes={mailbox.envelopes}
|
||||
inbox={inbox}
|
||||
contacts={roomContacts}
|
||||
group={group}
|
||||
pendingMessages={pendingMessages}
|
||||
s3={s3}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
sidebar={renderChannelSidebar(props, station)}
|
||||
>
|
||||
<ChatScreen
|
||||
chatSynced={chatSynced || {}}
|
||||
station={station}
|
||||
association={association}
|
||||
api={api}
|
||||
read={mailbox.config.read}
|
||||
length={mailbox.config.length}
|
||||
envelopes={mailbox.envelopes}
|
||||
inbox={inbox}
|
||||
contacts={roomContacts}
|
||||
group={group}
|
||||
pendingMessages={pendingMessages}
|
||||
s3={s3}
|
||||
popout={popout}
|
||||
sidebarShown={sidebarShown}
|
||||
chatInitialized={chatInitialized}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
chatInitialized={chatInitialized}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
{...props}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
|
@ -8,13 +8,15 @@ import { ChatHeader } from './lib/chat-header';
|
||||
import { ChatInput } from "./lib/chat-input";
|
||||
import { deSig } from "~/logic/lib/util";
|
||||
import { ChatHookUpdate } from "~/types/chat-hook-update";
|
||||
import ChatApi from "~/logic/api/chat";
|
||||
import { Inbox, Envelope } from "~/types/chat-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import { Path, Patp } from "~/types/noun";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import {Group} from "~/types/group-update";
|
||||
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload';
|
||||
import { IUnControlledCodeMirror } from "react-codemirror2";
|
||||
|
||||
|
||||
type ChatScreenProps = RouteComponentProps<{
|
||||
@ -26,7 +28,7 @@ type ChatScreenProps = RouteComponentProps<{
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
read: number;
|
||||
length: number;
|
||||
mailboxSize: number;
|
||||
inbox: Inbox;
|
||||
contacts: Contacts;
|
||||
group: Group;
|
||||
@ -38,13 +40,16 @@ type ChatScreenProps = RouteComponentProps<{
|
||||
envelopes: Envelope[];
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
};
|
||||
|
||||
interface ChatScreenState {
|
||||
messages: Map<string, string>;
|
||||
dragover: boolean;
|
||||
}
|
||||
|
||||
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||
private chatInput: React.RefObject<ChatInput>;
|
||||
lastNumPending = 0;
|
||||
activityTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
@ -53,8 +58,11 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||
|
||||
this.state = {
|
||||
messages: new Map(),
|
||||
dragover: false,
|
||||
};
|
||||
|
||||
this.chatInput = React.createRef();
|
||||
|
||||
moment.updateLocale("en", {
|
||||
calendar: {
|
||||
sameDay: "[Today]",
|
||||
@ -67,6 +75,26 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||
});
|
||||
}
|
||||
|
||||
readyToUpload(): boolean {
|
||||
return Boolean(this.chatInput.current?.s3Uploader.current?.inputRef.current);
|
||||
}
|
||||
|
||||
onDragEnter() {
|
||||
if (!this.readyToUpload()) {
|
||||
return;
|
||||
}
|
||||
this.setState({ dragover: true });
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent) {
|
||||
this.setState({ dragover: false });
|
||||
if (!event.dataTransfer || !event.dataTransfer.files.length) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.chatInput.current?.uploadFiles(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
@ -97,42 +125,36 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||
!(props.station in props.chatSynced) &&
|
||||
props.envelopes.length > 0;
|
||||
|
||||
const unreadCount = props.length - props.read;
|
||||
const unreadCount = props.mailboxSize - props.read;
|
||||
const unreadMsg = unreadCount > 0 && props.envelopes[unreadCount - 1];
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={props.station}
|
||||
className="h-100 w-100 overflow-hidden flex flex-column relative">
|
||||
<ChatHeader
|
||||
match={props.match}
|
||||
location={props.location}
|
||||
api={props.api}
|
||||
group={props.group}
|
||||
association={props.association}
|
||||
station={props.station}
|
||||
sidebarShown={props.sidebarShown}
|
||||
popout={props.popout} />
|
||||
className="h-100 w-100 overflow-hidden flex flex-column relative"
|
||||
onDragEnter={this.onDragEnter.bind(this)}
|
||||
onDragOver={event => {
|
||||
event.preventDefault();
|
||||
if (!this.state.dragover) {
|
||||
this.setState({ dragover: true });
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => this.setState({ dragover: false })}
|
||||
onDrop={this.onDrop.bind(this)}
|
||||
>
|
||||
{this.state.dragover ? <SubmitDragger /> : null}
|
||||
<ChatHeader {...props} />
|
||||
<ChatWindow
|
||||
history={props.history}
|
||||
isChatMissing={isChatMissing}
|
||||
isChatLoading={isChatLoading}
|
||||
isChatUnsynced={isChatUnsynced}
|
||||
unreadCount={unreadCount}
|
||||
unreadMsg={unreadMsg}
|
||||
pendingMessages={pendingMessages}
|
||||
messages={props.envelopes}
|
||||
length={props.length}
|
||||
contacts={props.contacts}
|
||||
association={props.association}
|
||||
group={props.group}
|
||||
stationPendingMessages={pendingMessages}
|
||||
ship={props.match.params.ship}
|
||||
station={props.station}
|
||||
api={props.api}
|
||||
hideNicknames={props.hideNicknames}
|
||||
hideAvatars={props.hideAvatars}
|
||||
/>
|
||||
{...props} />
|
||||
<ChatInput
|
||||
ref={this.chatInput}
|
||||
api={props.api}
|
||||
numMsgs={lastMsgNum}
|
||||
station={props.station}
|
||||
@ -149,6 +171,7 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
|
||||
deleteMessage={() => this.setState({
|
||||
messages: this.state.messages.set(props.station, "")
|
||||
})}
|
||||
hideAvatars={props.hideAvatars}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -101,9 +101,14 @@ export default class ChatEditor extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const {
|
||||
inCodeMode,
|
||||
placeholder,
|
||||
message,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
const codeTheme = props.inCodeMode ? ' code' : '';
|
||||
const codeTheme = inCodeMode ? ' code' : '';
|
||||
|
||||
const options = {
|
||||
mode: MARKDOWN_CONFIG,
|
||||
@ -112,10 +117,13 @@ export default class ChatEditor extends Component {
|
||||
lineWrapping: true,
|
||||
scrollbarStyle: 'native',
|
||||
cursorHeight: 0.85,
|
||||
placeholder: props.inCodeMode ? 'Code...' : props.placeholder,
|
||||
placeholder: inCodeMode ? 'Code...' : placeholder,
|
||||
extraKeys: {
|
||||
'Enter': () => {
|
||||
this.submit();
|
||||
},
|
||||
'Esc': () => {
|
||||
this.editor?.getInputField().blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -124,11 +132,11 @@ export default class ChatEditor extends Component {
|
||||
<div
|
||||
className={
|
||||
'chat fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center' +
|
||||
(props.inCodeMode ? ' code' : '')
|
||||
(inCodeMode ? ' code' : '')
|
||||
}
|
||||
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}>
|
||||
<CodeEditor
|
||||
value={props.message}
|
||||
value={message}
|
||||
options={options}
|
||||
onChange={(e, d, v) => this.messageChange(e, d, v)}
|
||||
editorDidMount={(editor) => {
|
||||
@ -137,6 +145,7 @@ export default class ChatEditor extends Component {
|
||||
editor.focus();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,58 +1,61 @@
|
||||
import React, { Component, Fragment } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { ChatTabBar } from "./chat-tabbar";
|
||||
import { SidebarSwitcher } from "~/views/components/SidebarSwitch";
|
||||
import { deSig } from "~/logic/lib/util";
|
||||
import React, { Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { TabBar } from '~/views/components/chat-link-tabbar';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { deSig } from '~/logic/lib/util';
|
||||
|
||||
export const ChatHeader = (props) => {
|
||||
const isInPopout = props.popout ? "popout/" : "";
|
||||
const isInPopout = props.popout ? 'popout/' : '';
|
||||
const group = Array.from(props.group.members);
|
||||
let title = props.station.substr(1);
|
||||
if (props.association &&
|
||||
"metadata" in props.association &&
|
||||
props.association.metadata.tile !== "") {
|
||||
title = props.association.metadata.title
|
||||
if (props.association &&
|
||||
'metadata' in props.association &&
|
||||
props.association.metadata.tile !== '') {
|
||||
title = props.association.metadata.title;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
|
||||
style={{ height: "1rem" }}>
|
||||
<Link to="/~chat/">{"⟵ All Chats"}</Link>
|
||||
style={{ height: '1rem' }}
|
||||
>
|
||||
<Link to="/~chat/">{'⟵ All Chats'}</Link>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative " +
|
||||
"overflow-x-auto overflow-y-hidden flex-shrink-0 "
|
||||
'pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative ' +
|
||||
'overflow-x-auto overflow-y-hidden flex-shrink-0 '
|
||||
}
|
||||
style={{ height: 48 }}>
|
||||
style={{ height: 48 }}
|
||||
>
|
||||
<SidebarSwitcher
|
||||
sidebarShown={props.sidebarShown}
|
||||
popout={props.popout}
|
||||
api={props.api}
|
||||
/>
|
||||
<Link
|
||||
to={"/~chat/" + isInPopout + "room" + props.station}
|
||||
className="pt2 white-d">
|
||||
to={'/~chat/' + isInPopout + 'room' + props.station}
|
||||
className="pt2 white-d"
|
||||
>
|
||||
<h2
|
||||
className={
|
||||
"dib f9 fw4 lh-solid v-top " +
|
||||
(title === props.station.substr(1) ? "mono" : "")
|
||||
'dib f9 fw4 lh-solid v-top ' +
|
||||
(title === props.station.substr(1) ? 'mono' : '')
|
||||
}
|
||||
style={{ width: "max-content" }}>
|
||||
style={{ width: 'max-content' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</Link>
|
||||
<ChatTabBar
|
||||
<TabBar
|
||||
location={props.location}
|
||||
station={props.station}
|
||||
isOwner={deSig(props.match.params.ship) === window.ship}
|
||||
popoutHref={`/~chat/popout/room${props.station}`}
|
||||
settings={`/~chat/${isInPopout}settings${props.station}`}
|
||||
popout={props.popout}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -1,265 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import ChatEditor from './chat-editor';
|
||||
import { S3Upload } from './s3-upload'
|
||||
;
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
|
||||
|
||||
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source));
|
||||
|
||||
export class ChatInput extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
inCodeMode: false,
|
||||
};
|
||||
|
||||
this.submit = this.submit.bind(this);
|
||||
this.toggleCode = this.toggleCode.bind(this);
|
||||
}
|
||||
|
||||
uploadSuccess(url) {
|
||||
const { props } = this;
|
||||
props.api.chat.message(
|
||||
props.station,
|
||||
`~${window.ship}`,
|
||||
Date.now(),
|
||||
{ url }
|
||||
);
|
||||
}
|
||||
|
||||
uploadError(error) {
|
||||
// no-op for now
|
||||
}
|
||||
|
||||
toggleCode() {
|
||||
this.setState({
|
||||
inCodeMode: !this.state.inCodeMode
|
||||
});
|
||||
}
|
||||
|
||||
getLetterType(letter) {
|
||||
if (letter.startsWith('/me ')) {
|
||||
letter = letter.slice(4);
|
||||
// remove insignificant leading whitespace.
|
||||
// aces might be relevant to style.
|
||||
while (letter[0] === '\n') {
|
||||
letter = letter.slice(1);
|
||||
}
|
||||
|
||||
return {
|
||||
me: letter
|
||||
};
|
||||
} else if (this.isUrl(letter)) {
|
||||
return {
|
||||
url: letter
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
text: letter
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
isUrl(string) {
|
||||
try {
|
||||
return URL_REGEX.test(string);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
submit(text) {
|
||||
const { props, state } = this;
|
||||
if (state.inCodeMode) {
|
||||
this.setState({
|
||||
inCodeMode: false
|
||||
}, () => {
|
||||
props.api.chat.message(
|
||||
props.station,
|
||||
`~${window.ship}`,
|
||||
Date.now(), {
|
||||
code: {
|
||||
expression: text,
|
||||
output: undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let messages = [];
|
||||
let message = [];
|
||||
let isInCodeBlock = false;
|
||||
let endOfCodeBlock = false;
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
if (index !== 0) {
|
||||
message.push('\n');
|
||||
}
|
||||
// A line of backticks enters and exits a codeblock
|
||||
if (line.startsWith('```')) {
|
||||
// But we need to check if we've ended a codeblock
|
||||
endOfCodeBlock = isInCodeBlock;
|
||||
isInCodeBlock = (!isInCodeBlock);
|
||||
} else {
|
||||
endOfCodeBlock = false;
|
||||
}
|
||||
|
||||
if (isInCodeBlock || endOfCodeBlock) {
|
||||
message.push(line);
|
||||
} else {
|
||||
line.split(/\s/).forEach((str) => {
|
||||
if (
|
||||
(str.startsWith('`') && str !== '`')
|
||||
|| (str === '`' && !isInCodeBlock)
|
||||
) {
|
||||
isInCodeBlock = true;
|
||||
} else if (
|
||||
(str.endsWith('`') && str !== '`')
|
||||
|| (str === '`' && isInCodeBlock)
|
||||
) {
|
||||
isInCodeBlock = false;
|
||||
}
|
||||
|
||||
if (this.isUrl(str) && !isInCodeBlock) {
|
||||
if (message.length > 0) {
|
||||
// If we're in the middle of a message, add it to the stack and reset
|
||||
messages.push(message);
|
||||
message = [];
|
||||
}
|
||||
messages.push([str]);
|
||||
message = [];
|
||||
} else {
|
||||
message.push(str);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (message.length) {
|
||||
// Add any remaining message
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
props.deleteMessage();
|
||||
|
||||
messages.forEach((message) => {
|
||||
if (message.length > 0) {
|
||||
message = this.getLetterType(message.join(' '));
|
||||
props.api.chat.message(
|
||||
props.station,
|
||||
`~${window.ship}`,
|
||||
Date.now(),
|
||||
message
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// perf testing:
|
||||
/*let closure = () => {
|
||||
let x = 0;
|
||||
for (var i = 0; i < 30; i++) {
|
||||
x++;
|
||||
props.api.chat.message(
|
||||
props.station,
|
||||
`~${window.ship}`,
|
||||
Date.now(),
|
||||
{
|
||||
text: `${x}`
|
||||
}
|
||||
);
|
||||
}
|
||||
setTimeout(closure, 1000);
|
||||
};
|
||||
this.closure = closure.bind(this);
|
||||
setTimeout(this.closure, 2000);*/
|
||||
}
|
||||
|
||||
uploadSuccess(url) {
|
||||
const { props } = this;
|
||||
props.api.chat.message(
|
||||
props.station,
|
||||
`~${window.ship}`,
|
||||
Date.now(),
|
||||
{ url }
|
||||
);
|
||||
}
|
||||
|
||||
uploadError(error) {
|
||||
// no-op for now
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const color = props.ownerContact
|
||||
? uxToHex(props.ownerContact.color) : '000000';
|
||||
|
||||
const sigilClass = props.ownerContact
|
||||
? '' : 'mix-blend-diff';
|
||||
|
||||
const avatar = (props.ownerContact && (props.ownerContact.avatar !== null))
|
||||
? <img src={props.ownerContact.avatar} height={24} width={24} className="dib" />
|
||||
: <Sigil
|
||||
ship={window.ship}
|
||||
size={24}
|
||||
color={`#${color}`}
|
||||
classes={sigilClass}
|
||||
/>;
|
||||
|
||||
return (
|
||||
<div className={
|
||||
"pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white " +
|
||||
"bg-gray0-d relative"
|
||||
}
|
||||
style={{ flexGrow: 1 }}>
|
||||
<div className="fl"
|
||||
style={{
|
||||
marginTop: 6,
|
||||
flexBasis: 24,
|
||||
height: 24
|
||||
}}>
|
||||
{avatar}
|
||||
</div>
|
||||
<ChatEditor
|
||||
inCodeMode={state.inCodeMode}
|
||||
submit={this.submit}
|
||||
onUnmount={props.onUnmount}
|
||||
message={props.message}
|
||||
placeholder='Message...' />
|
||||
<div className="ml2 mr2"
|
||||
style={{
|
||||
height: '16px',
|
||||
width: '16px',
|
||||
flexBasis: 16,
|
||||
marginTop: 10
|
||||
}}>
|
||||
<S3Upload
|
||||
configuration={props.s3.configuration}
|
||||
credentials={props.s3.credentials}
|
||||
uploadSuccess={this.uploadSuccess.bind(this)}
|
||||
uploadError={this.uploadError.bind(this)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
height: '16px',
|
||||
width: '16px',
|
||||
flexBasis: 16,
|
||||
marginTop: 10
|
||||
}}>
|
||||
<img style={{
|
||||
filter: state.inCodeMode && 'invert(100%)',
|
||||
height: '14px',
|
||||
width: '14px',
|
||||
}}
|
||||
onClick={this.toggleCode}
|
||||
src="/~chat/img/CodeEval.png"
|
||||
className="contrast-10-d bg-white bg-none-d ba b--gray1-d br1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
255
pkg/interface/src/views/apps/chat/components/lib/chat-input.tsx
Normal file
255
pkg/interface/src/views/apps/chat/components/lib/chat-input.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
import React, { Component } from 'react';
|
||||
import ChatEditor from './chat-editor';
|
||||
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload'
|
||||
;
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import tokenizeMessage, { isUrl } from '~/logic/lib/tokenizeMessage';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { Envelope } from '~/types/chat-update';
|
||||
import { Contacts, S3Configuration } from '~/types';
|
||||
|
||||
interface ChatInputProps {
|
||||
api: GlobalApi;
|
||||
numMsgs: number;
|
||||
station: any;
|
||||
owner: string;
|
||||
ownerContact: any;
|
||||
envelopes: Envelope[];
|
||||
contacts: Contacts;
|
||||
onUnmount(msg: string): void;
|
||||
s3: any;
|
||||
placeholder: string;
|
||||
message: string;
|
||||
deleteMessage(): void;
|
||||
hideAvatars: boolean;
|
||||
onPaste?(): void;
|
||||
}
|
||||
|
||||
interface ChatInputState {
|
||||
inCodeMode: boolean;
|
||||
submitFocus: boolean;
|
||||
uploadingPaste: boolean;
|
||||
}
|
||||
|
||||
|
||||
export class ChatInput extends Component<ChatInputProps, ChatInputState> {
|
||||
public s3Uploader: React.RefObject<S3Upload>;
|
||||
private chatEditor: React.RefObject<ChatEditor>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
inCodeMode: false,
|
||||
submitFocus: false,
|
||||
uploadingPaste: false,
|
||||
};
|
||||
|
||||
this.s3Uploader = React.createRef();
|
||||
this.chatEditor = React.createRef();
|
||||
|
||||
this.submit = this.submit.bind(this);
|
||||
this.toggleCode = this.toggleCode.bind(this);
|
||||
|
||||
}
|
||||
|
||||
toggleCode() {
|
||||
this.setState({
|
||||
inCodeMode: !this.state.inCodeMode
|
||||
});
|
||||
}
|
||||
|
||||
getLetterType(letter) {
|
||||
if (letter.startsWith('/me ')) {
|
||||
letter = letter.slice(4);
|
||||
// remove insignificant leading whitespace.
|
||||
// aces might be relevant to style.
|
||||
while (letter[0] === '\n') {
|
||||
letter = letter.slice(1);
|
||||
}
|
||||
|
||||
return {
|
||||
me: letter
|
||||
};
|
||||
} else if (isUrl(letter)) {
|
||||
return {
|
||||
url: letter
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
text: letter
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
submit(text) {
|
||||
const { props, state } = this;
|
||||
if (state.inCodeMode) {
|
||||
this.setState({
|
||||
inCodeMode: false
|
||||
}, () => {
|
||||
props.api.chat.message(
|
||||
props.station,
|
||||
`~${window.ship}`,
|
||||
Date.now(), {
|
||||
code: {
|
||||
expression: text,
|
||||
output: undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = tokenizeMessage(text);
|
||||
|
||||
props.deleteMessage();
|
||||
|
||||
messages.forEach((message) => {
|
||||
if (message.length > 0) {
|
||||
message = this.getLetterType(message.join(' '));
|
||||
props.api.chat.message(
|
||||
props.station,
|
||||
`~${window.ship}`,
|
||||
Date.now(),
|
||||
message
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
uploadSuccess(url) {
|
||||
const { props } = this;
|
||||
if (this.state.uploadingPaste) {
|
||||
this.chatEditor.current.editor.setValue(url);
|
||||
this.setState({ uploadingPaste: false });
|
||||
} else {
|
||||
props.api.chat.message(
|
||||
props.station,
|
||||
`~${window.ship}`,
|
||||
Date.now(),
|
||||
{ url }
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
uploadError(error) {
|
||||
// no-op for now
|
||||
}
|
||||
|
||||
readyToUpload(): boolean {
|
||||
return Boolean(this.s3Uploader.current?.inputRef.current);
|
||||
}
|
||||
|
||||
onPaste(codemirrorInstance, event: ClipboardEvent) {
|
||||
if (!event.clipboardData || !event.clipboardData.files.length) {
|
||||
return;
|
||||
}
|
||||
this.setState({ uploadingPaste: true });
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.uploadFiles(event.clipboardData.files);
|
||||
}
|
||||
|
||||
uploadFiles(files: FileList) {
|
||||
if (!this.readyToUpload()) {
|
||||
return;
|
||||
}
|
||||
this.s3Uploader.current.inputRef.current.files = files;
|
||||
const fire = document.createEvent("HTMLEvents");
|
||||
fire.initEvent("change", true, true);
|
||||
this.s3Uploader.current?.inputRef.current?.dispatchEvent(fire);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
const color = props.ownerContact
|
||||
? uxToHex(props.ownerContact.color) : '000000';
|
||||
|
||||
const sigilClass = props.ownerContact
|
||||
? '' : 'mix-blend-diff';
|
||||
|
||||
const avatar = (
|
||||
props.ownerContact &&
|
||||
((props.ownerContact.avatar !== null) && !props.hideAvatars)
|
||||
)
|
||||
? <img src={props.ownerContact.avatar} height={24} width={24} className="dib" />
|
||||
: <Sigil
|
||||
ship={window.ship}
|
||||
size={24}
|
||||
color={`#${color}`}
|
||||
classes={sigilClass}
|
||||
/>;
|
||||
|
||||
return (
|
||||
<div className={
|
||||
"pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white " +
|
||||
"bg-gray0-d relative"
|
||||
}
|
||||
style={{ flexGrow: 1 }}
|
||||
>
|
||||
<div className="fl"
|
||||
style={{
|
||||
marginTop: 6,
|
||||
flexBasis: 24,
|
||||
height: 24
|
||||
}}>
|
||||
{avatar}
|
||||
</div>
|
||||
<ChatEditor
|
||||
ref={this.chatEditor}
|
||||
inCodeMode={state.inCodeMode}
|
||||
submit={this.submit}
|
||||
onUnmount={props.onUnmount}
|
||||
message={props.message}
|
||||
onPaste={this.onPaste.bind(this)}
|
||||
placeholder='Message...'
|
||||
/>
|
||||
<div className="ml2 mr2"
|
||||
style={{
|
||||
height: '16px',
|
||||
width: '16px',
|
||||
flexBasis: 16,
|
||||
marginTop: 10
|
||||
}}>
|
||||
<S3Upload
|
||||
ref={this.s3Uploader}
|
||||
configuration={props.s3.configuration}
|
||||
credentials={props.s3.credentials}
|
||||
uploadSuccess={this.uploadSuccess.bind(this)}
|
||||
uploadError={this.uploadError.bind(this)}
|
||||
accept="*"
|
||||
>
|
||||
<img
|
||||
className="invert-d"
|
||||
src="/~chat/img/ImageUpload.png"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
</S3Upload>
|
||||
</div>
|
||||
<div style={{
|
||||
height: '16px',
|
||||
width: '16px',
|
||||
flexBasis: 16,
|
||||
marginTop: 10
|
||||
}}>
|
||||
<img style={{
|
||||
filter: state.inCodeMode ? 'invert(100%)' : '',
|
||||
height: '14px',
|
||||
width: '14px',
|
||||
}}
|
||||
onClick={this.toggleCode}
|
||||
src="/~chat/img/CodeEval.png"
|
||||
className="contrast-10-d bg-white bg-none-d ba b--gray1-d br1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -2,87 +2,91 @@ import React, { PureComponent, Fragment } from "react";
|
||||
import moment from "moment";
|
||||
|
||||
import { Message } from "./message";
|
||||
import { Envelope } from "~/types/chat-update";
|
||||
import _ from "lodash";
|
||||
|
||||
type IMessage = Envelope & { pending?: boolean };
|
||||
|
||||
|
||||
export const ChatMessage = (props) => {
|
||||
const {
|
||||
msg,
|
||||
previousMsg,
|
||||
nextMsg,
|
||||
isLastUnread,
|
||||
group,
|
||||
association,
|
||||
contacts,
|
||||
unreadRef,
|
||||
hideAvatars,
|
||||
hideNicknames
|
||||
} = props;
|
||||
|
||||
// Render sigil if previous message is not by the same sender
|
||||
const aut = ["author"];
|
||||
const renderSigil =
|
||||
_.get(nextMsg, aut) !== _.get(msg, aut, msg.author);
|
||||
const paddingTop = renderSigil;
|
||||
const paddingBot =
|
||||
_.get(previousMsg, aut) !== _.get(msg, aut, msg.author);
|
||||
|
||||
const when = ["when"];
|
||||
const dayBreak =
|
||||
moment(_.get(nextMsg, when)).format("YYYY.MM.DD") !==
|
||||
moment(_.get(msg, when)).format("YYYY.MM.DD");
|
||||
|
||||
const messageElem = (
|
||||
<Message
|
||||
key={msg.uid}
|
||||
msg={msg}
|
||||
renderSigil={renderSigil}
|
||||
paddingTop={paddingTop}
|
||||
paddingBot={paddingBot}
|
||||
pending={Boolean(msg.pending)}
|
||||
group={group}
|
||||
contacts={contacts}
|
||||
association={association}
|
||||
hideNicknames={hideNicknames}
|
||||
hideAvatars={hideAvatars}
|
||||
/>
|
||||
);
|
||||
|
||||
if (props.isLastUnread) {
|
||||
return (
|
||||
<Fragment key={msg.uid}>
|
||||
{messageElem}
|
||||
<div ref={unreadRef}
|
||||
className="mv2 green2 flex items-center f9">
|
||||
<hr className="dn-s ma0 w2 b--green2 bt-0" />
|
||||
<p className="mh4">New messages below</p>
|
||||
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
|
||||
{dayBreak && (
|
||||
<p className="gray2 mh4">
|
||||
{moment(_.get(msg, when)).calendar()}
|
||||
</p>
|
||||
)}
|
||||
<hr
|
||||
style={{ width: "calc(50% - 48px)" }}
|
||||
className="b--green2 ma0 bt-0"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
export class ChatMessage extends PureComponent {
|
||||
render() {
|
||||
const {
|
||||
msg,
|
||||
previousMsg,
|
||||
nextMsg,
|
||||
isFirstUnread,
|
||||
group,
|
||||
association,
|
||||
contacts,
|
||||
unreadRef,
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
remoteContentPolicy,
|
||||
className = ''
|
||||
} = this.props;
|
||||
|
||||
// Render sigil if previous message is not by the same sender
|
||||
const aut = ["author"];
|
||||
const renderSigil =
|
||||
_.get(nextMsg, aut) !== _.get(msg, aut, msg.author);
|
||||
const paddingTop = renderSigil;
|
||||
const paddingBot =
|
||||
_.get(previousMsg, aut) !== _.get(msg, aut, msg.author);
|
||||
|
||||
const when = ["when"];
|
||||
const dayBreak =
|
||||
moment(_.get(nextMsg, when)).format("YYYY.MM.DD") !==
|
||||
moment(_.get(msg, when)).format("YYYY.MM.DD");
|
||||
|
||||
const messageElem = (
|
||||
<Message
|
||||
key={msg.uid}
|
||||
msg={msg}
|
||||
renderSigil={renderSigil}
|
||||
paddingTop={paddingTop}
|
||||
paddingBot={paddingBot}
|
||||
pending={Boolean(msg.pending)}
|
||||
group={group}
|
||||
contacts={contacts}
|
||||
association={association}
|
||||
hideNicknames={hideNicknames}
|
||||
hideAvatars={hideAvatars}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
} else if (dayBreak) {
|
||||
return (
|
||||
<Fragment key={msg.uid}>
|
||||
{messageElem}
|
||||
<div
|
||||
className="pv3 gray2 b--gray2 flex items-center justify-center f9 "
|
||||
>
|
||||
<p>{moment(_.get(msg, when)).calendar()}</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
return messageElem;
|
||||
|
||||
if (isFirstUnread) {
|
||||
return (
|
||||
<Fragment key={msg.uid}>
|
||||
{messageElem}
|
||||
<div ref={unreadRef}
|
||||
className="mv2 green2 flex items-center f9">
|
||||
<hr className="dn-s ma0 w2 b--green2 bt-0" />
|
||||
<p className="mh4">New messages below</p>
|
||||
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
|
||||
{dayBreak && (
|
||||
<p className="gray2 mh4">
|
||||
{moment(_.get(msg, when)).calendar()}
|
||||
</p>
|
||||
)}
|
||||
<hr
|
||||
style={{ width: "calc(50% - 48px)" }}
|
||||
className="b--green2 ma0 bt-0"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
} else if (dayBreak) {
|
||||
return (
|
||||
<Fragment key={msg.uid}>
|
||||
<div
|
||||
className="pv3 gray2 b--gray2 flex items-center justify-center f9 "
|
||||
>
|
||||
<p>{moment(_.get(msg, when)).calendar()}</p>
|
||||
</div>
|
||||
{messageElem}
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
return messageElem;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}
|
@ -1,143 +0,0 @@
|
||||
import React, { Component, Fragment } from "react";
|
||||
|
||||
import { scrollIsAtTop, scrollIsAtBottom } from "~/logic/lib/util";
|
||||
|
||||
// Restore chat position on FF when new messages come in
|
||||
const recalculateScrollTop = (lastScrollHeight, scrollContainer) => {
|
||||
if (!scrollContainer || !lastScrollHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newScrollTop = scrollContainer.scrollHeight - lastScrollHeight;
|
||||
if (scrollContainer.scrollTop !== 0 ||
|
||||
scrollContainer.scrollTop === newScrollTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight - lastScrollHeight;
|
||||
};
|
||||
|
||||
|
||||
export class ChatScrollContainer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// only for FF
|
||||
this.state = {
|
||||
lastScrollHeight: null
|
||||
};
|
||||
|
||||
this.isTriggeredScroll = false;
|
||||
|
||||
this.isAtBottom = true;
|
||||
this.isAtTop = false;
|
||||
|
||||
this.containerDidScroll = this.containerDidScroll.bind(this);
|
||||
|
||||
this.containerRef = React.createRef();
|
||||
this.scrollRef = React.createRef();
|
||||
}
|
||||
|
||||
containerDidScroll(e) {
|
||||
const { props } = this;
|
||||
if (scrollIsAtTop(e.target)) {
|
||||
// Save scroll position for FF
|
||||
if (navigator.userAgent.includes("Firefox")) {
|
||||
this.setState({
|
||||
lastScrollHeight: e.target.scrollHeight,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.isAtTop) {
|
||||
props.scrollIsAtTop();
|
||||
}
|
||||
|
||||
this.isTriggeredScroll = false;
|
||||
this.isAtBottom = false;
|
||||
this.isAtTop = true;
|
||||
} else if (scrollIsAtBottom(e.target) && !this.isTriggeredScroll) {
|
||||
if (!this.isAtBottom) {
|
||||
props.scrollIsAtBottom();
|
||||
}
|
||||
|
||||
this.isTriggeredScroll = false;
|
||||
this.isAtBottom = true;
|
||||
this.isAtTop = false;
|
||||
} else {
|
||||
this.isAtBottom = false;
|
||||
this.isAtTop = false;
|
||||
this.isTriggeredScroll = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Replace with just the "not Firefox" implementation
|
||||
// when Firefox #1042151 is patched.
|
||||
|
||||
if (navigator.userAgent.includes("Firefox")) {
|
||||
return this.firefoxScrollContainer();
|
||||
} else {
|
||||
return this.normalScrollContainer();
|
||||
}
|
||||
}
|
||||
|
||||
firefoxScrollContainer() {
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-y-scroll h-100"
|
||||
onScroll={this.containerDidScroll}
|
||||
ref={this.containerRef}>
|
||||
<div
|
||||
className="bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse"
|
||||
style={{ resize: "vertical" }}>
|
||||
<div ref={this.scrollRef}></div>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
normalScrollContainer() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex " +
|
||||
"flex-column-reverse relative"
|
||||
}
|
||||
style={{ height: "100%", resize: "vertical" }}
|
||||
onScroll={this.containerDidScroll}>
|
||||
<div ref={this.scrollRef}></div>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.isTriggeredScroll = true;
|
||||
if (this.scrollRef.current) {
|
||||
this.scrollRef.current.scrollIntoView(false);
|
||||
}
|
||||
|
||||
if (navigator.userAgent.includes("Firefox")) {
|
||||
recalculateScrollTop(
|
||||
this.state.lastScrollHeight,
|
||||
this.scrollContainer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
scrollToReference(ref) {
|
||||
this.isTriggeredScroll = true;
|
||||
if (this.scrollRef.current && ref.current) {
|
||||
ref.current.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
if (navigator.userAgent.includes("Firefox")) {
|
||||
recalculateScrollTop(
|
||||
this.state.lastScrollHeight,
|
||||
this.scrollContainer
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,54 +1,140 @@
|
||||
import React, { Component, Fragment } from "react";
|
||||
import { Virtuoso as VirtualList, VirtuosoMethods } from 'react-virtuoso';
|
||||
|
||||
import { ChatMessage } from './chat-message';
|
||||
import { ChatScrollContainer } from "./chat-scroll-container";
|
||||
import { UnreadNotice } from "./unread-notice";
|
||||
import { ResubscribeElement } from "./resubscribe-element";
|
||||
import { BacklogElement } from "./backlog-element";
|
||||
import { Envelope, IMessage } from "~/types/chat-update";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { Patp, Path } from "~/types/noun";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import { Group } from "~/types/group-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import _ from "lodash";
|
||||
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||
import { ListRange } from "react-virtuoso/dist/engines/scrollSeekEngine";
|
||||
|
||||
|
||||
const MAX_BACKLOG_SIZE = 1000;
|
||||
const DEFAULT_BACKLOG_SIZE = 200;
|
||||
const PAGE_SIZE = 50;
|
||||
const INITIAL_LOAD = 20;
|
||||
const DEFAULT_BACKLOG_SIZE = 200;
|
||||
const IDLE_THRESHOLD = 3;
|
||||
|
||||
const Placeholder = ({ height, index, className = '', style = {}, ...props }) => (
|
||||
<div className={`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ${className}`} style={{ height, ...style }} {...props}>
|
||||
<div className="fl pr3 v-top bg-white bg-gray0-d">
|
||||
<span
|
||||
className="db bg-gray2 bg-white-d"
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "50%",
|
||||
visibility: (index % 5 == 0) ? "initial" : "hidden",
|
||||
}}
|
||||
></span>
|
||||
</div>
|
||||
<div className="fr clamp-message white-d" style={{ flexGrow: 1, marginTop: -8 }}>
|
||||
<div className="hide-child" style={{paddingTop: "6px", visibility: (index % 5 == 0) ? "initial" : "hidden" }}>
|
||||
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
|
||||
<span className="mw5 db"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></span>
|
||||
</p>
|
||||
<p className="v-mid mono f9 gray2 dib"><span className="bg-gray5 bg-gray1-d db w-100 h-100" style={{height: "1em", width: `${(index % 3 + 1) * 3}em`}}></span></p>
|
||||
<p className="v-mid mono f9 ml2 gray2 dib child dn-s"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></p>
|
||||
</div>
|
||||
<span className="bg-gray5 bg-gray1-d db w-100 h-100 db" style={{height: `1em`, width: `${(index % 5) * 20}%`}}></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
export class ChatWindow extends Component {
|
||||
type ChatWindowProps = RouteComponentProps<{
|
||||
ship: Patp;
|
||||
station: string;
|
||||
}> & {
|
||||
unreadCount: number;
|
||||
envelopes: Envelope[];
|
||||
isChatMissing: boolean;
|
||||
isChatLoading: boolean;
|
||||
isChatUnsynced: boolean;
|
||||
unreadMsg: Envelope | false;
|
||||
stationPendingMessages: IMessage[];
|
||||
mailboxSize: number;
|
||||
contacts: Contacts;
|
||||
association: Association;
|
||||
group: Group;
|
||||
ship: Patp;
|
||||
station: any;
|
||||
api: GlobalApi;
|
||||
hideNicknames: boolean;
|
||||
hideAvatars: boolean;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
}
|
||||
|
||||
interface ChatWindowState {
|
||||
fetchPending: boolean;
|
||||
idle: boolean;
|
||||
range: ListRange;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
|
||||
private unreadReference: React.RefObject<Component>;
|
||||
private virtualList: React.RefObject<VirtuosoMethods>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
numPages: 1,
|
||||
};
|
||||
|
||||
this.hasAskedForMessages = false;
|
||||
this.state = {
|
||||
fetchPending: false,
|
||||
idle: (this.initialIndex() < props.mailboxSize - IDLE_THRESHOLD) ? true : false,
|
||||
range: { startIndex: 0, endIndex: 0},
|
||||
initialized: false
|
||||
};
|
||||
|
||||
this.dismissUnread = this.dismissUnread.bind(this);
|
||||
this.scrollIsAtBottom = this.scrollIsAtBottom.bind(this);
|
||||
this.scrollIsAtTop = this.scrollIsAtTop.bind(this);
|
||||
this.initialIndex = this.initialIndex.bind(this);
|
||||
this.scrollToUnread = this.scrollToUnread.bind(this);
|
||||
|
||||
this.scrollReference = React.createRef();
|
||||
this.unreadReference = React.createRef();
|
||||
this.virtualList = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.initialFetch();
|
||||
}
|
||||
|
||||
if (this.state.numPages === 1 && this.props.unreadCount < INITIAL_LOAD) {
|
||||
this.dismissUnread();
|
||||
this.scrollToBottom();
|
||||
}
|
||||
initialIndex() {
|
||||
const { mailboxSize, unreadCount } = this.props;
|
||||
return Math.min(Math.max(mailboxSize - 1 < INITIAL_LOAD
|
||||
? 0
|
||||
: unreadCount // otherwise if there are unread messages
|
||||
? mailboxSize - unreadCount - 1 // put the one right before at the top
|
||||
: mailboxSize - 1,
|
||||
0), mailboxSize);
|
||||
}
|
||||
|
||||
initialFetch() {
|
||||
const { props } = this;
|
||||
if (props.messages.length > 0) {
|
||||
const unreadUnloaded = props.unreadCount - props.messages.length;
|
||||
|
||||
if (unreadUnloaded <= MAX_BACKLOG_SIZE &&
|
||||
unreadUnloaded + INITIAL_LOAD > DEFAULT_BACKLOG_SIZE) {
|
||||
this.fetchBacklog(unreadUnloaded + INITIAL_LOAD);
|
||||
} else {
|
||||
this.fetchBacklog(DEFAULT_BACKLOG_SIZE);
|
||||
const { envelopes, mailboxSize, unreadCount } = this.props;
|
||||
if (envelopes.length > 0) {
|
||||
const start = Math.min(mailboxSize - unreadCount, mailboxSize - DEFAULT_BACKLOG_SIZE);
|
||||
this.fetchMessages(start, start + DEFAULT_BACKLOG_SIZE, true);
|
||||
const initialIndex = this.initialIndex();
|
||||
if (initialIndex < mailboxSize - IDLE_THRESHOLD) {
|
||||
this.setState({ idle: true });
|
||||
}
|
||||
if (unreadCount !== mailboxSize) {
|
||||
this.virtualList.current?.scrollToIndex({
|
||||
index: initialIndex,
|
||||
align: initialIndex <= 1 ? 'end' : 'start'
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.setState({ initialized: true });
|
||||
}, 500);
|
||||
} else {
|
||||
this.setState({ initialized: true });
|
||||
}
|
||||
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.initialFetch();
|
||||
@ -57,140 +143,159 @@ export class ChatWindow extends Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { props, state } = this;
|
||||
const { isChatMissing, history, envelopes, mailboxSize, unreadCount } = this.props;
|
||||
let { idle } = this.state;
|
||||
|
||||
if (props.isChatMissing) {
|
||||
props.history.push("/~chat");
|
||||
} else if (props.messages.length >= prevProps.messages.length + 10) {
|
||||
this.hasAskedForMessages = false;
|
||||
let numPages = props.unreadCount > 0 ?
|
||||
Math.ceil(props.unreadCount / PAGE_SIZE) : this.state.numPages;
|
||||
if (isChatMissing) {
|
||||
history.push("/~chat");
|
||||
} else if (envelopes.length !== prevProps.envelopes.length && this.state.fetchPending) {
|
||||
this.setState({ fetchPending: false });
|
||||
}
|
||||
|
||||
if (this.state.numPages === numPages) {
|
||||
if (props.unreadCount > 20) {
|
||||
this.scrollToUnread();
|
||||
if (this.state.range.endIndex !== prevState.range.endIndex) {
|
||||
if (this.state.range.endIndex < mailboxSize - IDLE_THRESHOLD) {
|
||||
if (!idle) {
|
||||
idle = true;
|
||||
}
|
||||
} else {
|
||||
this.setState({ numPages }, () => {
|
||||
if (props.unreadCount > 20) {
|
||||
this.scrollToUnread();
|
||||
}
|
||||
});
|
||||
} else if (idle) {
|
||||
idle = false;
|
||||
}
|
||||
} else if (
|
||||
state.numPages === 1 &&
|
||||
this.props.unreadCount < INITIAL_LOAD &&
|
||||
this.props.unreadCount > 0
|
||||
) {
|
||||
this.dismissUnread();
|
||||
this.scrollToBottom();
|
||||
this.setState({ idle });
|
||||
}
|
||||
}
|
||||
|
||||
scrollIsAtTop() {
|
||||
const { props, state } = this;
|
||||
this.setState({ numPages: state.numPages + 1 }, () => {
|
||||
if (state.numPages * PAGE_SIZE < props.length) {
|
||||
this.fetchBacklog(DEFAULT_BACKLOG_SIZE);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scrollIsAtBottom() {
|
||||
if (this.state.numPages !== 1) {
|
||||
this.setState({ numPages: 1 });
|
||||
this.dismissUnread();
|
||||
if (!idle && idle !== prevState.idle) {
|
||||
setTimeout(() => {
|
||||
this.virtualList.current?.scrollToIndex(mailboxSize);
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
if (this.scrollReference.current) {
|
||||
this.scrollReference.current.scrollToBottom();
|
||||
}
|
||||
if (this.state.numPages !== 1) {
|
||||
this.setState({ numPages: 1 });
|
||||
if (!idle && prevProps.unreadCount !== unreadCount) {
|
||||
this.virtualList.current?.scrollToIndex(mailboxSize);
|
||||
}
|
||||
}
|
||||
|
||||
scrollToUnread() {
|
||||
if (this.scrollReference.current && this.unreadReference.current) {
|
||||
this.scrollReference.current.scrollToReference(this.unreadReference);
|
||||
}
|
||||
const { mailboxSize, unreadCount } = this.props;
|
||||
this.virtualList.current?.scrollToIndex({
|
||||
index: mailboxSize - unreadCount,
|
||||
align: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
dismissUnread() {
|
||||
this.props.api.chat.read(this.props.station);
|
||||
}
|
||||
|
||||
fetchBacklog(size) {
|
||||
const { props } = this;
|
||||
fetchMessages(start, end, force = false) {
|
||||
start = Math.max(start, 0);
|
||||
end = Math.max(end, 0);
|
||||
const { api, mailboxSize, station } = this.props;
|
||||
|
||||
if (
|
||||
props.messages.length >= props.length ||
|
||||
this.hasAskedForMessages ||
|
||||
props.length <= 0
|
||||
(this.state.fetchPending ||
|
||||
mailboxSize <= 0)
|
||||
&& !force
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.chat
|
||||
.fetchMessages(Math.max(mailboxSize - end, 0), Math.min(mailboxSize - start, mailboxSize), station)
|
||||
.finally(() => {
|
||||
this.setState({ fetchPending: false });
|
||||
});
|
||||
|
||||
const start =
|
||||
props.length - props.messages[props.messages.length - 1].number;
|
||||
if (start > 0) {
|
||||
const end = start + size < props.length ? start + size : props.length;
|
||||
props.api.chat.fetchMessages(start + 1, end, props.station);
|
||||
this.hasAskedForMessages = true;
|
||||
}
|
||||
this.setState({ fetchPending: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
const sliceLength = Math.min(
|
||||
state.numPages * PAGE_SIZE,
|
||||
props.messages.length + props.pendingMessages.length
|
||||
);
|
||||
const messages =
|
||||
props.pendingMessages
|
||||
.concat(props.messages)
|
||||
.slice(0, sliceLength);
|
||||
const {
|
||||
envelopes,
|
||||
stationPendingMessages,
|
||||
unreadCount,
|
||||
unreadMsg,
|
||||
isChatLoading,
|
||||
isChatUnsynced,
|
||||
api,
|
||||
ship,
|
||||
station,
|
||||
association,
|
||||
group,
|
||||
contacts,
|
||||
mailboxSize,
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
remoteContentPolicy,
|
||||
} = this.props;
|
||||
|
||||
const messages: Envelope[] = [];
|
||||
const debouncedFetch = _.debounce(this.fetchMessages, 500).bind(this);
|
||||
|
||||
envelopes
|
||||
.forEach((message) => {
|
||||
messages[message.number] = message;
|
||||
});
|
||||
|
||||
stationPendingMessages.sort((a, b) => a.when - b.when).forEach((message, index) => {
|
||||
messages[mailboxSize + index + 1] = message;
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<UnreadNotice
|
||||
unreadCount={props.unreadCount}
|
||||
unreadMsg={props.unreadMsg}
|
||||
dismissUnread={this.dismissUnread} />
|
||||
<ChatScrollContainer
|
||||
ref={this.scrollReference}
|
||||
scrollIsAtBottom={this.scrollIsAtBottom}
|
||||
scrollIsAtTop={this.scrollIsAtTop}>
|
||||
<BacklogElement isChatLoading={props.isChatLoading} />
|
||||
<ResubscribeElement
|
||||
api={props.api}
|
||||
host={props.ship}
|
||||
station={props.station}
|
||||
isChatUnsynced={props.isChatUnsynced}
|
||||
/>
|
||||
{ messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={msg.uid}
|
||||
unreadRef={this.unreadReference}
|
||||
isLastUnread={
|
||||
props.unreadCount > 0 &&
|
||||
i === props.unreadCount - 1 &&
|
||||
state.numPages !== 1
|
||||
}
|
||||
msg={msg}
|
||||
previousMsg={messages[i - 1]}
|
||||
nextMsg={messages[i + 1]}
|
||||
association={props.association}
|
||||
group={props.group}
|
||||
contacts={props.contacts}
|
||||
hideAvatars={props.hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</ChatScrollContainer>
|
||||
unreadCount={unreadCount}
|
||||
unreadMsg={unreadMsg}
|
||||
dismissUnread={this.dismissUnread}
|
||||
onClick={this.scrollToUnread}
|
||||
/>
|
||||
<BacklogElement isChatLoading={isChatLoading} />
|
||||
<ResubscribeElement {...{ api, host: ship, station, isChatUnsynced}} />
|
||||
{messages.length ? <VirtualList
|
||||
ref={this.virtualList}
|
||||
style={{height: '100%', width: '100%', visibility: this.state.initialized ? 'initial' : 'hidden'}}
|
||||
totalCount={mailboxSize + stationPendingMessages.length}
|
||||
followOutput={!this.state.idle}
|
||||
endReached={this.dismissUnread}
|
||||
scrollSeek={{
|
||||
enter: velocity => Math.abs(velocity) > 2000,
|
||||
exit: velocity => Math.abs(velocity) < 200,
|
||||
change: (_velocity, _range) => {},
|
||||
placeholder: this.state.initialized ? Placeholder : () => <div></div>
|
||||
}}
|
||||
startReached={() => debouncedFetch(0, DEFAULT_BACKLOG_SIZE)}
|
||||
overscan={DEFAULT_BACKLOG_SIZE}
|
||||
rangeChanged={(range) => {
|
||||
this.setState({ range });
|
||||
debouncedFetch(range.startIndex - (DEFAULT_BACKLOG_SIZE / 2), range.endIndex + (DEFAULT_BACKLOG_SIZE / 2));
|
||||
}}
|
||||
item={(i) => {
|
||||
const number = i + 1;
|
||||
const msg = messages[number];
|
||||
|
||||
if (!msg) {
|
||||
debouncedFetch(number - DEFAULT_BACKLOG_SIZE, number + DEFAULT_BACKLOG_SIZE);
|
||||
return <Placeholder index={number} height="0px" style={{overflow: 'hidden'}} />;
|
||||
}
|
||||
return <ChatMessage
|
||||
key={number}
|
||||
unreadRef={this.unreadReference}
|
||||
isFirstUnread={
|
||||
unreadCount
|
||||
&& mailboxSize - unreadCount === number
|
||||
}
|
||||
msg={msg}
|
||||
previousMsg={messages[number + 1]}
|
||||
nextMsg={messages[number - 1]}
|
||||
association={association}
|
||||
group={group}
|
||||
contacts={contacts}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
className={number === mailboxSize + stationPendingMessages.length ? 'pb3' : ''}
|
||||
/>
|
||||
}}
|
||||
/> : <div style={{height: '100%', width: '100%'}}></div>}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
@ -1,106 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Button } from '@tlon/indigo-react';
|
||||
|
||||
const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
|
||||
|
||||
const YOUTUBE_REGEX =
|
||||
new RegExp(
|
||||
String(/(?:https?:\/\/(?:[a-z]+.)?)/.source) // protocol
|
||||
+ /(?:youtu\.?be(?:\.com)?\/)(?:embed\/)?/.source // short and long-links
|
||||
+ /(?:(?:(?:(?:watch\?)?(?:time_continue=(?:[0-9]+))?.+v=)?([a-zA-Z0-9_-]+))(?:\?t\=(?:[0-9a-zA-Z]+))?)/.source // id
|
||||
);
|
||||
|
||||
export default class UrlContent extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
unfold: false,
|
||||
copied: false
|
||||
};
|
||||
this.unfoldEmbed = this.unfoldEmbed.bind(this);
|
||||
}
|
||||
|
||||
unfoldEmbed(id) {
|
||||
let unfoldState = this.state.unfold;
|
||||
unfoldState = !unfoldState;
|
||||
this.setState({ unfold: unfoldState });
|
||||
this.iframe.setAttribute('src', this.iframe.dataset.src);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const content = props.content;
|
||||
const imgMatch = IMAGE_REGEX.exec(props.content.url);
|
||||
const ytMatch = YOUTUBE_REGEX.exec(props.content.url);
|
||||
|
||||
let contents = content.url;
|
||||
if (imgMatch) {
|
||||
contents = (
|
||||
<img
|
||||
className="o-80-d"
|
||||
src={content.url}
|
||||
style={{
|
||||
maxWidth: '18rem'
|
||||
}}
|
||||
></img>
|
||||
);
|
||||
return (
|
||||
<a className='f7 lh-copy v-top word-break-all'
|
||||
href={content.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{contents}
|
||||
</a>
|
||||
);
|
||||
} else if (ytMatch) {
|
||||
contents = (
|
||||
<div className={'embed-container mb2 w-100 w-75-l w-50-xl ' +
|
||||
((this.state.unfold === true)
|
||||
? 'db' : 'dn')}
|
||||
>
|
||||
<iframe
|
||||
ref={(el) => {
|
||||
this.iframe = el;
|
||||
}}
|
||||
width="560"
|
||||
height="315"
|
||||
data-src={`https://www.youtube.com/embed/${ytMatch[1]}`}
|
||||
frameBorder="0" allow="picture-in-picture, fullscreen"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<a href={content.url}
|
||||
className='f7 lh-copy v-top bb b--white-d word-break-all'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{content.url}
|
||||
</a>
|
||||
<Button
|
||||
border={1}
|
||||
style={{ display: 'inline-flex', height: '1.66em' }} // Height is hacked to line-height until Button supports proper size
|
||||
ml={1}
|
||||
onClick={e => this.unfoldEmbed()}
|
||||
>
|
||||
{this.state.unfold ? 'collapse' : 'embed'}
|
||||
</Button>
|
||||
{contents}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<a className='f7 lh-copy v-top bb b--white-d b--black word-break-all'
|
||||
href={content.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{contents}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,51 +1,54 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
export const DeleteButton = memo(({ isOwner, station, changeLoading, association, contacts, api }) => {
|
||||
const leaveButtonClasses = (!isOwner) ? 'pointer' : 'c-default';
|
||||
const deleteButtonClasses = (isOwner) ?
|
||||
'b--red2 red2 pointer bg-gray0-d' :
|
||||
'b--gray3 gray3 bg-gray0-d c-default';
|
||||
|
||||
export const DeleteButton = (props) => {
|
||||
const { isOwner, station, changeLoading, api } = props;
|
||||
const leaveButtonClasses = (!isOwner) ? 'pointer' : 'c-default';
|
||||
const deleteButtonClasses = (isOwner) ?
|
||||
'b--red2 red2 pointer bg-gray0-d' :
|
||||
'b--gray3 gray3 bg-gray0-d c-default';
|
||||
|
||||
const deleteChat = () => {
|
||||
changeLoading(
|
||||
true,
|
||||
true,
|
||||
isOwner ? 'Deleting chat...' : 'Leaving chat...',
|
||||
() => {
|
||||
api.chat.delete(station);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-100 cf">
|
||||
<div className={'w-100 fl mt3 ' + ((isOwner) ? 'o-30' : '')}>
|
||||
<p className="f8 mt3 lh-copy db">Leave Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Remove this chat from your chat list.{' '}
|
||||
You will need to request for access again.
|
||||
</p>
|
||||
<a onClick={(!isOwner) ? deleteChat : null}
|
||||
className={
|
||||
'dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d ' +
|
||||
leaveButtonClasses
|
||||
}>
|
||||
Leave this chat
|
||||
</a>
|
||||
</div>
|
||||
<div className={'w-100 fl mt3 ' + ((!isOwner) ? 'o-30' : '')}>
|
||||
<p className="f8 mt3 lh-copy db">Delete Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Permanently delete this chat.{' '}
|
||||
All current members will no longer see this chat.
|
||||
</p>
|
||||
<a onClick={(isOwner) ? deleteChat : null}
|
||||
className={'dib f9 ba pa2 ' + deleteButtonClasses}
|
||||
>Delete this chat</a>
|
||||
</div>
|
||||
</div>
|
||||
const deleteChat = () => {
|
||||
changeLoading(
|
||||
true,
|
||||
true,
|
||||
isOwner ? 'Deleting chat...' : 'Leaving chat...',
|
||||
() => {
|
||||
api.chat.delete(station);
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const groupPath = association['group-path'];
|
||||
const unmanagedVillage = !contacts[groupPath];
|
||||
|
||||
return (
|
||||
<div className="w-100 cf">
|
||||
<div className={'w-100 fl mt3 ' + ((isOwner) ? 'o-30' : '')}>
|
||||
<p className="f8 mt3 lh-copy db">Leave Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Remove this chat from your chat list.{' '}
|
||||
{unmanagedVillage
|
||||
? 'You will need to request for access again'
|
||||
: 'You will need to join again from the group page.'
|
||||
}
|
||||
</p>
|
||||
<a onClick={(!isOwner) ? deleteChat : null}
|
||||
className={
|
||||
'dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d ' +
|
||||
leaveButtonClasses
|
||||
}>
|
||||
Leave this chat
|
||||
</a>
|
||||
</div>
|
||||
<div className={'w-100 fl mt3 ' + ((!isOwner) ? 'o-30' : '')}>
|
||||
<p className="f8 mt3 lh-copy db">Delete Chat</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Permanently delete this chat.{' '}
|
||||
All current members will no longer see this chat.
|
||||
</p>
|
||||
<a onClick={(isOwner) ? deleteChat : null}
|
||||
className={'dib f9 ba pa2 ' + deleteButtonClasses}
|
||||
>Delete this chat</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
@ -1,11 +1,13 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChannelItem } from './channel-item';
|
||||
import { deSig, cite } from "~/logic/lib/util";
|
||||
|
||||
export class GroupItem extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
const association = props.association ? props.association : {};
|
||||
const DEFAULT_TITLE_REGEX = new RegExp(`(( <-> )?~(?:${window.ship}|${deSig(cite(window.ship))})( <-> )?)`);
|
||||
|
||||
let title = association['app-path'] ? association['app-path'] : 'Direct Messages';
|
||||
if (association.metadata && association.metadata.title) {
|
||||
@ -47,9 +49,13 @@ export class GroupItem extends Component {
|
||||
each in props.chatMetadata &&
|
||||
props.chatMetadata[each].metadata
|
||||
) {
|
||||
title = props.chatMetadata[each].metadata.title
|
||||
? props.chatMetadata[each].metadata.title
|
||||
: each.substr(1);
|
||||
if (props.chatMetadata[each].metadata.title) {
|
||||
title = props.chatMetadata[each].metadata.title
|
||||
}
|
||||
}
|
||||
|
||||
if (DEFAULT_TITLE_REGEX.test(title) && props.index === "dm") {
|
||||
title = title.replace(DEFAULT_TITLE_REGEX, '');
|
||||
}
|
||||
const selected = props.station === each;
|
||||
|
||||
|
@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
||||
|
||||
import TextContent from './content/text';
|
||||
import CodeContent from './content/code';
|
||||
import UrlContent from './content/url';
|
||||
import RemoteContent from '~/views/components/RemoteContent';
|
||||
|
||||
|
||||
export default class MessageContent extends Component {
|
||||
@ -15,7 +15,18 @@ export default class MessageContent extends Component {
|
||||
if ('code' in content) {
|
||||
return <CodeContent content={content} />;
|
||||
} else if ('url' in content) {
|
||||
return <UrlContent content={content} />;
|
||||
return (
|
||||
<RemoteContent
|
||||
url={content.url}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
imageProps={{style: {
|
||||
maxWidth: '18rem'
|
||||
}}}
|
||||
videoProps={{style: {
|
||||
maxWidth: '18rem'
|
||||
}}}
|
||||
/>
|
||||
);
|
||||
} else if ('me' in content) {
|
||||
return (
|
||||
<p className='f7 i lh-copy v-top'>
|
||||
|
@ -6,15 +6,21 @@ import moment from 'moment';
|
||||
|
||||
|
||||
export const Message = (props) => {
|
||||
const pending = props.msg.pending ? ' o-40' : '';
|
||||
const {
|
||||
msg,
|
||||
renderSigil,
|
||||
remoteContentPolicy,
|
||||
className = ''
|
||||
} = props;
|
||||
const pending = msg.pending ? ' o-40' : '';
|
||||
const containerClass =
|
||||
props.renderSigil ?
|
||||
`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ` + pending :
|
||||
'w-100 pr3 cf hide-child flex' + pending;
|
||||
renderSigil
|
||||
? `w-100 f7 pl3 pt4 pr3 cf flex lh-copy ${pending} ${className}`
|
||||
: `w-100 pr3 cf hide-child flex ${pending} ${className}`;
|
||||
|
||||
const timestamp =
|
||||
moment.unix(props.msg.when / 1000).format(
|
||||
props.renderSigil ? 'hh:mm a' : 'hh:mm'
|
||||
moment.unix(msg.when / 1000).format(
|
||||
renderSigil ? 'hh:mm a' : 'hh:mm'
|
||||
);
|
||||
|
||||
|
||||
@ -24,14 +30,14 @@ export const Message = (props) => {
|
||||
minHeight: 'min-content'
|
||||
}}>
|
||||
{
|
||||
props.renderSigil ? (
|
||||
renderSigil ? (
|
||||
renderWithSigil(props, timestamp)
|
||||
) : (
|
||||
<div className="flex w-100">
|
||||
<p className="child pt2 pl2 pr1 mono f9 gray2 dib">{timestamp}</p>
|
||||
<div className="fr f7 clamp-message white-d pr3 lh-copy"
|
||||
style={{ flexGrow: 1 }}>
|
||||
<MessageContent letter={props.msg.letter} />
|
||||
<MessageContent letter={msg.letter} remoteContentPolicy={remoteContentPolicy}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -41,66 +47,67 @@ export const Message = (props) => {
|
||||
};
|
||||
|
||||
const renderWithSigil = (props, timestamp) => {
|
||||
const paddingTop = props.paddingTop ? { 'paddingTop': '6px' } : '';
|
||||
const datestamp =
|
||||
'~' + moment.unix(props.msg.when / 1000).format('YYYY.M.D');
|
||||
|
||||
const contact = props.msg.author in props.contacts
|
||||
? props.contacts[props.msg.author] : false;
|
||||
const showNickname = !props.hideNicknames && contact?.nickname;
|
||||
let name = `~${props.msg.author}`;
|
||||
let color = '#000000';
|
||||
let sigilClass = 'mix-blend-diff';
|
||||
if (contact) {
|
||||
name = showNickname
|
||||
? contact.nickname
|
||||
: `~${props.msg.author}`;
|
||||
color = `#${uxToHex(contact.color)}`;
|
||||
sigilClass = '';
|
||||
}
|
||||
const paddingTop = props.paddingTop ? { 'paddingTop': '6px' } : '';
|
||||
const datestamp =
|
||||
'~' + moment.unix(props.msg.when / 1000).format('YYYY.M.D');
|
||||
|
||||
if (`~${props.msg.author}` === name) {
|
||||
name = cite(props.msg.author);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-100">
|
||||
<OverlaySigil
|
||||
ship={props.msg.author}
|
||||
contact={contact}
|
||||
color={color}
|
||||
sigilClass={sigilClass}
|
||||
association={props.association}
|
||||
group={props.group}
|
||||
hideAvatars={props.hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
className="fl pr3 v-top bg-white bg-gray0-d"
|
||||
/>
|
||||
<div className="fr clamp-message white-d"
|
||||
style={{ flexGrow: 1, marginTop: -8 }}>
|
||||
<div className="hide-child" style={paddingTop}>
|
||||
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
|
||||
<span
|
||||
className={
|
||||
'mw5 db truncate pointer ' +
|
||||
(showNickname ? '' : 'mono')
|
||||
}
|
||||
onClick={() => {
|
||||
writeText(props.msg.author);
|
||||
}}
|
||||
title={`~${props.msg.author}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</p>
|
||||
<p className={`v-mid mono f9 gray2 dib`}>{timestamp}</p>
|
||||
<p className={`v-mid mono f9 ml2 gray2 dib child dn-s`}>
|
||||
{datestamp}
|
||||
</p>
|
||||
</div>
|
||||
<MessageContent letter={props.msg.letter} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const contact = props.msg.author in props.contacts
|
||||
? props.contacts[props.msg.author] : false;
|
||||
const showNickname = !props.hideNicknames && contact?.nickname;
|
||||
let name = `~${props.msg.author}`;
|
||||
let color = '#000000';
|
||||
let sigilClass = 'mix-blend-diff';
|
||||
if (contact) {
|
||||
name = showNickname
|
||||
? contact.nickname
|
||||
: `~${props.msg.author}`;
|
||||
color = `#${uxToHex(contact.color)}`;
|
||||
sigilClass = '';
|
||||
}
|
||||
|
||||
if (`~${props.msg.author}` === name) {
|
||||
name = cite(props.msg.author);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-100">
|
||||
<OverlaySigil
|
||||
ship={props.msg.author}
|
||||
contact={contact}
|
||||
color={color}
|
||||
sigilClass={sigilClass}
|
||||
association={props.association}
|
||||
group={props.group}
|
||||
hideAvatars={props.hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
className="fl pr3 v-top bg-white bg-gray0-d"
|
||||
/>
|
||||
<div className="fr clamp-message white-d"
|
||||
style={{ flexGrow: 1, marginTop: -8 }}>
|
||||
<div className="hide-child" style={paddingTop}>
|
||||
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
|
||||
<span
|
||||
className={
|
||||
'mw5 db truncate pointer ' +
|
||||
(showNickname ? '' : 'mono')
|
||||
}
|
||||
onClick={() => {
|
||||
writeText(props.msg.author);
|
||||
}}
|
||||
title={`~${props.msg.author}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</p>
|
||||
<p className={`v-mid mono f9 gray2 dib`}>{timestamp}</p>
|
||||
<p className={`v-mid mono f9 ml2 gray2 dib child dn-s`}>
|
||||
{datestamp}
|
||||
</p>
|
||||
</div>
|
||||
<MessageContent letter={props.msg.letter} remoteContentPolicy={props.remoteContentPolicy} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,105 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import S3Client from '~/logic/lib/s3';
|
||||
|
||||
export class S3Upload extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.s3 = new S3Client();
|
||||
this.setCredentials(props.credentials, props.configuration);
|
||||
this.inputRef = React.createRef();
|
||||
}
|
||||
|
||||
isReady(creds, config) {
|
||||
return (
|
||||
Boolean(creds) &&
|
||||
'endpoint' in creds &&
|
||||
'accessKeyId' in creds &&
|
||||
'secretAccessKey' in creds &&
|
||||
creds.endpoint !== '' &&
|
||||
creds.accessKeyId !== '' &&
|
||||
creds.secretAccessKey !== '' &&
|
||||
Boolean(config) &&
|
||||
'currentBucket' in config &&
|
||||
config.currentBucket !== ''
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props } = this;
|
||||
this.setCredentials(props.credentials, props.configuration);
|
||||
}
|
||||
|
||||
setCredentials(credentials, configuration) {
|
||||
if (!this.isReady(credentials, configuration)) {
|
||||
return;
|
||||
}
|
||||
this.s3.setCredentials(
|
||||
credentials.endpoint,
|
||||
credentials.accessKeyId,
|
||||
credentials.secretAccessKey
|
||||
);
|
||||
}
|
||||
|
||||
getFileUrl(endpoint, filename) {
|
||||
return endpoint + '/' + filename;
|
||||
}
|
||||
|
||||
onChange() {
|
||||
const { props } = this;
|
||||
if (!this.inputRef.current) {
|
||||
return;
|
||||
}
|
||||
const files = this.inputRef.current.files;
|
||||
if (files.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files.item(0);
|
||||
const bucket = props.configuration.currentBucket;
|
||||
|
||||
this.s3.upload(bucket, file.name, file).then((data) => {
|
||||
if (!data || !('Location' in data)) {
|
||||
return;
|
||||
}
|
||||
this.props.uploadSuccess(data.Location);
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
this.props.uploadError(err);
|
||||
});
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (!this.inputRef.current) {
|
||||
return;
|
||||
}
|
||||
this.inputRef.current.click();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
if (!this.isReady(props.credentials, props.configuration)) {
|
||||
return <div></div>;
|
||||
} else {
|
||||
const classes = props.className ?
|
||||
'pointer ' + props.className : 'pointer';
|
||||
return (
|
||||
<div className={classes}>
|
||||
<input className="dn"
|
||||
type="file"
|
||||
id="fileElement"
|
||||
ref={this.inputRef}
|
||||
accept="image/*"
|
||||
onChange={this.onChange.bind(this)}
|
||||
/>
|
||||
<img className="invert-d"
|
||||
src="/~chat/img/ImageUpload.png"
|
||||
width="16"
|
||||
height="16"
|
||||
onClick={this.onClick.bind(this)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
export const UnreadNotice = (props) => {
|
||||
const { unreadCount, unreadMsg, dismissUnread } = props;
|
||||
const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
|
||||
|
||||
if (!unreadMsg || (unreadCount === 0)) {
|
||||
return null;
|
||||
@ -22,7 +22,7 @@ export const UnreadNotice = (props) => {
|
||||
"ba b--green2 green2 bg-white bg-gray0-d flex items-center " +
|
||||
"pa2 f9 justify-between br1"
|
||||
}>
|
||||
<p className="lh-copy db">
|
||||
<p className="lh-copy db pointer" onClick={onClick}>
|
||||
{unreadCount} new messages since{' '}
|
||||
{datestamp && (
|
||||
<>
|
||||
|
@ -3,7 +3,7 @@ import { Spinner } from '~/views/components/Spinner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { deSig } from '~/logic/lib/util';
|
||||
import { deSig, cite } from '~/logic/lib/util';
|
||||
|
||||
export class NewDmScreen extends Component {
|
||||
constructor(props) {
|
||||
@ -91,7 +91,7 @@ export class NewDmScreen extends Component {
|
||||
|
||||
const aud = state.ship !== window.ship ? [`~${state.ships[0]}`] : [];
|
||||
|
||||
let title = `~${window.ship} <-> ~${state.ships[0]}`;
|
||||
let title = `${cite(window.ship)} <-> ${cite(state.ships[0])}`;
|
||||
|
||||
if (state.title !== '') {
|
||||
title = state.title;
|
||||
|
@ -1,15 +1,11 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { deSig } from '~/logic/lib/util';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { ChatHeader } from './lib/chat-header';
|
||||
import { MetadataSettings } from './lib/metadata-settings';
|
||||
import { MetadataSettings } from '~/views/components/metadata/settings';
|
||||
import { DeleteButton } from './lib/delete-button';
|
||||
import { GroupifyButton } from './lib/groupify-button';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { ChatTabBar } from './lib/chat-tabbar';
|
||||
import SidebarSwitcher from '~/views/components/SidebarSwitch';
|
||||
|
||||
|
||||
export class SettingsScreen extends Component {
|
||||
constructor(props) {
|
||||
@ -89,13 +85,17 @@ export class SettingsScreen extends Component {
|
||||
isOwner={isOwner}
|
||||
changeLoading={this.changeLoading}
|
||||
station={station}
|
||||
association={association}
|
||||
contacts={contacts}
|
||||
api={api} />
|
||||
<MetadataSettings
|
||||
isOwner={isOwner}
|
||||
changeLoading={this.changeLoading}
|
||||
api={api}
|
||||
association={association}
|
||||
station={station} />
|
||||
resource="chat"
|
||||
app="chat"
|
||||
/>
|
||||
<Spinner
|
||||
awaiting={this.state.awaiting}
|
||||
classes="absolute right-2 bottom-2 ba pa2 b--gray1-d"
|
||||
@ -121,13 +121,13 @@ export class SettingsScreen extends Component {
|
||||
const isInPopout = popout ? "popout/" : "";
|
||||
const title =
|
||||
( association &&
|
||||
('metadata' in association) &&
|
||||
('metadata' in association) &&
|
||||
(association.metadata.title !== '')
|
||||
) ? association.metadata.title : station.substr(1);
|
||||
|
||||
return (
|
||||
<div className="h-100 w-100 overflow-x-hidden flex flex-column white-d">
|
||||
<ChatHeader
|
||||
<ChatHeader
|
||||
match={match}
|
||||
location={location}
|
||||
api={api}
|
||||
|
@ -116,20 +116,8 @@ h2 {
|
||||
100% {transform: rotate(360deg);}
|
||||
}
|
||||
|
||||
/* embeds */
|
||||
.embed-container {
|
||||
position: relative;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
padding-bottom: 28.125%;
|
||||
}
|
||||
|
||||
.embed-container iframe, .embed-container object, .embed-container embed {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.embed-container iframe {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mh-16 {
|
||||
@ -188,9 +176,6 @@ h2 {
|
||||
.h-100-minus-96-s {
|
||||
height: calc(100% - 96px);
|
||||
}
|
||||
.embed-container {
|
||||
padding-bottom: 56.25%;
|
||||
}
|
||||
.unread-notice {
|
||||
top: 96px;
|
||||
}
|
||||
@ -200,18 +185,12 @@ h2 {
|
||||
.flex-basis-250-m {
|
||||
flex-basis: 250px;
|
||||
}
|
||||
.embed-container {
|
||||
padding-bottom: 56.25%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 46.875em) and (max-width: 60em) {
|
||||
.flex-basis-250-l {
|
||||
flex-basis: 250px;
|
||||
}
|
||||
.embed-container {
|
||||
padding-bottom: 37.5%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 60em) {
|
||||
@ -252,10 +231,15 @@ blockquote {
|
||||
font-family: 'Inter';
|
||||
}
|
||||
|
||||
code, pre.code {
|
||||
pre, code {
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
pre code {
|
||||
background-color: transparent;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
code, .code, .chat.code .react-codemirror2 .CodeMirror * {
|
||||
font-family: 'Source Code Pro';
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { Link } from 'react-router-dom';
|
||||
import { EditElement } from './edit-element';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { S3Upload } from './s3-upload';
|
||||
import { S3Upload } from '~/views/components/s3-upload';
|
||||
|
||||
export class ContactCard extends Component {
|
||||
constructor(props) {
|
||||
@ -492,7 +492,15 @@ export class ContactCard extends Component {
|
||||
credentials={props.s3.credentials}
|
||||
uploadSuccess={this.uploadSuccess.bind(this)}
|
||||
uploadError={this.uploadError.bind(this)}
|
||||
/>
|
||||
accept="image/*"
|
||||
>
|
||||
<img
|
||||
className="invert-d"
|
||||
src="/~chat/img/ImageUpload.png"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
</S3Upload>
|
||||
</span>
|
||||
<EditElement
|
||||
className="fr w-80"
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { Virtuoso as VirtualList } from 'react-virtuoso';
|
||||
|
||||
import { ContactItem } from './contact-item';
|
||||
import { ShareSheet } from './share-sheet';
|
||||
@ -180,19 +180,17 @@ export class ContactSidebar extends Component<ContactSidebarProps, ContactSideba
|
||||
>Channels</Link>
|
||||
{shareSheet}
|
||||
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">Members</h2>
|
||||
<List
|
||||
height={this.state.memberboxHeight}
|
||||
<VirtualList
|
||||
style={{ height: this.state.memberboxHeight, width: '100%' }}
|
||||
className="flex-auto"
|
||||
itemCount={contactItems.length + groupItems.length}
|
||||
itemSize={44}
|
||||
width="100%"
|
||||
>
|
||||
{({ index, style }) => (<div style={style}>{
|
||||
index <= (contactItems.length - 1) // If the index is within the length of contact items,
|
||||
totalCount={contactItems.length + groupItems.length}
|
||||
itemHeight={44} // We happen to know this
|
||||
item={
|
||||
(index) => index <= (contactItems.length - 1) // If the index is within the length of contact items,
|
||||
? contactItems[index] // show a contact item
|
||||
: groupItems[index - contactItems.length] // Otherwise show a group item
|
||||
}</div>)}
|
||||
</List>
|
||||
}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<Spinner awaiting={this.state.awaiting} text="Removing from group..." classes="pa2 ba absolute right-1 bottom-1 b--gray1-d" />
|
||||
|
@ -324,11 +324,13 @@ export class GroupDetail extends Component {
|
||||
<a className={'dib f9 ba pa2 ' + deleteButtonClasses}
|
||||
onClick={() => {
|
||||
if (groupOwner) {
|
||||
this.setState({ awaiting: true, type: 'Deleting' }, (() => {
|
||||
props.api.contacts.delete(props.path).then(() => {
|
||||
props.history.push('/~groups');
|
||||
});
|
||||
}));
|
||||
if (prompt(`To confirm deleting this group, type ${props.path.substr(6)}`) === props.path.substr(6)) {
|
||||
this.setState({ awaiting: true, type: 'Deleting' }, (() => {
|
||||
props.api.contacts.delete(props.path).then(() => {
|
||||
props.history.push('/~groups');
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>Delete this group</a>
|
||||
|
@ -1,96 +0,0 @@
|
||||
import React, { Component } from 'react'
|
||||
import S3Client from '~/logic/lib/s3';
|
||||
|
||||
export class S3Upload extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.s3 = new S3Client();
|
||||
this.setCredentials(props.credentials, props.configuration);
|
||||
this.inputRef = React.createRef();
|
||||
}
|
||||
|
||||
isReady(creds, config) {
|
||||
return (
|
||||
!!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="/~landscape/img/ImageUpload.png"
|
||||
width="32"
|
||||
height="32"
|
||||
onClick={this.onClick.bind(this)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,18 +8,17 @@ import './css/custom.css';
|
||||
|
||||
import { Skeleton } from './components/skeleton';
|
||||
import { NewScreen } from './components/new';
|
||||
import { MemberScreen } from './components/member';
|
||||
import { SettingsScreen } from './components/settings';
|
||||
import { MessageScreen } from './components/lib/message-screen';
|
||||
import { Links } from './components/links-list';
|
||||
import { LinkDetail } from './components/link';
|
||||
|
||||
import {
|
||||
makeRoutePath,
|
||||
amOwnerOfGroup,
|
||||
base64urlDecode
|
||||
} from '~/logic/lib/util';
|
||||
|
||||
|
||||
export class LinksApp extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@ -31,6 +30,7 @@ export class LinksApp extends Component {
|
||||
|
||||
this.props.api.links.getPage('', 0);
|
||||
this.props.subscription.startApp('link');
|
||||
this.props.subscription.startApp('graph');
|
||||
if (!this.props.sidebarShown) {
|
||||
this.props.api.local.sidebarToggle();
|
||||
}
|
||||
@ -38,22 +38,17 @@ export class LinksApp extends Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.subscription.stopApp('link');
|
||||
this.props.subscription.stopApp('graph');
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const contacts = props.contacts ? props.contacts : {};
|
||||
|
||||
const groups = props.groups ? props.groups : {};
|
||||
|
||||
const associations = props.associations ? props.associations : { link: {}, contacts: {} };
|
||||
const links = props.links ? props.links : {};
|
||||
const comments = props.linkComments ? props.linkComments : {};
|
||||
|
||||
const seen = props.linksSeen ? props.linksSeen : {};
|
||||
|
||||
const totalUnseen = _.reduce(
|
||||
links,
|
||||
(acc, collection) => acc + collection.unseenCount,
|
||||
@ -63,14 +58,12 @@ export class LinksApp extends Component {
|
||||
const invites = props.invites ?
|
||||
props.invites : {};
|
||||
|
||||
|
||||
const listening = props.linkListening;
|
||||
|
||||
const { api, sidebarShown, hideAvatars, hideNicknames } = this.props;
|
||||
const { api, sidebarShown, hideAvatars, hideNicknames, s3, remoteContentPolicy } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<Helmet defer={false}>
|
||||
<title>{totalUnseen > 0 ? `(${totalUnseen}) ` : ''}OS1 - Links</title>
|
||||
</Helmet>
|
||||
<Switch>
|
||||
@ -263,6 +256,7 @@ export class LinksApp extends Component {
|
||||
api={api}
|
||||
hideNicknames={hideNicknames}
|
||||
hideAvatars={hideAvatars}
|
||||
s3={s3}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
@ -325,6 +319,7 @@ export class LinksApp extends Component {
|
||||
api={api}
|
||||
hideAvatars={hideAvatars}
|
||||
hideNicknames={hideNicknames}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
/>
|
||||
</Skeleton>
|
||||
);
|
||||
|
@ -2,6 +2,8 @@ import React, { Component } from 'react';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import moment from 'moment';
|
||||
import { Box, Text, Row } from '@tlon/indigo-react';
|
||||
import RichText from '~/views/components/RichText';
|
||||
|
||||
export class CommentItem extends Component {
|
||||
constructor(props) {
|
||||
@ -35,9 +37,7 @@ export class CommentItem extends Component {
|
||||
|
||||
const member = props.member || false;
|
||||
|
||||
const pending = props.pending ? 'o-60' : '';
|
||||
|
||||
const showAvatar = props.avatar && !props.hideAvatars
|
||||
const showAvatar = props.avatar && !props.hideAvatars;
|
||||
const showNickname = props.nickname && !props.hideNicknames;
|
||||
const img = showAvatar
|
||||
? <img src={props.avatar} height={36} width={36} className="dib" />
|
||||
@ -49,22 +49,20 @@ export class CommentItem extends Component {
|
||||
/>;
|
||||
|
||||
return (
|
||||
<div className={'w-100 pv3 ' + pending}>
|
||||
<div className="flex bg-white bg-gray0-d">
|
||||
{img}
|
||||
<p className="gray2 f9 flex items-center ml2">
|
||||
<span className={'black white-d ' + props.nameClass}
|
||||
title={props.ship}
|
||||
>
|
||||
<Box width="100%" py={3} opacity={props.pending ? '0.6' : '1'}>
|
||||
<Row backgroundColor='white'>
|
||||
{img}
|
||||
<Row fontSize={0} alignItems="center" ml={2}>
|
||||
<Text mono={!props.hasNickname} title={props.ship}>
|
||||
{showNickname ? props.nickname : cite(props.ship)}
|
||||
</span>
|
||||
<span className="ml2">
|
||||
</Text>
|
||||
<Text gray ml={2}>
|
||||
{this.state.timeSinceComment}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="inter f8 pv3 white-d">{props.content}</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Row>
|
||||
</Row>
|
||||
<Text display="block" py={3} fontSize={1}><RichText remoteContentPolicy={props.remoteContentPolicy}>{props.content}</RichText></Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ export class Comments extends Component {
|
||||
? props.comments.totalPages
|
||||
: 1;
|
||||
|
||||
const { hideNicknames, hideAvatars } = props;
|
||||
const { hideNicknames, hideAvatars, remoteContentPolicy } = props;
|
||||
|
||||
const commentsList = Object.keys(commentsPage)
|
||||
.map((entry) => {
|
||||
@ -62,12 +62,13 @@ export class Comments extends Component {
|
||||
time={time}
|
||||
content={udon}
|
||||
nickname={nickname}
|
||||
nameClass={nameClass}
|
||||
hasNickname={Boolean(nickname)}
|
||||
color={color}
|
||||
avatar={avatar}
|
||||
member={member}
|
||||
hideNicknames={hideNicknames}
|
||||
hideAvatars={hideAvatars}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -1,78 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
|
||||
export class InviteElement extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
members: [],
|
||||
error: false,
|
||||
success: false,
|
||||
awaiting: false
|
||||
};
|
||||
this.setInvite = this.setInvite.bind(this);
|
||||
}
|
||||
|
||||
modifyMembers() {
|
||||
const { props, state } = this;
|
||||
|
||||
const aud = state.members.map(mem => `~${mem}`);
|
||||
|
||||
if (state.members.length === 0) {
|
||||
this.setState({
|
||||
error: true,
|
||||
success: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ awaiting: true });
|
||||
|
||||
this.setState({
|
||||
error: false,
|
||||
success: true,
|
||||
members: []
|
||||
}, () => {
|
||||
props.api.links.inviteToCollection(props.resourcePath, aud).then(() => {
|
||||
this.setState({ awaiting: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setInvite(invite) {
|
||||
this.setState({ members: invite.ships });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
let modifyButtonClasses = 'mt4 db f9 ba pa2 white-d bg-gray0-d b--black b--gray2-d pointer';
|
||||
if (state.error) {
|
||||
modifyButtonClasses = modifyButtonClasses + ' gray3';
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InviteSearch
|
||||
groups={{}}
|
||||
contacts={props.contacts}
|
||||
groupResults={false}
|
||||
shipResults={true}
|
||||
invites={{
|
||||
groups: [],
|
||||
ships: this.state.members
|
||||
}}
|
||||
setInvite={this.setInvite}
|
||||
/>
|
||||
<button
|
||||
onClick={this.modifyMembers.bind(this)}
|
||||
className={modifyButtonClasses}
|
||||
>
|
||||
Invite
|
||||
</button>
|
||||
<Spinner awaiting={this.state.awaiting} text="Inviting to collection..." classes="mt3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -2,6 +2,8 @@ import React, { Component } from 'react';
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import moment from 'moment';
|
||||
|
||||
import RemoteContent from "~/views/components/RemoteContent";
|
||||
|
||||
export class LinkPreview extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@ -27,26 +29,6 @@ export class LinkPreview extends Component {
|
||||
timeSinceLinkPost: this.getTimeSinceLinkPost()
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
// check for soundcloud for fetching embed
|
||||
const soundcloudRegex = new RegExp(String(/(https?:\/\/(?:www.)?soundcloud.com\/[\w-]+\/?(?:sets\/)?[\w-]+)/.source)
|
||||
);
|
||||
|
||||
const isSoundcloud = soundcloudRegex.exec(this.props.url);
|
||||
|
||||
if (isSoundcloud && this.state.embed === '') {
|
||||
fetch(
|
||||
'https://soundcloud.com/oembed?format=json&url=' +
|
||||
encodeURIComponent(this.props.url))
|
||||
.then((response) => {
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
this.setState({ embed: json.html });
|
||||
});
|
||||
} else if (!isSoundcloud) {
|
||||
this.setState({ embed: '' });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@ -66,6 +48,16 @@ export class LinkPreview extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
const embed = (
|
||||
<RemoteContent
|
||||
unfold={true}
|
||||
renderUrl={false}
|
||||
url={props.url}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
className="mw-100"
|
||||
/>
|
||||
);
|
||||
|
||||
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}/
|
||||
);
|
||||
@ -76,42 +68,6 @@ export class LinkPreview extends Component {
|
||||
hostname = hostname[4];
|
||||
}
|
||||
|
||||
const imgMatch = /(jpg|img|png|gif|tiff|jpeg|JPG|IMG|PNG|TIFF|GIF|webp|WEBP|webm|WEBM)$/.exec(
|
||||
props.url
|
||||
);
|
||||
|
||||
const youTubeRegex = new RegExp(
|
||||
String(/(?:https?:\/\/(?:[a-z]+.)?)/.source) + // protocol
|
||||
/(?:youtu\.?be(?:\.com)?\/)(?:embed\/)?/.source + // short and long-links
|
||||
/(?:(?:(?:(?:watch\?)?(?:time_continue=(?:[0-9]+))?.+v=)?([a-zA-Z0-9_-]+))(?:\?t\=(?:[0-9a-zA-Z]+))?)/.source // id
|
||||
);
|
||||
|
||||
const ytMatch = youTubeRegex.exec(props.url);
|
||||
|
||||
let embed = '';
|
||||
|
||||
if (imgMatch) {
|
||||
embed = <a href={props.url}
|
||||
target="_blank"
|
||||
style={{ width: 'max-content' }}
|
||||
>
|
||||
<img src={props.url} style={{ maxHeight: '500px', maxWidth: '100%' }} />
|
||||
</a>;
|
||||
}
|
||||
|
||||
if (ytMatch) {
|
||||
embed = (
|
||||
<iframe
|
||||
ref="iframe"
|
||||
width="560"
|
||||
height="315"
|
||||
src={`https://www.youtube.com/embed/${ytMatch[1]}`}
|
||||
frameBorder="0"
|
||||
allow="picture-in-picture, fullscreen"
|
||||
></iframe>
|
||||
);
|
||||
}
|
||||
|
||||
const showNickname = props.nickname && !props.hideNicknames;
|
||||
|
||||
const nameClass = showNickname ? 'inter' : 'mono';
|
||||
@ -119,9 +75,9 @@ export class LinkPreview extends Component {
|
||||
return (
|
||||
<div className="pb6 w-100">
|
||||
<div
|
||||
className={'w-100 tc ' + (ytMatch ? 'links embed-container' : '')}
|
||||
className={'w-100 tc'}
|
||||
>
|
||||
{embed || <div dangerouslySetInnerHTML={{ __html: this.state.embed }} />}
|
||||
{embed}
|
||||
</div>
|
||||
<div className="flex flex-column ml2 pt6 flex-auto">
|
||||
<a href={props.url} className="w-100 flex" target="_blank" rel="noopener noreferrer">
|
||||
|
@ -1,134 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
|
||||
export class LinkSubmit extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
linkValue: '',
|
||||
linkTitle: '',
|
||||
linkValid: false,
|
||||
submitFocus: false,
|
||||
disabled: false
|
||||
};
|
||||
this.setLinkValue = this.setLinkValue.bind(this);
|
||||
this.setLinkTitle = this.setLinkTitle.bind(this);
|
||||
}
|
||||
|
||||
onClickPost() {
|
||||
const link = this.state.linkValue;
|
||||
const title = this.state.linkTitle
|
||||
? this.state.linkTitle
|
||||
: this.state.linkValue;
|
||||
this.setState({ disabled: true });
|
||||
this.props.api.links.postLink(this.props.resourcePath, link, title).then((r) => {
|
||||
this.setState({
|
||||
disabled: false,
|
||||
linkValue: '',
|
||||
linkTitle: '',
|
||||
linkValid: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setLinkValid(link) {
|
||||
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}/
|
||||
);
|
||||
|
||||
const validURL = URLparser.exec(link);
|
||||
|
||||
if (!validURL) {
|
||||
const checkProtocol = URLparser.exec('http://' + link);
|
||||
if (checkProtocol) {
|
||||
this.setState({ linkValid: true });
|
||||
this.setState({ linkValue: 'http://' + link });
|
||||
} else {
|
||||
this.setState({ linkValid: false });
|
||||
}
|
||||
} else if (validURL) {
|
||||
this.setState({ linkValid: true });
|
||||
}
|
||||
}
|
||||
|
||||
setLinkValue(event) {
|
||||
this.setState({ linkValue: event.target.value });
|
||||
this.setLinkValid(event.target.value);
|
||||
}
|
||||
|
||||
setLinkTitle(event) {
|
||||
this.setState({ linkTitle: event.target.value });
|
||||
}
|
||||
|
||||
render() {
|
||||
const activeClasses = (this.state.linkValid && !this.state.disabled)
|
||||
? 'green2 pointer' : 'gray2';
|
||||
|
||||
const focus = (this.state.submitFocus)
|
||||
? 'b--black b--white-d'
|
||||
: 'b--gray4 b--gray2-d';
|
||||
|
||||
return (
|
||||
<div className={'relative ba br1 w-100 mb6 ' + focus}>
|
||||
<textarea
|
||||
className="pl2 bg-gray0-d white-d w-100 f8"
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 40,
|
||||
paddingTop: 10
|
||||
}}
|
||||
placeholder="Paste link here"
|
||||
onChange={this.setLinkValue}
|
||||
onBlur={() => this.setState({ submitFocus: false })}
|
||||
onFocus={() => this.setState({ submitFocus: true })}
|
||||
spellCheck="false"
|
||||
rows={1}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.onClickPost();
|
||||
}
|
||||
}}
|
||||
value={this.state.linkValue}
|
||||
/>
|
||||
<textarea
|
||||
className="pl2 bg-gray0-d white-d w-100 f8"
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 40,
|
||||
paddingTop: 16
|
||||
}}
|
||||
placeholder="Enter title"
|
||||
onChange={this.setLinkTitle}
|
||||
onBlur={() => this.setState({ submitFocus: false })}
|
||||
onFocus={() => this.setState({ submitFocus: true })}
|
||||
spellCheck="false"
|
||||
rows={1}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.onClickPost();
|
||||
}
|
||||
}}
|
||||
value={this.state.linkTitle}
|
||||
/>
|
||||
<button
|
||||
className={
|
||||
'absolute bg-gray0-d f8 ml2 flex-shrink-0 ' + activeClasses
|
||||
}
|
||||
disabled={!this.state.linkValid || this.state.disabled}
|
||||
onClick={this.onClickPost.bind(this)}
|
||||
style={{
|
||||
bottom: 12,
|
||||
right: 8
|
||||
}}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
<Spinner awaiting={this.state.disabled} classes="mt3 absolute right-0" text="Posting to collection..." />
|
||||
</div>
|
||||
) ;
|
||||
}
|
||||
}
|
||||
|
||||
export default LinkSubmit;
|
@ -0,0 +1,255 @@
|
||||
import React, { Component } from 'react';
|
||||
import { hasProvider } from 'oembed-parser';
|
||||
|
||||
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Icon } from "@tlon/indigo-react";
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { S3State } from '~/types';
|
||||
|
||||
interface LinkSubmitProps {
|
||||
api: GlobalApi;
|
||||
resourcePath: string;
|
||||
s3: S3State;
|
||||
}
|
||||
|
||||
interface LinkSubmitState {
|
||||
linkValue: string;
|
||||
linkTitle: string;
|
||||
linkValid: boolean;
|
||||
submitFocus: boolean;
|
||||
urlFocus: boolean;
|
||||
disabled: boolean;
|
||||
dragover: boolean;
|
||||
}
|
||||
|
||||
export class LinkSubmit extends Component<LinkSubmitProps, LinkSubmitState> {
|
||||
private s3Uploader: React.RefObject<S3Upload>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
linkValue: '',
|
||||
linkTitle: '',
|
||||
linkValid: false,
|
||||
submitFocus: false,
|
||||
urlFocus: false,
|
||||
disabled: false,
|
||||
dragover: false
|
||||
};
|
||||
this.setLinkValue = this.setLinkValue.bind(this);
|
||||
this.setLinkTitle = this.setLinkTitle.bind(this);
|
||||
this.onDragEnter = this.onDragEnter.bind(this);
|
||||
this.onDrop = this.onDrop.bind(this);
|
||||
this.onPaste = this.onPaste.bind(this);
|
||||
this.uploadFiles = this.uploadFiles.bind(this);
|
||||
this.s3Uploader = React.createRef();
|
||||
}
|
||||
|
||||
onClickPost() {
|
||||
const link = this.state.linkValue;
|
||||
const title = this.state.linkTitle
|
||||
? this.state.linkTitle
|
||||
: this.state.linkValue;
|
||||
this.setState({ disabled: true });
|
||||
this.props.api.links.postLink(this.props.resourcePath, link, title).then((r) => {
|
||||
this.setState({
|
||||
disabled: false,
|
||||
linkValue: '',
|
||||
linkTitle: '',
|
||||
linkValid: false
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setLinkValid(linkValue) {
|
||||
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 linkValid = URLparser.test(linkValue);
|
||||
|
||||
if (!linkValid) {
|
||||
linkValid = URLparser.test(`http://${linkValue}`);
|
||||
if (linkValid) {
|
||||
linkValue = `http://${linkValue}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ linkValid, linkValue });
|
||||
|
||||
if (linkValid) {
|
||||
if (hasProvider(linkValue)) {
|
||||
fetch(`https://noembed.com/embed?url=${linkValue}`)
|
||||
.then(response => response.json())
|
||||
.then((result) => {
|
||||
if (result.title) {
|
||||
this.setState({ linkTitle: result.title });
|
||||
}
|
||||
}).catch((error) => {/*noop*/});
|
||||
} else {
|
||||
this.setState({
|
||||
linkTitle: decodeURIComponent(linkValue
|
||||
.split('/')
|
||||
.pop()
|
||||
.split('.')
|
||||
.slice(0, -1)
|
||||
.join('.')
|
||||
.replace('_', ' ')
|
||||
.replace(/\d{4}\.\d{1,2}\.\d{2}\.\.\d{2}\.\d{2}\.\d{2}-/, '')
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLinkValue(event) {
|
||||
this.setState({ linkValue: event.target.value });
|
||||
this.setLinkValid(event.target.value);
|
||||
}
|
||||
|
||||
setLinkTitle(event) {
|
||||
this.setState({ linkTitle: event.target.value });
|
||||
}
|
||||
|
||||
uploadSuccess(url) {
|
||||
this.setState({ linkValue: url });
|
||||
this.setLinkValid(url);
|
||||
}
|
||||
|
||||
uploadError(error) {
|
||||
// no-op for now
|
||||
}
|
||||
|
||||
readyToUpload(): boolean {
|
||||
return Boolean(this.s3Uploader.current && this.s3Uploader.current.inputRef.current);
|
||||
}
|
||||
|
||||
onDragEnter() {
|
||||
if (!this.readyToUpload()) {
|
||||
return;
|
||||
}
|
||||
this.setState({ dragover: true });
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent) {
|
||||
this.setState({ dragover: false });
|
||||
if (!event.dataTransfer || !event.dataTransfer.files.length) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.uploadFiles(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
onPaste(event: ClipboardEvent) {
|
||||
if (!event.clipboardData || !event.clipboardData.files.length) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.uploadFiles(event.clipboardData.files);
|
||||
}
|
||||
|
||||
uploadFiles(files: FileList) {
|
||||
if (!this.readyToUpload()) {
|
||||
return;
|
||||
}
|
||||
this.s3Uploader.current.inputRef.current.files = files;
|
||||
const fire = document.createEvent("HTMLEvents");
|
||||
fire.initEvent("change", true, true);
|
||||
this.s3Uploader.current?.inputRef.current?.dispatchEvent(fire);
|
||||
}
|
||||
|
||||
render() {
|
||||
const activeClasses = (this.state.linkValid && !this.state.disabled)
|
||||
? 'green2 pointer' : 'gray2';
|
||||
|
||||
const focus = (this.state.submitFocus)
|
||||
? 'b--black b--white-d'
|
||||
: 'b--gray4 b--gray2-d';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ba br1 w-100 mb6 ${focus}`}
|
||||
onDragEnter={this.onDragEnter.bind(this)}
|
||||
onDragOver={e => {e.preventDefault();this.setState({ dragover: true})}}
|
||||
onDragLeave={() => this.setState({ dragover: false })}
|
||||
onDrop={this.onDrop}
|
||||
>
|
||||
{this.state.dragover ? <SubmitDragger /> : null}
|
||||
<div className="relative">
|
||||
{(this.state.linkValue || this.state.urlFocus || this.state.disabled) ? null : <span className="gray2 absolute pl2 pt3 pb2 f8" style={{pointerEvents: 'none'}}>
|
||||
Drop or <span className="pointer green2" style={{pointerEvents: 'all'}} onClick={(event) => {
|
||||
if (!this.readyToUpload()) {
|
||||
return;
|
||||
}
|
||||
this.s3Uploader.current.inputRef.current.click();
|
||||
}}>upload</span> a file, or paste a link here
|
||||
</span>}
|
||||
{!this.state.disabled ? <S3Upload
|
||||
ref={this.s3Uploader}
|
||||
configuration={this.props.s3.configuration}
|
||||
credentials={this.props.s3.credentials}
|
||||
uploadSuccess={this.uploadSuccess.bind(this)}
|
||||
uploadError={this.uploadError.bind(this)}
|
||||
className="dn absolute pt3 pb2 pl2 w-100"
|
||||
></S3Upload> : null}
|
||||
<input
|
||||
type="url"
|
||||
className="pl2 w-100 f8 pt3 pb2 white-d bg-transparent"
|
||||
onChange={this.setLinkValue}
|
||||
onBlur={() => this.setState({ submitFocus: false, urlFocus: false })}
|
||||
onFocus={() => this.setState({ submitFocus: true, urlFocus: true })}
|
||||
spellCheck="false"
|
||||
onPaste={this.onPaste}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.onClickPost();
|
||||
}
|
||||
}}
|
||||
value={this.state.linkValue}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="pl2 bg-transparent w-100 f8 white-d"
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 40
|
||||
}}
|
||||
placeholder="Provide a title"
|
||||
onChange={this.setLinkTitle}
|
||||
onBlur={() => this.setState({ submitFocus: false })}
|
||||
onFocus={() => this.setState({ submitFocus: true })}
|
||||
spellCheck="false"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.onClickPost();
|
||||
}
|
||||
}}
|
||||
value={this.state.linkTitle}
|
||||
/>
|
||||
{!this.state.disabled ? <button
|
||||
className={
|
||||
'bg-transparent f8 flex-shrink-0 pr2 pl2 pt2 pb3 ' + activeClasses
|
||||
}
|
||||
disabled={!this.state.linkValid || this.state.disabled}
|
||||
onClick={this.onClickPost.bind(this)}
|
||||
style={{
|
||||
bottom: 12,
|
||||
right: 8
|
||||
}}
|
||||
>
|
||||
Post link
|
||||
</button> : null}
|
||||
<Spinner awaiting={this.state.disabled} classes="nowrap flex items-center pr2 pl2 pt2 pb4" style={{flex: '1 1 14rem'}} text="Posting to collection..." />
|
||||
|
||||
|
||||
</div>
|
||||
) ;
|
||||
}
|
||||
}
|
||||
|
||||
export default LinkSubmit;
|
@ -1,66 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { makeRoutePath } from '~/logic/lib/util';
|
||||
|
||||
export class LinksTabBar extends Component {
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
let memColor = '',
|
||||
setColor = '';
|
||||
|
||||
if (props.location.pathname.includes('/settings')) {
|
||||
memColor = 'gray3';
|
||||
setColor = 'black white-d';
|
||||
} else if (props.location.pathname.includes('/members')) {
|
||||
memColor = 'black white-d';
|
||||
setColor = 'gray3';
|
||||
} else {
|
||||
memColor = 'gray3';
|
||||
setColor = 'gray3';
|
||||
}
|
||||
|
||||
const hidePopoutIcon = (props.popout)
|
||||
? 'dn-m dn-l dn-xl'
|
||||
: 'dib-m dib-l dib-xl';
|
||||
|
||||
return (
|
||||
<div className="dib flex-shrink-0 flex-grow-1">
|
||||
{props.amOwner ? (
|
||||
<div className={'dib pt2 f9 pl6 lh-solid'}>
|
||||
<Link
|
||||
className={'no-underline ' + memColor}
|
||||
to={makeRoutePath(props.resourcePath, props.popout) + '/members'}
|
||||
>
|
||||
Members
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dib" style={{ width: 0 }}></div>
|
||||
)}
|
||||
<div className={'dib pt2 f9 pl6 pr6 lh-solid'}>
|
||||
<Link
|
||||
className={'no-underline ' + setColor}
|
||||
to={makeRoutePath(props.resourcePath, props.popout) + '/settings'}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
<a href={makeRoutePath(props.resourcePath, true, props.page)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="dib fr pt2 pr1"
|
||||
>
|
||||
<img
|
||||
className={'flex-shrink-0 pr3 dn ' + hidePopoutIcon}
|
||||
src="/~link/img/popout.png"
|
||||
height="16"
|
||||
width="16"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LinksTabBar;
|
@ -1,5 +1,5 @@
|
||||
import React, { Component } from 'react';
|
||||
import { LinksTabBar } from './lib/links-tabbar';
|
||||
import { TabBar } from '~/views/components/chat-link-tabbar';
|
||||
import { LinkPreview } from './lib/link-detail-preview';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { Link } from 'react-router-dom';
|
||||
@ -123,6 +123,7 @@ export class LinkDetail extends Component {
|
||||
pending={true}
|
||||
content={com}
|
||||
member={our.member}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
time={new Date().getTime()}
|
||||
/>
|
||||
);
|
||||
@ -147,7 +148,12 @@ export class LinkDetail extends Component {
|
||||
>
|
||||
{`<- ${props.resource.metadata.title}`}
|
||||
</Link>
|
||||
<LinksTabBar {...props} popout={props.popout} resourcePath={props.resourcePath} />
|
||||
<TabBar
|
||||
location={props.location}
|
||||
popout={props.popout}
|
||||
popoutHref={makeRoutePath(props.resourcePath, true, props.page)}
|
||||
settings={makeRoutePath(props.resourcePath, props.popout) + '/settings'}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-100 mt2 flex justify-center overflow-y-scroll ph4 pb4">
|
||||
<div className="w-100 mw7">
|
||||
@ -162,6 +168,7 @@ export class LinkDetail extends Component {
|
||||
linkIndex={props.linkIndex}
|
||||
time={this.state.data.time}
|
||||
hideNicknames={props.hideNicknames}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className={'relative ba br1 mt6 mb6 ' + focus}>
|
||||
@ -215,6 +222,7 @@ export class LinkDetail extends Component {
|
||||
api={props.api}
|
||||
hideAvatars={props.hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
||||
|
||||
import { LoadingScreen } from './loading';
|
||||
import { MessageScreen } from './lib/message-screen';
|
||||
import { LinksTabBar } from './lib/links-tabbar';
|
||||
import { TabBar } from '~/views/components/chat-link-tabbar';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LinkItem } from './lib/link-item';
|
||||
@ -129,17 +129,17 @@ export class Links extends Component {
|
||||
{props.resource.metadata.title}
|
||||
</h2>
|
||||
</Link>
|
||||
<LinksTabBar
|
||||
{...props}
|
||||
popout={props.popout}
|
||||
page={props.page}
|
||||
resourcePath={props.resourcePath}
|
||||
<TabBar
|
||||
location={props.location}
|
||||
popout={props.popout}
|
||||
popoutHref={makeRoutePath(props.resourcePath, true, props.page)}
|
||||
settings={makeRoutePath(props.resourcePath, props.popout) + '/settings'}
|
||||
/>
|
||||
</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} api={this.props.api} />
|
||||
<LinkSubmit resourcePath={props.resourcePath} api={this.props.api} s3={props.s3} />
|
||||
</div>
|
||||
<div className="pb4">
|
||||
{LinkList}
|
||||
|
@ -1,70 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { LoadingScreen } from './loading';
|
||||
import { LinksTabBar } from './lib/links-tabbar';
|
||||
import { MemberElement } from './lib/member-element';
|
||||
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
|
||||
import { makeRoutePath } from '~/logic/lib/util';
|
||||
import { GroupView } from '~/views/components/Group';
|
||||
|
||||
export class MemberScreen extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
|
||||
if (!props.groupPath) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-100 w-100 overflow-x-hidden flex flex-column white-d'>
|
||||
<div
|
||||
className='w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8'
|
||||
style={{ height: '1rem' }}
|
||||
>
|
||||
<Link to='/~link'>{'⟵ All Collections'}</Link>
|
||||
</div>
|
||||
<div
|
||||
className={`pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative
|
||||
overflow-x-scroll overflow-x-auto-l overflow-x-auto-xl flex-shrink-0`}
|
||||
style={{ height: 48 }}
|
||||
>
|
||||
<SidebarSwitcher
|
||||
sidebarShown={this.props.sidebarShown}
|
||||
popout={this.props.popout}
|
||||
api={this.props.api}
|
||||
/>
|
||||
<Link
|
||||
to={makeRoutePath(props.resourcePath, props.popout)}
|
||||
className='pt2 white-d'
|
||||
>
|
||||
<h2
|
||||
className='dib f9 fw4 lh-solid v-top'
|
||||
style={{ width: 'max-content' }}
|
||||
>
|
||||
{props.resource.metadata.title}
|
||||
</h2>
|
||||
</Link>
|
||||
<LinksTabBar
|
||||
{...props}
|
||||
groupPath={props.groupPath}
|
||||
resourcePath={props.resourcePath}
|
||||
amOwner={props.amOwner}
|
||||
popout={props.popout}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-100 pl3 mt0 mt4-m mt4-l mt4-xl cf pr6'>
|
||||
<GroupView
|
||||
group={props.group}
|
||||
permissions
|
||||
resourcePath={props.groupPath}
|
||||
contacts={props.contacts}
|
||||
groups={props.groups}
|
||||
associations={props.associations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
import React, { Component } from 'react';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { InviteSearch } from '~/views/components/InviteSearch';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { makeRoutePath, deSig } from '~/logic/lib/util';
|
||||
import urbitOb from 'urbit-ob';
|
||||
|
||||
export class NewScreen extends Component {
|
||||
constructor(props) {
|
||||
|
@ -5,42 +5,27 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { LoadingScreen } from './loading';
|
||||
import { Spinner } from '~/views/components/Spinner';
|
||||
import { LinksTabBar } from './lib/links-tabbar';
|
||||
import { TabBar } from '~/views/components/chat-link-tabbar';
|
||||
import SidebarSwitcher from '~/views/components/SidebarSwitch';
|
||||
|
||||
import { MetadataSettings } from '~/views/components/metadata/settings';
|
||||
|
||||
export class SettingsScreen extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isLoading: false,
|
||||
title: '',
|
||||
description: '',
|
||||
color: '',
|
||||
disabled: false,
|
||||
awaiting: false,
|
||||
type: 'Editing'
|
||||
};
|
||||
|
||||
this.changeTitle = this.changeTitle.bind(this);
|
||||
this.changeDescription = this.changeDescription.bind(this);
|
||||
this.changeColor = this.changeColor.bind(this);
|
||||
this.submitColor = this.submitColor.bind(this);
|
||||
this.renderDelete = this.renderDelete.bind(this);
|
||||
this.renderMetadataSettings = this.renderMetadataSettings.bind(this);
|
||||
this.markAllAsSeen = this.markAllAsSeen.bind(this);
|
||||
this.changeLoading = this.changeLoading.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if ((this.props.resource) && ('metadata' in this.props.resource)) {
|
||||
this.setState({
|
||||
title: this.props.resource.metadata.title,
|
||||
description: this.props.resource.metadata.description,
|
||||
color: `#${uxToHex(this.props.resource.metadata.color || '0x0')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
componentDidUpdate() {
|
||||
const { props, state } = this;
|
||||
|
||||
if (Boolean(state.isLoading) && !props.resource) {
|
||||
@ -50,64 +35,14 @@ export class SettingsScreen extends Component {
|
||||
props.history.push('/~link');
|
||||
});
|
||||
}
|
||||
|
||||
if (((props.resource) && ('metadata' in props.resource))
|
||||
&& (prevProps !== props)) {
|
||||
this.setState({
|
||||
title: props.resource.metadata.title,
|
||||
description: props.resource.metadata.description,
|
||||
color: `#${uxToHex(this.props.resource.metadata.color || '0x0')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changeTitle() {
|
||||
this.setState({ title: event.target.value });
|
||||
}
|
||||
|
||||
changeDescription() {
|
||||
this.setState({ description: event.target.value });
|
||||
}
|
||||
|
||||
changeColor() {
|
||||
this.setState({ color: event.target.value });
|
||||
}
|
||||
|
||||
submitColor() {
|
||||
const { props, state } = this;
|
||||
const { resource } = props;
|
||||
|
||||
if (!('metadata' in resource)) {
|
||||
resource.metadata = {};
|
||||
}
|
||||
|
||||
// submit color if valid
|
||||
let color = state.color;
|
||||
if (color.startsWith('#')) {
|
||||
color = state.color.substr(1);
|
||||
}
|
||||
const hexExp = /([0-9A-Fa-f]{6})/;
|
||||
const hexTest = hexExp.exec(color);
|
||||
let currentColor = '000000';
|
||||
if (props.resource && 'metadata' in props.resource) {
|
||||
currentColor = uxToHex(props.resource.metadata.color);
|
||||
}
|
||||
if (hexTest && (hexTest[1] !== currentColor)) {
|
||||
if (props.amOwner) {
|
||||
this.setState({ disabled: true });
|
||||
props.api.metadataAdd(
|
||||
'link',
|
||||
props.resourcePath,
|
||||
props.groupPath,
|
||||
resource.metadata.title,
|
||||
resource.metadata.description,
|
||||
resource.metadata['date-created'],
|
||||
color
|
||||
).then(() => {
|
||||
this.setState({ disabled: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
changeLoading(isLoading, awaiting, type, closure) {
|
||||
this.setState({
|
||||
isLoading,
|
||||
awaiting,
|
||||
type
|
||||
}, closure);
|
||||
}
|
||||
|
||||
removeCollection() {
|
||||
@ -115,7 +50,7 @@ export class SettingsScreen extends Component {
|
||||
|
||||
this.setState({
|
||||
isLoading: true,
|
||||
disabled: true,
|
||||
awaiting: true,
|
||||
type: 'Removing'
|
||||
});
|
||||
props.api.links.removeCollection(props.resourcePath)
|
||||
@ -131,7 +66,7 @@ export class SettingsScreen extends Component {
|
||||
|
||||
this.setState({
|
||||
isLoading: true,
|
||||
disabled: true,
|
||||
awaiting: true,
|
||||
type: 'Deleting'
|
||||
});
|
||||
props.api.links.deleteCollection(props.resourcePath)
|
||||
@ -175,7 +110,7 @@ export class SettingsScreen extends Component {
|
||||
Delete this collection, for you and all group members.
|
||||
</p>
|
||||
<a onClick={this.deleteCollection.bind(this)}
|
||||
className="dib f9 ba pa2 b--red2 red2 pointer bg-gray0-d"
|
||||
className="dib f9 ba pa2 b--red2 red2 pointer bg-gray0-d mb4"
|
||||
>
|
||||
Delete collection
|
||||
</a>
|
||||
@ -184,105 +119,6 @@ export class SettingsScreen extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
renderMetadataSettings() {
|
||||
const { props, state } = this;
|
||||
const { resource } = props;
|
||||
|
||||
if (!('metadata' in resource)) {
|
||||
resource.metadata = {};
|
||||
}
|
||||
|
||||
return(
|
||||
<div>
|
||||
<div className={'w-100 pb6 fl mt3 ' + ((props.amOwner) ? '' : 'o-30')}>
|
||||
<p className="f8 mt3 lh-copy">Rename</p>
|
||||
<p className="f9 gray2 db mb4">Change the name of this collection</p>
|
||||
<div className="relative w-100 flex"
|
||||
style={{ maxWidth: '29rem' }}
|
||||
>
|
||||
<input
|
||||
className={'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
|
||||
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
|
||||
value={this.state.title}
|
||||
disabled={!props.amOwner || this.state.disabled}
|
||||
onChange={this.changeTitle}
|
||||
onBlur={() => {
|
||||
if (props.amOwner) {
|
||||
this.setState({ disabled: true });
|
||||
props.api.metadata.metadataAdd(
|
||||
'link',
|
||||
props.resourcePath,
|
||||
props.groupPath,
|
||||
state.title,
|
||||
resource.metadata.description,
|
||||
resource.metadata['date-created'],
|
||||
uxToHex(resource.metadata.color)
|
||||
).then(() => {
|
||||
this.setState({ disabled: false });
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="f8 mt3 lh-copy">Change description</p>
|
||||
<p className="f9 gray2 db mb4">
|
||||
Change the description of this collection
|
||||
</p>
|
||||
<div className="relative w-100 flex"
|
||||
style={{ maxWidth: '29rem' }}
|
||||
>
|
||||
<input
|
||||
className={'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
|
||||
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
|
||||
value={this.state.description}
|
||||
disabled={!props.amOwner || this.state.disabled}
|
||||
onChange={this.changeDescription}
|
||||
onBlur={() => {
|
||||
if (props.amOwner) {
|
||||
this.setState({ disabled: true });
|
||||
props.api.metadata.metadataAdd(
|
||||
'link',
|
||||
props.resourcePath,
|
||||
props.groupPath,
|
||||
resource.metadata.title,
|
||||
state.description,
|
||||
resource.metadata['date-created'],
|
||||
uxToHex(resource.metadata.color)
|
||||
).then(() => {
|
||||
this.setState({ disabled: false });
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="f8 mt3 lh-copy">Change color</p>
|
||||
<p className="f9 gray2 db mb4">Give this collection a color when viewing group channels</p>
|
||||
<div className="relative w-100 flex"
|
||||
style={{ maxWidth: '10rem' }}
|
||||
>
|
||||
<div className="absolute"
|
||||
style={{
|
||||
height: 16,
|
||||
width: 16,
|
||||
backgroundColor: state.color,
|
||||
top: 13,
|
||||
left: 11
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className={'pl7 f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
|
||||
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
|
||||
value={this.state.color}
|
||||
disabled={!props.amOwner || this.state.disabled}
|
||||
onChange={this.changeColor}
|
||||
onBlur={this.submitColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
|
||||
@ -318,7 +154,12 @@ export class SettingsScreen extends Component {
|
||||
{props.resource.metadata.title}
|
||||
</h2>
|
||||
</Link>
|
||||
<LinksTabBar {...props} />
|
||||
<TabBar
|
||||
location={props.location}
|
||||
popout={props.popout}
|
||||
popoutHref={makeRoutePath(props.resourcePath, true, props.page)}
|
||||
settings={makeRoutePath(props.resourcePath, props.popout) + '/settings'}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-100 pl3 mt4 cf">
|
||||
<h2 className="f8 pb2">Removing...</h2>
|
||||
@ -354,8 +195,13 @@ export class SettingsScreen extends Component {
|
||||
{props.resource.metadata.title}
|
||||
</h2>
|
||||
</Link>
|
||||
<LinksTabBar {...props} />
|
||||
</div>
|
||||
<TabBar
|
||||
location={props.location}
|
||||
popout={props.popout}
|
||||
popoutHref={makeRoutePath(props.resourcePath, true, props.page)}
|
||||
settings={makeRoutePath(props.resourcePath, props.popout) + '/settings'}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-100 pl3 mt4 cf">
|
||||
<h2 className="f8 pb2">Collection Settings</h2>
|
||||
<p className="f8 mt3 lh-copy db">Mark all links as read</p>
|
||||
@ -367,11 +213,18 @@ export class SettingsScreen extends Component {
|
||||
</a>
|
||||
{this.renderRemove()}
|
||||
{this.renderDelete()}
|
||||
{this.renderMetadataSettings()}
|
||||
<MetadataSettings
|
||||
isOwner={props.amOwner}
|
||||
changeLoading={this.changeLoading}
|
||||
api={props.api}
|
||||
association={props.resource}
|
||||
resource="collection"
|
||||
app="link"
|
||||
/>
|
||||
<Spinner
|
||||
awaiting={this.state.disabled}
|
||||
awaiting={this.state.awaiting}
|
||||
classes="absolute right-1 bottom-1 pa2 ba b--black b--gray0-d white-d"
|
||||
text={`${this.state.type} collection...`}
|
||||
text={this.state.type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -0,0 +1,94 @@
|
||||
import React from "react";
|
||||
import { Box, Button, Checkbox } from '@tlon/indigo-react';
|
||||
import { Formik, Form } from "formik";
|
||||
import * as Yup from "yup";
|
||||
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { LocalUpdateRemoteContentPolicy } from "~/types/local-update";
|
||||
|
||||
const formSchema = Yup.object().shape({
|
||||
imageShown: Yup.boolean(),
|
||||
audioShown: Yup.boolean(),
|
||||
videoShown: Yup.boolean(),
|
||||
oembedShown: Yup.boolean()
|
||||
});
|
||||
|
||||
interface FormSchema {
|
||||
imageShown: boolean;
|
||||
audioShown: boolean;
|
||||
videoShown: boolean;
|
||||
oembedShown: boolean;
|
||||
}
|
||||
|
||||
interface RemoteContentFormProps {
|
||||
api: GlobalApi;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
}
|
||||
|
||||
export default function RemoteContentForm(props: RemoteContentFormProps) {
|
||||
const { api, remoteContentPolicy } = props;
|
||||
const imageShown = remoteContentPolicy.imageShown;
|
||||
const audioShown = remoteContentPolicy.audioShown;
|
||||
const videoShown = remoteContentPolicy.videoShown;
|
||||
const oembedShown = remoteContentPolicy.oembedShown;
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={
|
||||
{
|
||||
imageShown,
|
||||
audioShown,
|
||||
videoShown,
|
||||
oembedShown
|
||||
} as FormSchema
|
||||
}
|
||||
onSubmit={(values, actions) => {
|
||||
api.local.setRemoteContentPolicy({
|
||||
imageShown: values.imageShown,
|
||||
audioShown: values.audioShown,
|
||||
videoShown: values.videoShown,
|
||||
oembedShown: values.oembedShown
|
||||
});
|
||||
api.local.dehydrate();
|
||||
actions.setSubmitting(false);
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Form>
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="1fr"
|
||||
gridTemplateRows="audio"
|
||||
gridRowGap={3}
|
||||
>
|
||||
<Box color="black" fontSize={1} mb={3} fontWeight={900}>
|
||||
Remote Content
|
||||
</Box>
|
||||
<Box>
|
||||
<Checkbox
|
||||
label="Load images"
|
||||
id="imageShown"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Load audio files"
|
||||
id="audioShown"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Load video files"
|
||||
id="videoShown"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Load embedded content"
|
||||
id="oembedShown"
|
||||
caption="Embedded content may contain scripts"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button border={1} borderColor="washedGray" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
@ -24,18 +24,18 @@ export default function SecuritySettings({ api }: SecuritySettingsProps) {
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
{/* <Box color="black" fontSize={0} mt={4} fontWeight={700}>
|
||||
<Box color="black" fontSize={0} mt={4} fontWeight={700}>
|
||||
Log out of all sessions
|
||||
</Box> */}
|
||||
{/* Restore after testing sending 'all' in POST body
|
||||
</Box>
|
||||
<Box fontSize={0} mt={2} color="gray">
|
||||
You will be logged out of all browsers that have currently logged into your Urbit.
|
||||
<form method="post" action="/~/logout">
|
||||
<input type="hidden" name="all" />
|
||||
<Button error narrow mt={4} border={1}>
|
||||
Logout
|
||||
</Button>
|
||||
</form>
|
||||
</Box> */}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import { StoreState } from "../../../store/type";
|
||||
import DisplayForm from "./lib/DisplayForm";
|
||||
import S3Form from "./lib/S3Form";
|
||||
import SecuritySettings from "./lib/Security";
|
||||
import RemoteContentForm from "./lib/RemoteContent";
|
||||
|
||||
type ProfileProps = StoreState & { api: GlobalApi; ship: string };
|
||||
|
||||
@ -30,6 +31,7 @@ export default function Settings({
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
background,
|
||||
remoteContentPolicy
|
||||
}: ProfileProps) {
|
||||
return (
|
||||
<Box
|
||||
@ -51,6 +53,7 @@ export default function Settings({
|
||||
background={background}
|
||||
s3={s3}
|
||||
/>
|
||||
<RemoteContentForm {...{api, remoteContentPolicy}} />
|
||||
<S3Form api={api} s3={s3} />
|
||||
<SecuritySettings api={api} />
|
||||
</Box>
|
||||
|
@ -1,12 +1,13 @@
|
||||
import React from "react";
|
||||
import { Route, Link, Switch } from "react-router-dom";
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { Box, Text, Row, Col, Center, Icon } from "@tlon/indigo-react";
|
||||
import { Box, Text, Row, Col, Icon } from "@tlon/indigo-react";
|
||||
|
||||
import { Sigil } from "~/logic/lib/sigil";
|
||||
import { uxToHex, MOBILE_BROWSER_REGEX } from "~/logic/lib/util";
|
||||
|
||||
import Settings from "./components/settings";
|
||||
import { Route, Link } from "react-router-dom";
|
||||
import { ContactCard } from "../groups/components/lib/ContactCard";
|
||||
|
||||
const SidebarItem = ({ children, view, current }) => {
|
||||
@ -15,7 +16,6 @@ const SidebarItem = ({ children, view, current }) => {
|
||||
return (
|
||||
<Link to={`/~profile/${view}`}>
|
||||
<Row
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
verticalAlign="middle"
|
||||
py={1}
|
||||
@ -34,6 +34,11 @@ const SidebarItem = ({ children, view, current }) => {
|
||||
export default function ProfileScreen(props: any) {
|
||||
const { ship, dark } = props;
|
||||
return (
|
||||
<>
|
||||
<Helmet defer={false}>
|
||||
<title>OS1 - Profile</title>
|
||||
</Helmet>
|
||||
<Switch>
|
||||
<Route
|
||||
path={["/~profile/:view", "/~profile"]}
|
||||
render={({ match, history }) => {
|
||||
@ -85,7 +90,7 @@ export default function ProfileScreen(props: any) {
|
||||
<Sigil ship={`~${ship}`} size={80} color={sigilColor} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box width="100%" py={3}>
|
||||
<Box width="100%" py={3} zIndex='2'>
|
||||
<SidebarItem current={view} view="identity">
|
||||
Your Identity
|
||||
</SidebarItem>
|
||||
@ -120,5 +125,7 @@ export default function ProfileScreen(props: any) {
|
||||
);
|
||||
}}
|
||||
></Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -63,6 +63,7 @@ export default function PublishApp(props: PublishAppProps) {
|
||||
associations,
|
||||
hideNicknames,
|
||||
hideAvatars,
|
||||
remoteContentPolicy
|
||||
} = props;
|
||||
|
||||
const active = location.pathname.endsWith("/~publish")
|
||||
@ -71,7 +72,7 @@ export default function PublishApp(props: PublishAppProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<Helmet defer={false}>
|
||||
<title>{unreadTotal > 0 ? `(${unreadTotal}) ` : ""}OS1 - Publish</title>
|
||||
</Helmet>
|
||||
<Route
|
||||
@ -161,6 +162,8 @@ export default function PublishApp(props: PublishAppProps) {
|
||||
api={api}
|
||||
hideNicknames={hideNicknames}
|
||||
hideAvatars={hideAvatars}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
associations={associations}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -1,14 +1,13 @@
|
||||
import React, { useState } from "react";
|
||||
import moment from "moment";
|
||||
import { Sigil } from "~/logic/lib/sigil";
|
||||
import CommentInput from "./CommentInput";
|
||||
import { uxToHex, cite } from "~/logic/lib/util";
|
||||
import { Comment, NoteId } from "~/types/publish-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Button, Box, Row, Text } from "@tlon/indigo-react";
|
||||
import { Box, Row } from "@tlon/indigo-react";
|
||||
import styled from "styled-components";
|
||||
import { Author } from "./Author";
|
||||
import tokenizeMessage from '~/logic/lib/tokenizeMessage';
|
||||
import RichText from '~/views/components/RichText';
|
||||
|
||||
const ClickBox = styled(Box)`
|
||||
cursor: pointer;
|
||||
@ -28,17 +27,11 @@ interface CommentItemProps {
|
||||
}
|
||||
|
||||
export function CommentItem(props: CommentItemProps) {
|
||||
const { ship, contacts, book, note, api } = props;
|
||||
const { ship, contacts, book, note, api, remoteContentPolicy } = props;
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const commentPath = Object.keys(props.comment)[0];
|
||||
const commentData = props.comment[commentPath];
|
||||
const content = commentData.content.split("\n").map((line, i) => {
|
||||
return (
|
||||
<Text className="mb2" key={i}>
|
||||
{line}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
const content = tokenizeMessage(commentData.content).flat().join(' ');
|
||||
|
||||
const disabled = props.pending || window.ship !== commentData.author.slice(1);
|
||||
|
||||
@ -86,14 +79,13 @@ export function CommentItem(props: CommentItemProps) {
|
||||
</Author>
|
||||
</Row>
|
||||
<Box mb={2}>
|
||||
{!editing && content}
|
||||
{editing && (
|
||||
<CommentInput
|
||||
{editing
|
||||
? <CommentInput
|
||||
onSubmit={onUpdate}
|
||||
initial={commentData.content}
|
||||
label="Update"
|
||||
/>
|
||||
)}
|
||||
: <RichText className="f9 white-d" remoteContentPolicy={remoteContentPolicy}>{content}</RichText>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
@ -8,6 +8,7 @@ import { Contacts } from "~/types/contact-update";
|
||||
import _ from "lodash";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { FormikHelpers } from "formik";
|
||||
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||
|
||||
interface CommentsProps {
|
||||
comments: Comment[];
|
||||
@ -21,6 +22,7 @@ interface CommentsProps {
|
||||
enabled: boolean;
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
}
|
||||
|
||||
export function Comments(props: CommentsProps) {
|
||||
@ -78,6 +80,7 @@ export function Comments(props: CommentsProps) {
|
||||
pending={true}
|
||||
hideNicknames={props.hideNicknames}
|
||||
hideAvatars={props.hideAvatars}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -92,6 +95,7 @@ export function Comments(props: CommentsProps) {
|
||||
note={note["note-id"]}
|
||||
hideNicknames={props.hideNicknames}
|
||||
hideAvatars={props.hideAvatars}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
|
@ -0,0 +1,77 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { Box, Col, Button, InputLabel, InputCaption } from "@tlon/indigo-react";
|
||||
import * as Yup from "yup";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Notebook } from "~/types/publish-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
|
||||
import { MetadataForm } from "./MetadataForm";
|
||||
import { Groups, Associations } from "~/types";
|
||||
import { Formik, FormikHelpers, Form } from "formik";
|
||||
import GroupSearch from "~/views/components/GroupSearch";
|
||||
import { AsyncButton } from "~/views/components/AsyncButton";
|
||||
|
||||
const formSchema = Yup.object({
|
||||
group: Yup.string().nullable(),
|
||||
});
|
||||
|
||||
interface FormSchema {
|
||||
group: string | null;
|
||||
}
|
||||
|
||||
interface GroupifyFormProps {
|
||||
host: string;
|
||||
book: string;
|
||||
notebook: Notebook;
|
||||
groups: Groups;
|
||||
api: GlobalApi;
|
||||
associations: Associations;
|
||||
}
|
||||
|
||||
export function GroupifyForm(props: GroupifyFormProps) {
|
||||
const onGroupify = async (
|
||||
values: FormSchema,
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => {
|
||||
try {
|
||||
await props.api.publish.groupify(props.book, values.group);
|
||||
actions.setStatus({ success: null });
|
||||
} catch (e) {
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const groupPath = props.notebook?.["writers-group-path"];
|
||||
|
||||
const isUnmanaged = props.groups?.[groupPath]?.hidden || false;
|
||||
|
||||
if (!isUnmanaged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialValues: FormSchema = {
|
||||
group: null
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={onGroupify}
|
||||
>
|
||||
<Form style={{ display: "contents" }}>
|
||||
<GroupSearch
|
||||
id="group"
|
||||
label="Group"
|
||||
caption="What group should this notebook be added to? If blank, a new group will be made for the notebook"
|
||||
associations={props.associations}
|
||||
/>
|
||||
<AsyncButton loadingText="Groupifying..." border>
|
||||
Groupify
|
||||
</AsyncButton>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupifyForm;
|
@ -17,7 +17,7 @@ export function JoinScreen(props: JoinScreenProps & RouteComponentProps) {
|
||||
const [error, setError] = useState(false);
|
||||
const joining = useRef(false);
|
||||
|
||||
const waiter = useWaitForProps(props, 10000);
|
||||
const waiter = useWaitForProps(props);
|
||||
|
||||
const onJoin = useCallback(async () => {
|
||||
joining.current = true;
|
||||
|
@ -12,7 +12,7 @@ export const MarkdownField = ({ id, ...rest }: { id: string; } & Parameters<type
|
||||
const [{ value }, { error, touched }, { setValue, setTouched }] = useField(id);
|
||||
|
||||
return (
|
||||
<Box width="100%" display="flex" flexDirection="column" {...rest}>
|
||||
<Box overflowY="hidden" width="100%" display="flex" flexDirection="column" {...rest}>
|
||||
<MarkdownEditor
|
||||
onFocus={() => setTouched(true)}
|
||||
onBlur={() => setTouched(false)}
|
||||
|
@ -0,0 +1,104 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { AsyncButton } from "../../../../components/AsyncButton";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
Box,
|
||||
Input,
|
||||
Checkbox,
|
||||
Col,
|
||||
InputLabel,
|
||||
InputCaption,
|
||||
Button,
|
||||
Center,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Notebook } from "~/types/publish-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { RouteComponentProps, useHistory } from "react-router-dom";
|
||||
|
||||
interface MetadataFormProps {
|
||||
host: string;
|
||||
book: string;
|
||||
notebook: Notebook;
|
||||
contacts: Contacts;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
interface FormSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
comments: boolean;
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
name: Yup.string().required("Notebook must have a name"),
|
||||
description: Yup.string(),
|
||||
comments: Yup.boolean(),
|
||||
});
|
||||
|
||||
const ResetOnPropsChange = (props: { init: FormSchema; book: string }) => {
|
||||
const { resetForm } = useFormikContext<FormSchema>();
|
||||
useEffect(() => {
|
||||
resetForm({ values: props.init });
|
||||
}, [props.book]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
export function MetadataForm(props: MetadataFormProps) {
|
||||
const { host, notebook, api, book } = props;
|
||||
const initialValues: FormSchema = {
|
||||
name: notebook?.title,
|
||||
description: notebook?.about,
|
||||
comments: notebook?.comments,
|
||||
};
|
||||
|
||||
const onSubmit = async (
|
||||
values: FormSchema,
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => {
|
||||
try {
|
||||
const { name, description, comments } = values;
|
||||
await api.publish.editBook(book, name, description, comments);
|
||||
api.publish.fetchNotebook(host, book);
|
||||
actions.setStatus({ success: null });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form style={{ display: "contents" }}>
|
||||
<Input
|
||||
id="name"
|
||||
label="Rename"
|
||||
caption="Change the name of this notebook"
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Change description"
|
||||
caption="Change the description of this notebook"
|
||||
/>
|
||||
<Checkbox
|
||||
id="comments"
|
||||
label="Comments"
|
||||
caption="Subscribers may comment when enabled"
|
||||
/>
|
||||
<ResetOnPropsChange init={initialValues} book={book} />
|
||||
<AsyncButton primary loadingText="Updating.." border>
|
||||
Save
|
||||
</AsyncButton>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
@ -13,6 +13,7 @@ import {
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Author } from "./Author";
|
||||
import { LocalUpdateRemoteContentPolicy } from "~/types";
|
||||
|
||||
interface NoteProps {
|
||||
ship: string;
|
||||
@ -24,6 +25,7 @@ interface NoteProps {
|
||||
api: GlobalApi;
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
}
|
||||
|
||||
export function Note(props: NoteProps & RouteComponentProps) {
|
||||
@ -115,6 +117,7 @@ export function Note(props: NoteProps & RouteComponentProps) {
|
||||
api={props.api}
|
||||
hideNicknames={props.hideNicknames}
|
||||
hideAvatars={props.hideAvatars}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
/>
|
||||
)}
|
||||
<Spinner
|
||||
|
@ -31,10 +31,11 @@ export function PostForm(props: PostFormProps) {
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
height="100%"
|
||||
p={[2, 4]}
|
||||
display="grid"
|
||||
justifyItems="start"
|
||||
gridAutoRows="min-content"
|
||||
gridTemplateRows={["64px 64px 1fr", "64px 1fr"]}
|
||||
gridTemplateColumns={["100%", "1fr 1fr"]}
|
||||
gridColumnGap={2}
|
||||
gridRowGap={2}
|
||||
|
@ -20,6 +20,7 @@ import { Groups } from "~/types/group-update";
|
||||
import { Contacts, Rolodex } from "~/types/contact-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import styled from "styled-components";
|
||||
import {Associations} from "~/types";
|
||||
|
||||
const TabList = styled(_TabList)`
|
||||
margin-bottom: ${(p) => p.theme.space[4]}px;
|
||||
@ -38,6 +39,7 @@ interface NotebookProps {
|
||||
contacts: Rolodex;
|
||||
groups: Groups;
|
||||
hideNicknames: boolean;
|
||||
associations: Associations;
|
||||
}
|
||||
|
||||
export function Notebook(props: NotebookProps & RouteComponentProps) {
|
||||
@ -130,6 +132,8 @@ export function Notebook(props: NotebookProps & RouteComponentProps) {
|
||||
api={api}
|
||||
notebook={notebook}
|
||||
contacts={notebookContacts}
|
||||
associations={props.associations}
|
||||
groups={groups}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
|
@ -18,6 +18,7 @@ function UnreadCount(props: { unread: number }) {
|
||||
fontWeight="700"
|
||||
py={1}
|
||||
borderRadius={1}
|
||||
flexShrink='0'
|
||||
color="white"
|
||||
bg="lightGray"
|
||||
>
|
||||
@ -41,7 +42,7 @@ export function NotebookItem(props: NotebookItemProps) {
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Box py={1}>{props.title}</Box>
|
||||
<Box py='1' pr='1'>{props.title}</Box>
|
||||
{props.unreadCount > 0 && <UnreadCount unread={props.unreadCount} />}
|
||||
</HoverBox>
|
||||
</Link>
|
||||
|
@ -9,6 +9,7 @@ import { Contacts, Rolodex } from "../../../../types/contact-update";
|
||||
import Notebook from "./Notebook";
|
||||
import NewPost from "./new-post";
|
||||
import { NoteRoutes } from './NoteRoutes';
|
||||
import { LocalUpdateRemoteContentPolicy, Associations } from "~/types";
|
||||
|
||||
interface NotebookRoutesProps {
|
||||
api: GlobalApi;
|
||||
@ -21,6 +22,8 @@ interface NotebookRoutesProps {
|
||||
groups: Groups;
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
associations: Associations;
|
||||
}
|
||||
|
||||
export function NotebookRoutes(
|
||||
@ -74,6 +77,7 @@ export function NotebookRoutes(
|
||||
contacts={notebookContacts}
|
||||
hideAvatars={props.hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
{...routeProps}
|
||||
/>
|
||||
);
|
||||
|
@ -1,128 +1,65 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { AsyncButton } from "../../../../components/AsyncButton";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
Box,
|
||||
Input,
|
||||
Checkbox,
|
||||
Col,
|
||||
InputLabel,
|
||||
InputCaption,
|
||||
Button,
|
||||
Center,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
|
||||
import { Box, Col, Button, InputLabel, InputCaption } from "@tlon/indigo-react";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Notebook } from "~/types/publish-update";
|
||||
import { Contacts } from "~/types/contact-update";
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { RouteComponentProps, useHistory } from "react-router-dom";
|
||||
|
||||
import { MetadataForm } from "./MetadataForm";
|
||||
import { Groups, Associations } from "~/types";
|
||||
import GroupifyForm from "./GroupifyForm";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
interface SettingsProps {
|
||||
host: string;
|
||||
book: string;
|
||||
notebook: Notebook;
|
||||
contacts: Contacts;
|
||||
groups: Groups;
|
||||
api: GlobalApi;
|
||||
associations: Associations;
|
||||
}
|
||||
|
||||
interface FormSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
comments: boolean;
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
name: Yup.string().required("Notebook must have a name"),
|
||||
description: Yup.string(),
|
||||
comments: Yup.boolean(),
|
||||
});
|
||||
|
||||
const ResetOnPropsChange = (props: { init: FormSchema; book: string }) => {
|
||||
const { resetForm } = useFormikContext<FormSchema>();
|
||||
useEffect(() => {
|
||||
resetForm({ values: props.init });
|
||||
}, [props.book]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const Divider = (props) => (
|
||||
<Box {...props} mb={4} borderBottom={1} borderBottomColor="lightGray" />
|
||||
);
|
||||
export function Settings(props: SettingsProps) {
|
||||
const { host, notebook, api, book } = props;
|
||||
const history = useHistory();
|
||||
const initialValues: FormSchema = {
|
||||
name: notebook?.title,
|
||||
description: notebook?.about,
|
||||
comments: notebook?.comments,
|
||||
};
|
||||
|
||||
const onSubmit = async (
|
||||
values: FormSchema,
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => {
|
||||
try {
|
||||
const { name, description, comments } = values;
|
||||
await api.publish.editBook(book, name, description, comments);
|
||||
api.publish.fetchNotebook(host, book);
|
||||
actions.setStatus({ success: null });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
await api.publish.delBook(book);
|
||||
await props.api.publish.delBook(props.book);
|
||||
history.push("/~publish");
|
||||
};
|
||||
const groupPath = props.notebook?.["writers-group-path"];
|
||||
|
||||
const isUnmanaged = props.groups?.[groupPath]?.hidden || false;
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
<Box
|
||||
mx="auto"
|
||||
maxWidth="300px"
|
||||
mb={4}
|
||||
gridTemplateColumns="1fr"
|
||||
gridAutoRows="auto"
|
||||
display="grid"
|
||||
>
|
||||
<Form>
|
||||
<Box
|
||||
maxWidth="300px"
|
||||
mb={4}
|
||||
gridTemplateColumns="1fr"
|
||||
gridAutoRows="auto"
|
||||
display="grid"
|
||||
>
|
||||
<Col mb={4}>
|
||||
<InputLabel>Delete Notebook</InputLabel>
|
||||
<InputCaption>
|
||||
Permanently delete this notebook. (All current members will no
|
||||
longer see this notebook.)
|
||||
</InputCaption>
|
||||
<Button onClick={onDelete} mt={1} border error>
|
||||
Delete this notebook
|
||||
</Button>
|
||||
</Col>
|
||||
<Input
|
||||
id="name"
|
||||
label="Rename"
|
||||
caption="Change the name of this notebook"
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Change description"
|
||||
caption="Change the description of this notebook"
|
||||
/>
|
||||
<Checkbox
|
||||
id="comments"
|
||||
label="Comments"
|
||||
caption="Subscribers may comment when enabled"
|
||||
/>
|
||||
<ResetOnPropsChange init={initialValues} book={book} />
|
||||
<AsyncButton loadingText="Updating.." border>
|
||||
Save
|
||||
</AsyncButton>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Box>
|
||||
</Form>
|
||||
</Formik>
|
||||
{isUnmanaged && (
|
||||
<>
|
||||
<GroupifyForm {...props} />
|
||||
<Divider mt={4} />
|
||||
</>
|
||||
)}
|
||||
<MetadataForm {...props} />
|
||||
<Divider />
|
||||
<Col mb={4}>
|
||||
<InputLabel>Delete Notebook</InputLabel>
|
||||
<InputCaption>
|
||||
Permanently delete this notebook. (All current members will no longer
|
||||
see this notebook.)
|
||||
</InputCaption>
|
||||
<Button onClick={onDelete} mt={1} border error>
|
||||
Delete this notebook
|
||||
</Button>
|
||||
</Col>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { Component } from 'react';
|
||||
import { GroupView } from '~/views/components/Group';
|
||||
import { resourceFromPath } from '~/logic/lib/group';
|
||||
import { resourceFromPath, roleForShip } from '~/logic/lib/group';
|
||||
import {Notebook} from '~/types/publish-update';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import {Groups} from '~/types/group-update';
|
||||
import {Associations} from '~/types/metadata-update';
|
||||
import {Rolodex} from '~/types/contact-update';
|
||||
import {Box, Button} from '@tlon/indigo-react';
|
||||
|
||||
interface SubscribersProps {
|
||||
notebook: Notebook;
|
||||
@ -71,16 +72,20 @@ export class Subscribers extends Component<SubscribersProps> {
|
||||
addDesc: 'Allow user to write to this notebook'
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
if(!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role = roleForShip(group, window.ship)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={this.addAll}
|
||||
className={'dib f9 black gray4-d bg-gray0-d ba pa2 mb4 b--black b--gray1-d pointer'}
|
||||
>
|
||||
Add all members as writers
|
||||
</button>
|
||||
<Box>
|
||||
{ role === 'admin' && (
|
||||
<Button mb={3} border onClick={this.addAll}>
|
||||
Add all members as writers
|
||||
</Button>
|
||||
)}
|
||||
<GroupView
|
||||
permissions
|
||||
resourcePath={path}
|
||||
@ -92,7 +97,7 @@ export class Subscribers extends Component<SubscribersProps> {
|
||||
associations={this.props.associations}
|
||||
api={this.props.api}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ class ErrorComponent extends Component<ErrorProps> {
|
||||
render () {
|
||||
const { code, error, history, description } = this.props;
|
||||
return (
|
||||
<Col alignItems="center" justifyContent="center" height="100%" p={4}>
|
||||
<Col alignItems="center" justifyContent="center" height="100%" p="4" backgroundColor="white">
|
||||
<Box mb={4}>
|
||||
<Text fontSize={3}>
|
||||
{code ? code : 'Error'}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import _, { capitalize } from 'lodash';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { Virtuoso as VirtualList } from 'react-virtuoso';
|
||||
|
||||
import { cite, deSig } from '~/logic/lib/util';
|
||||
import { roleForShip, resourceFromPath } from '~/logic/lib/group';
|
||||
@ -143,10 +143,8 @@ export class GroupView extends Component<
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
const us = `~${window.ship}`;
|
||||
const role = roleForShip(this.props.group, window.ship);
|
||||
const resource = resourceFromPath(this.props.resourcePath);
|
||||
return resource.ship == us || role === 'admin';
|
||||
return role === 'admin';
|
||||
}
|
||||
|
||||
optionsForShip(ship: Patp, missing: GroupViewAppTag[]) {
|
||||
@ -334,14 +332,11 @@ export class GroupView extends Component<
|
||||
{'open' in group.policy && this.renderBanned(group.policy)}
|
||||
<div className='flex flex-column'>
|
||||
<div className='f9 gray2 mt6 mb3'>Members</div>
|
||||
<List
|
||||
height={500}
|
||||
itemCount={memberElements.length}
|
||||
itemSize={44}
|
||||
width="100%"
|
||||
>
|
||||
{({ index, style }) => <div key={index} style={style} className='flex flex-column pv3'>{memberElements[index]}</div>}
|
||||
</List>
|
||||
<VirtualList
|
||||
style={{ height: '500px', width: '100%' }}
|
||||
totalCount={memberElements.length}
|
||||
item={(index) => <div key={index} className='flex flex-column pv3'>{memberElements[index]}</div>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spinner
|
||||
|
@ -88,7 +88,7 @@ export function GroupSearch(props: InviteSearchProps) {
|
||||
caption={props.caption}
|
||||
candidates={groups}
|
||||
renderCandidate={renderCandidate}
|
||||
disabled={value.length !== 0}
|
||||
disabled={value && value.length !== 0}
|
||||
search={(s: string, a: Association) =>
|
||||
a.metadata.title.toLowerCase().startsWith(s.toLowerCase())
|
||||
}
|
||||
|
@ -1,96 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Row, Icon, Text } from '@tlon/indigo-react';
|
||||
import defaultApps from '~/logic/lib/default-apps';
|
||||
|
||||
export class OmniboxResult extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isSelected: false,
|
||||
hovered: false
|
||||
};
|
||||
this.setHover = this.setHover.bind(this);
|
||||
this.result = React.createRef();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
if (prevProps &&
|
||||
!state.hovered &&
|
||||
prevProps.selected !== props.selected &&
|
||||
props.selected === props.link
|
||||
) {
|
||||
this.result.current.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
setHover(boolean) {
|
||||
this.setState({ hovered: boolean });
|
||||
}
|
||||
render() {
|
||||
const { icon, text, subtext, link, navigate, selected, dark } = this.props;
|
||||
|
||||
let invertGraphic = {};
|
||||
|
||||
if (icon.toLowerCase() !== 'dojo') {
|
||||
invertGraphic = (!dark && this.state.hovered) ||
|
||||
selected === link ||
|
||||
(dark && !(this.state.hovered || selected === link))
|
||||
? { filter: 'invert(1)', paddingTop: 2 }
|
||||
: { filter: 'invert(0)', paddingTop: 2 };
|
||||
} else {
|
||||
invertGraphic =
|
||||
(!dark && this.state.hovered) ||
|
||||
selected === link ||
|
||||
(dark && !(this.state.hovered || selected === link))
|
||||
? { filter: 'invert(0)', paddingTop: 2 }
|
||||
: { filter: 'invert(1)', paddingTop: 2 };
|
||||
}
|
||||
|
||||
let graphic = <div />;
|
||||
if (defaultApps.includes(icon.toLowerCase()) || icon.toLowerCase() === 'links') {
|
||||
graphic = <img className="mr2 v-mid" height="12" width="12" src={`/~landscape/img/${icon.toLowerCase()}.png`} style={invertGraphic} />;
|
||||
} else {
|
||||
graphic = <Icon verticalAlign="middle" mr={2} size="12px" />;
|
||||
}
|
||||
return (
|
||||
<Row
|
||||
py='2'
|
||||
px='2'
|
||||
display='flex'
|
||||
flexDirection='row'
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={() => this.setHover(true)}
|
||||
onMouseLeave={() => this.setHover(false)}
|
||||
backgroundColor={
|
||||
this.state.hovered || selected === link ? 'blue' : 'white'
|
||||
}
|
||||
onClick={navigate}
|
||||
width="100%"
|
||||
ref={this.result}
|
||||
>
|
||||
{this.state.hovered || selected === link ? (
|
||||
<>
|
||||
{graphic}
|
||||
<Text color='white' mr='1' style={{ 'flex-shrink': 0 }}>
|
||||
{text}
|
||||
</Text>
|
||||
<Text pr='2' color='white' width='100%' textAlign='right'>
|
||||
{subtext}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{graphic}
|
||||
<Text mr='1' style={{ 'flex-shrink': 0 }}>{text}</Text>
|
||||
<Text pr='2' gray width='100%' textAlign='right'>
|
||||
{subtext}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OmniboxResult;
|
151
pkg/interface/src/views/components/RemoteContent.tsx
Normal file
151
pkg/interface/src/views/components/RemoteContent.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { LocalUpdateRemoteContentPolicy } from "~/types/local-update";
|
||||
import { Button } from '@tlon/indigo-react';
|
||||
import { hasProvider } from 'oembed-parser';
|
||||
import EmbedContainer from 'react-oembed-container';
|
||||
|
||||
interface RemoteContentProps {
|
||||
url: string;
|
||||
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
|
||||
unfold: boolean;
|
||||
renderUrl: boolean;
|
||||
imageProps: any;
|
||||
audioProps: any;
|
||||
videoProps: any;
|
||||
oembedProps: any;
|
||||
}
|
||||
|
||||
interface RemoteContentState {
|
||||
unfold: boolean;
|
||||
embed: any | undefined;
|
||||
}
|
||||
|
||||
const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
|
||||
const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i);
|
||||
const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
|
||||
|
||||
export default class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
unfold: props.unfold || false,
|
||||
embed: undefined
|
||||
};
|
||||
this.unfoldEmbed = this.unfoldEmbed.bind(this);
|
||||
this.loadOembed = this.loadOembed.bind(this);
|
||||
this.wrapInLink = this.wrapInLink.bind(this);
|
||||
}
|
||||
|
||||
unfoldEmbed() {
|
||||
let unfoldState = this.state.unfold;
|
||||
unfoldState = !unfoldState;
|
||||
this.setState({ unfold: unfoldState });
|
||||
}
|
||||
|
||||
loadOembed() {
|
||||
fetch(`https://noembed.com/embed?url=${this.props.url}`)
|
||||
.then(response => response.json())
|
||||
.then((result) => {
|
||||
this.setState({ embed: result });
|
||||
}).catch((error) => {
|
||||
this.setState({ embed: 'error' });
|
||||
console.log('error fetching oembed', error);
|
||||
});
|
||||
}
|
||||
|
||||
wrapInLink(contents) {
|
||||
return (<a
|
||||
href={this.props.url}
|
||||
className={`word-break-all ${(typeof contents === 'string') ? 'bb b--white-d b--black' : ''}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{contents}
|
||||
</a>);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
remoteContentPolicy,
|
||||
url,
|
||||
unfold = false,
|
||||
renderUrl = true,
|
||||
imageProps = {},
|
||||
audioProps = {},
|
||||
videoProps = {},
|
||||
oembedProps = {},
|
||||
...props
|
||||
} = this.props;
|
||||
const isImage = IMAGE_REGEX.test(url);
|
||||
const isAudio = AUDIO_REGEX.test(url);
|
||||
const isVideo = VIDEO_REGEX.test(url);
|
||||
const isOembed = hasProvider(url);
|
||||
|
||||
if (isImage && remoteContentPolicy.imageShown) {
|
||||
return this.wrapInLink(
|
||||
<img
|
||||
src={url}
|
||||
{...imageProps}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else if (isAudio && remoteContentPolicy.audioShown) {
|
||||
return (
|
||||
<>
|
||||
{renderUrl ? this.wrapInLink(url) : null}
|
||||
<audio
|
||||
controls
|
||||
className="db"
|
||||
src={url}
|
||||
{...audioProps}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (isVideo && remoteContentPolicy.videoShown) {
|
||||
return (
|
||||
<>
|
||||
{renderUrl ? this.wrapInLink(url) : null}
|
||||
<video
|
||||
controls
|
||||
className="db"
|
||||
src={url}
|
||||
{...videoProps}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (isOembed && remoteContentPolicy.oembedShown) {
|
||||
if (!this.state.embed) {
|
||||
this.loadOembed();
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{renderUrl ? this.wrapInLink(this.state.embed && this.state.embed.title ? this.state.embed.title : url) : null}
|
||||
{this.state.embed !== 'error' && !unfold ? <Button
|
||||
border={1}
|
||||
style={{ display: 'inline-flex', height: '1.66em' }} // Height is hacked to line-height until Button supports proper size
|
||||
ml={1}
|
||||
onClick={this.unfoldEmbed}
|
||||
>
|
||||
{this.state.unfold ? 'collapse' : 'expand'}
|
||||
</Button> : null}
|
||||
<div
|
||||
className={'embed-container mb2 w-100 w-75-l w-50-xl ' + (this.state.unfold ? 'db' : 'dn')}
|
||||
{...oembedProps}
|
||||
{...props}
|
||||
>
|
||||
{this.state.embed && this.state.embed.html && this.state.unfold
|
||||
? <EmbedContainer markup={this.state.embed.html}>
|
||||
<div dangerouslySetInnerHTML={{__html: this.state.embed.html}}></div>
|
||||
</EmbedContainer>
|
||||
: null}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
return renderUrl ? this.wrapInLink(url) : null;
|
||||
}
|
||||
}
|
||||
}
|
40
pkg/interface/src/views/components/RichText.js
Normal file
40
pkg/interface/src/views/components/RichText.js
Normal file
@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import RemoteContent from "~/views/components/RemoteContent";
|
||||
import { hasProvider } from 'oembed-parser';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
|
||||
|
||||
const DISABLED_BLOCK_TOKENS = [
|
||||
'indentedCode',
|
||||
'atxHeading',
|
||||
'thematicBreak',
|
||||
'list',
|
||||
'setextHeading',
|
||||
'html',
|
||||
'definition',
|
||||
'table'
|
||||
];
|
||||
|
||||
const DISABLED_INLINE_TOKENS = [];
|
||||
|
||||
const RichText = React.memo(({remoteContentPolicy, ...props}) => (
|
||||
<ReactMarkdown
|
||||
{...props}
|
||||
renderers={{
|
||||
link: (props) => {
|
||||
if (hasProvider(props.href)) {
|
||||
return <RemoteContent className="mw-100" url={props.href} remoteContentPolicy={remoteContentPolicy}/>;
|
||||
}
|
||||
return <a {...props} className="bb b--white-d b--black">{props.children}</a>
|
||||
},
|
||||
paragraph: (props) => {
|
||||
return <p {...props} className="mb2 lh-copy">{props.children}</p>
|
||||
}
|
||||
}}
|
||||
plugins={[[
|
||||
RemarkDisableTokenizers,
|
||||
{ block: DISABLED_BLOCK_TOKENS, inline: DISABLED_INLINE_TOKENS }
|
||||
]]} />
|
||||
));
|
||||
|
||||
export default RichText;
|
@ -1,24 +1,18 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
export class Spinner extends Component {
|
||||
render() {
|
||||
const classes = this.props.classes ? this.props.classes : '';
|
||||
const text = this.props.text ? this.props.text : '';
|
||||
const awaiting = this.props.awaiting ? this.props.awaiting : false;
|
||||
const Spinner = ({
|
||||
classes = '',
|
||||
text = '',
|
||||
awaiting = false
|
||||
}) => awaiting ? (
|
||||
<div className={classes + ' z-2 bg-white bg-gray0-d white-d flex'}>
|
||||
<img className="invert-d spin-active v-mid"
|
||||
src="/~landscape/img/Spinner.png"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<p className="dib f9 ml2 v-mid inter">{text}</p>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (awaiting) {
|
||||
return (
|
||||
<div className={classes + ' z-2 bg-white bg-gray0-d white-d'}>
|
||||
<img className="invert-d spin-active v-mid"
|
||||
src="/~landscape/img/Spinner.png"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<p className="dib f9 ml2 v-mid inter">{text}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
export { Spinner as default, Spinner };
|
@ -32,7 +32,6 @@ const StatusBar = (props) => {
|
||||
px={3}
|
||||
>
|
||||
<Row collapse>
|
||||
{atHome ? null : (
|
||||
<StatusBarItem mr={2} onClick={() => props.history.push('/')}>
|
||||
<img
|
||||
className='invert-d'
|
||||
@ -41,7 +40,6 @@ const StatusBar = (props) => {
|
||||
width='11'
|
||||
/>
|
||||
</StatusBarItem>
|
||||
)}
|
||||
<StatusBarItem mr={2} onClick={() => props.api.local.setOmnibox()}>
|
||||
<Text display='inline-block' style={{ transform: 'rotate(180deg)' }}>
|
||||
↩
|
||||
@ -53,7 +51,7 @@ const StatusBar = (props) => {
|
||||
{metaKey}/
|
||||
</Text>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem
|
||||
<StatusBarItem
|
||||
onClick={() => props.history.push('/~groups')}
|
||||
badge={Object.keys(invites).length > 0}>
|
||||
<img
|
||||
@ -72,7 +70,7 @@ const StatusBar = (props) => {
|
||||
<Row justifyContent="flex-end" collapse>
|
||||
<StatusBarItem onClick={() => props.history.push('/~profile')}>
|
||||
<Sigil ship={props.ship} size={24} color={"#000000"} classes="dib mix-blend-diff" />
|
||||
<Text ml={2} display={["none", "inline"]} fontFamily="mono">{props.ship}</Text>
|
||||
<Text ml={2} display={["none", "inline"]} fontFamily="mono">~{props.ship}</Text>
|
||||
</StatusBarItem>
|
||||
</Row>
|
||||
</Box>
|
||||
|
@ -1,10 +1,11 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export const ChatTabBar = (props) => {
|
||||
export const TabBar = (props) => {
|
||||
const {
|
||||
location,
|
||||
station
|
||||
settings,
|
||||
popoutHref
|
||||
} = props;
|
||||
let setColor = '', popout = '';
|
||||
|
||||
@ -22,11 +23,11 @@ export const ChatTabBar = (props) => {
|
||||
<div className={'dib pt2 f9 pl6 pr6 lh-solid'}>
|
||||
<Link
|
||||
className={'no-underline ' + setColor}
|
||||
to={'/~chat/' + popout + 'settings' + station}>
|
||||
to={settings}>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
<a href={'/~chat/popout/room' + station} rel="noopener noreferrer"
|
||||
<a href={popoutHref} rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="dib fr pr1"
|
||||
style={{ paddingTop: '8px' }}>
|
||||
@ -38,4 +39,4 @@ export const ChatTabBar = (props) => {
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
@ -6,7 +6,7 @@ import Mousetrap from 'mousetrap';
|
||||
import OmniboxInput from './OmniboxInput';
|
||||
import OmniboxResult from './OmniboxResult';
|
||||
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import defaultApps from '~/logic/lib/default-apps';
|
||||
|
||||
export class Omnibox extends Component {
|
||||
constructor(props) {
|
||||
@ -15,7 +15,7 @@ export class Omnibox extends Component {
|
||||
index: new Map([]),
|
||||
query: '',
|
||||
results: this.initialResults(),
|
||||
selected: ''
|
||||
selected: []
|
||||
};
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
this.search = this.search.bind(this);
|
||||
@ -26,11 +26,15 @@ export class Omnibox extends Component {
|
||||
this.renderResults = this.renderResults.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps !== this.props) {
|
||||
this.setState({ index: index(this.props.associations, this.props.apps.tiles) });
|
||||
}
|
||||
|
||||
if (prevProps && (prevProps.apps !== this.props.apps) && (this.state.query === '')) {
|
||||
this.setState({ results: this.initialResults() });
|
||||
}
|
||||
|
||||
if (prevProps && this.props.show && prevProps.show !== this.props.show) {
|
||||
Mousetrap.bind('escape', () => this.props.api.local.setOmnibox());
|
||||
document.addEventListener('mousedown', this.handleClickOutside);
|
||||
@ -48,7 +52,7 @@ export class Omnibox extends Component {
|
||||
}
|
||||
|
||||
getSearchedCategories() {
|
||||
return ['apps', 'commands', 'groups', 'subscriptions'];
|
||||
return ['apps', 'commands', 'groups', 'subscriptions', 'other'];
|
||||
}
|
||||
|
||||
control(evt) {
|
||||
@ -75,9 +79,11 @@ export class Omnibox extends Component {
|
||||
if (evt.key === 'Enter') {
|
||||
evt.preventDefault();
|
||||
if (this.state.selected !== '') {
|
||||
this.navigate(this.state.selected);
|
||||
this.navigate(this.state.selected[0], this.state.selected[1]);
|
||||
} else {
|
||||
this.navigate(Array.from(this.state.results.values()).flat()[0].link);
|
||||
this.navigate(
|
||||
Array.from(this.state.results.values()).flat()[0].app,
|
||||
Array.from(this.state.results.values()).flat()[0].link);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -91,14 +97,29 @@ export class Omnibox extends Component {
|
||||
}
|
||||
|
||||
initialResults() {
|
||||
return new Map(this.getSearchedCategories().map(category => [category, []]));
|
||||
return new Map(this.getSearchedCategories().map((category) => {
|
||||
if (!this.state) {
|
||||
return [category, []];
|
||||
}
|
||||
if (category === 'apps') {
|
||||
return ['apps', this.state.index.get('apps')];
|
||||
}
|
||||
if (category === 'other') {
|
||||
return ['other', this.state.index.get('other')];
|
||||
}
|
||||
return [category, []];
|
||||
}));
|
||||
}
|
||||
|
||||
navigate(link) {
|
||||
navigate(app, link) {
|
||||
const { props } = this;
|
||||
this.setState({ results: this.initialResults(), query: '' }, () => {
|
||||
props.api.local.setOmnibox();
|
||||
props.history.push(link);
|
||||
if (defaultApps.includes(app.toLowerCase()) || app === 'profile') {
|
||||
props.history.push(link);
|
||||
} else {
|
||||
window.location.href = link;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -152,19 +173,22 @@ export class Omnibox extends Component {
|
||||
if (current !== '') {
|
||||
const currentIndex = flattenedResults.indexOf(
|
||||
...flattenedResults.filter((e) => {
|
||||
return e.link === current;
|
||||
return e.link === current[1];
|
||||
})
|
||||
);
|
||||
if (currentIndex > 0) {
|
||||
const nextApp = flattenedResults[currentIndex - 1].app;
|
||||
const nextLink = flattenedResults[currentIndex - 1].link;
|
||||
this.setState({ selected: nextLink });
|
||||
this.setState({ selected: [nextApp, nextLink] });
|
||||
} else {
|
||||
const nextApp = flattenedResults[totalLength - 1].app;
|
||||
const nextLink = flattenedResults[totalLength - 1].link;
|
||||
this.setState({ selected: nextLink });
|
||||
this.setState({ selected: [nextApp, nextLink] });
|
||||
}
|
||||
} else {
|
||||
const nextApp = flattenedResults[totalLength - 1].app;
|
||||
const nextLink = flattenedResults[totalLength - 1].link;
|
||||
this.setState({ selected: nextLink });
|
||||
this.setState({ selected: [nextApp, nextLink] });
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,19 +198,22 @@ export class Omnibox extends Component {
|
||||
if (current !== '') {
|
||||
const currentIndex = flattenedResults.indexOf(
|
||||
...flattenedResults.filter((e) => {
|
||||
return e.link === current;
|
||||
return e.link === current[1];
|
||||
})
|
||||
);
|
||||
if (currentIndex < flattenedResults.length - 1) {
|
||||
const nextApp = flattenedResults[currentIndex + 1].app;
|
||||
const nextLink = flattenedResults[currentIndex + 1].link;
|
||||
this.setState({ selected: nextLink });
|
||||
this.setState({ selected: [nextApp, nextLink] });
|
||||
} else {
|
||||
const nextApp = flattenedResults[0].app;
|
||||
const nextLink = flattenedResults[0].link;
|
||||
this.setState({ selected: nextLink });
|
||||
this.setState({ selected: [nextApp, nextLink] });
|
||||
}
|
||||
} else {
|
||||
const nextApp = flattenedResults[0].app;
|
||||
const nextLink = flattenedResults[0].link;
|
||||
this.setState({ selected: nextLink });
|
||||
this.setState({ selected: [nextApp, nextLink] });
|
||||
}
|
||||
}
|
||||
|
||||
@ -202,30 +229,33 @@ export class Omnibox extends Component {
|
||||
{this.getSearchedCategories()
|
||||
.map(category => Object({ category, categoryResults: state.results.get(category) }))
|
||||
.filter(category => category.categoryResults.length > 0)
|
||||
.map(({ category, categoryResults }, i) => (
|
||||
<Box key={i} width='max(50vw, 300px)' maxWidth='600px'>
|
||||
.map(({ category, categoryResults }, i) => {
|
||||
const categoryTitle = (category === 'other')
|
||||
? null : <Text gray ml={2}>{category.charAt(0).toUpperCase() + category.slice(1)}</Text>;
|
||||
return (<Box key={i} width='max(50vw, 300px)' maxWidth='600px'>
|
||||
<Rule borderTopWidth="0.5px" color="washedGray" />
|
||||
<Text gray ml={2}>{category.charAt(0).toUpperCase() + category.slice(1)}</Text>
|
||||
{categoryTitle}
|
||||
{categoryResults.map((result, i2) => (
|
||||
<OmniboxResult
|
||||
key={i2}
|
||||
icon={result.app}
|
||||
text={result.title}
|
||||
subtext={cite(result.host)}
|
||||
subtext={result.host}
|
||||
link={result.link}
|
||||
navigate={() => this.navigate(result.link)}
|
||||
selected={this.state.selected}
|
||||
navigate={() => this.navigate(result.app, result.link)}
|
||||
selected={this.state.selected[1]}
|
||||
dark={props.dark} />
|
||||
))}
|
||||
</Box>
|
||||
))
|
||||
);
|
||||
})
|
||||
}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
if (!state.selected && Array.from(this.state.results.values()).flat().length) {
|
||||
if (state?.selected.length === 0 && Array.from(this.state.results.values()).flat().length) {
|
||||
this.setNextSelected();
|
||||
}
|
||||
return (
|
147
pkg/interface/src/views/components/leap/OmniboxResult.js
Normal file
147
pkg/interface/src/views/components/leap/OmniboxResult.js
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Row, Icon, Text } from '@tlon/indigo-react';
|
||||
import defaultApps from '~/logic/lib/default-apps';
|
||||
import Sigil from '~/logic/lib/sigil';
|
||||
|
||||
export class OmniboxResult extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isSelected: false,
|
||||
hovered: false
|
||||
};
|
||||
this.setHover = this.setHover.bind(this);
|
||||
this.result = React.createRef();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
if (prevProps &&
|
||||
!state.hovered &&
|
||||
prevProps.selected !== props.selected &&
|
||||
props.selected === props.link
|
||||
) {
|
||||
this.result.current.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
getIcon(icon, dark, selected, link) {
|
||||
// graphicStyle is only necessary for pngs
|
||||
//
|
||||
//TODO can be removed after indigo-react 1.2
|
||||
//which includes icons for apps
|
||||
let graphicStyle = {};
|
||||
|
||||
if (icon.toLowerCase() !== 'dojo') {
|
||||
graphicStyle = (!dark && this.state.hovered) ||
|
||||
selected === link ||
|
||||
(dark && !(this.state.hovered || selected === link))
|
||||
? { filter: 'invert(1)' }
|
||||
: { filter: 'invert(0)' };
|
||||
} else {
|
||||
graphicStyle =
|
||||
(!dark && this.state.hovered) ||
|
||||
selected === link ||
|
||||
(dark && !(this.state.hovered || selected === link))
|
||||
? { filter: 'invert(0)' }
|
||||
: { filter: 'invert(1)' };
|
||||
}
|
||||
|
||||
const iconFill = this.state.hovered || selected === link ? 'white' : 'black';
|
||||
const sigilFill = this.state.hovered || selected === link ? '#3a8ff7' : '#ffffff';
|
||||
|
||||
let graphic = <div />;
|
||||
if (defaultApps.includes(icon.toLowerCase()) || icon.toLowerCase() === 'links') {
|
||||
graphic =
|
||||
<img className="mr2 v-mid dib" height="16"
|
||||
width="16" src={`/~landscape/img/${icon.toLowerCase()}.png`}
|
||||
style={graphicStyle}
|
||||
/>;
|
||||
} else if (icon === 'logout') {
|
||||
graphic = <Icon display="inline-block" verticalAlign="middle" icon='ArrowWest' mr='2' size='16px' fill={iconFill} />;
|
||||
} else if (icon === 'profile') {
|
||||
graphic = <Sigil color={sigilFill} classes='dib v-mid mr2' ship={window.ship} size={16} />;
|
||||
} else {
|
||||
graphic = <Icon verticalAlign="middle" mr='2' size="16px" fill={iconFill} />;
|
||||
}
|
||||
|
||||
return graphic;
|
||||
}
|
||||
|
||||
setHover(boolean) {
|
||||
this.setState({ hovered: boolean });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { icon, text, subtext, link, navigate, selected, dark } = this.props;
|
||||
|
||||
const graphic = this.getIcon(icon, dark, selected, link);
|
||||
|
||||
return (
|
||||
<Row
|
||||
py='2'
|
||||
px='2'
|
||||
display='flex'
|
||||
flexDirection='row'
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={() => this.setHover(true)}
|
||||
onMouseLeave={() => this.setHover(false)}
|
||||
backgroundColor={
|
||||
this.state.hovered || selected === link ? 'blue' : 'white'
|
||||
}
|
||||
onClick={navigate}
|
||||
width="100%"
|
||||
ref={this.result}
|
||||
>
|
||||
{this.state.hovered || selected === link ? (
|
||||
<>
|
||||
{graphic}
|
||||
<Text
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
color='white'
|
||||
maxWidth="60%"
|
||||
style={{ 'flex-shrink': 0 }}
|
||||
mr='1'>
|
||||
{text}
|
||||
</Text>
|
||||
<Text pr='2'
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
color='white'
|
||||
width='100%'
|
||||
textAlign='right'
|
||||
>
|
||||
{subtext}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{graphic}
|
||||
<Text
|
||||
mr='1'
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
maxWidth="60%"
|
||||
style={{ 'flex-shrink': 0 }}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
<Text
|
||||
pr='2'
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
gray
|
||||
width='100%'
|
||||
textAlign='right'
|
||||
>
|
||||
{subtext}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OmniboxResult;
|
@ -1,10 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
|
||||
|
||||
export class MetadataColor extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -45,7 +41,7 @@ export class MetadataColor extends Component {
|
||||
return (
|
||||
<div className={'cf w-100 mb3 ' + ((props.isDisabled) ? 'o-30' : '')}>
|
||||
<p className="f8 lh-copy">Change color</p>
|
||||
<p className="f9 gray2 db mb4">Give this chat a color when viewing group channels</p>
|
||||
<p className="f9 gray2 db mb4">Give this {props.resource} a color when viewing group channels</p>
|
||||
<div className="relative w-100 flex"
|
||||
style={{ maxWidth: '10rem' }}
|
||||
>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user