Merge branch 'release/next-userspace'

This commit is contained in:
Matilde Park 2020-09-08 20:32:02 -04:00
commit c7f8af1fd2
103 changed files with 2995 additions and 2313 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,23 @@
:: lib/pull-hook: helper for creating a push hook
::
:: lib/pull-hook is a helper for automatically pulling data from a
:: corresponding push-hook to a store.
::
:: ## Interfacing notes:
::
:: The inner door may interact with the library by producing cards.
:: Do not pass any cards on a wire beginning with /helper as these
:: wires are reserved by this library. Any watches/pokes/peeks not
:: listed below will be routed to the inner door.
::
:: ## Subscription paths
::
:: /tracking: The set of resources we are pulling
::
:: ## Pokes
::
:: %pull-hook-action: Add/remove a resource from pulling.
::
/- *pull-hook
/+ default-agent, resource
::
@ -5,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)

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { Cage } from '~/types/cage';
import ContactReducer from '../reducers/contact-update';
import LinkUpdateReducer from '../reducers/link-update';
import S3Reducer from '../reducers/s3-update';
import { GraphReducer } from '../reducers/graph-update';
import GroupReducer from '../reducers/group-update';
import PermissionReducer from '../reducers/permission-update';
import PublishUpdateReducer from '../reducers/publish-update';
@ -53,6 +54,12 @@ export default class GlobalStore extends BaseStore<StoreState> {
suspendedFocus: null,
baseHash: null,
background: undefined,
remoteContentPolicy: {
imageShown: true,
audioShown: true,
videoShown: true,
oembedShown: true,
},
hideAvatars: false,
hideNicknames: false,
invites: {},
@ -64,6 +71,8 @@ export default class GlobalStore extends BaseStore<StoreState> {
},
groups: {},
groupKeys: new Set(),
graphs: {},
graphKeys: new Set(),
launch: {
firstTime: false,
tileOrdering: [],
@ -106,5 +115,6 @@ export default class GlobalStore extends BaseStore<StoreState> {
this.launchReducer.reduce(data, this.state);
this.linkListenReducer.reduce(data, this.state);
this.connReducer.reduce(data, this.state);
GraphReducer(data, this.state);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,13 +8,15 @@ import { ChatHeader } from './lib/chat-header';
import { ChatInput } from "./lib/chat-input";
import { deSig } from "~/logic/lib/util";
import { ChatHookUpdate } from "~/types/chat-hook-update";
import ChatApi from "~/logic/api/chat";
import { Inbox, Envelope } from "~/types/chat-update";
import { Contacts } from "~/types/contact-update";
import { Path, Patp } from "~/types/noun";
import GlobalApi from "~/logic/api/global";
import { Association } from "~/types/metadata-update";
import {Group} from "~/types/group-update";
import { LocalUpdateRemoteContentPolicy } from "~/types";
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload';
import { IUnControlledCodeMirror } from "react-codemirror2";
type ChatScreenProps = RouteComponentProps<{
@ -26,7 +28,7 @@ type ChatScreenProps = RouteComponentProps<{
association: Association;
api: GlobalApi;
read: number;
length: number;
mailboxSize: number;
inbox: Inbox;
contacts: Contacts;
group: Group;
@ -38,13 +40,16 @@ type ChatScreenProps = RouteComponentProps<{
envelopes: Envelope[];
hideAvatars: boolean;
hideNicknames: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
};
interface ChatScreenState {
messages: Map<string, string>;
dragover: boolean;
}
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
private chatInput: React.RefObject<ChatInput>;
lastNumPending = 0;
activityTimeout: NodeJS.Timeout | null = null;
@ -53,8 +58,11 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
this.state = {
messages: new Map(),
dragover: false,
};
this.chatInput = React.createRef();
moment.updateLocale("en", {
calendar: {
sameDay: "[Today]",
@ -67,6 +75,26 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
});
}
readyToUpload(): boolean {
return Boolean(this.chatInput.current?.s3Uploader.current?.inputRef.current);
}
onDragEnter() {
if (!this.readyToUpload()) {
return;
}
this.setState({ dragover: true });
}
onDrop(event: DragEvent) {
this.setState({ dragover: false });
if (!event.dataTransfer || !event.dataTransfer.files.length) {
return;
}
event.preventDefault();
this.chatInput.current?.uploadFiles(event.dataTransfer.files);
}
render() {
const { props, state } = this;
@ -97,42 +125,36 @@ export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
!(props.station in props.chatSynced) &&
props.envelopes.length > 0;
const unreadCount = props.length - props.read;
const unreadCount = props.mailboxSize - props.read;
const unreadMsg = unreadCount > 0 && props.envelopes[unreadCount - 1];
return (
<div
key={props.station}
className="h-100 w-100 overflow-hidden flex flex-column relative">
<ChatHeader
match={props.match}
location={props.location}
api={props.api}
group={props.group}
association={props.association}
station={props.station}
sidebarShown={props.sidebarShown}
popout={props.popout} />
className="h-100 w-100 overflow-hidden flex flex-column relative"
onDragEnter={this.onDragEnter.bind(this)}
onDragOver={event => {
event.preventDefault();
if (!this.state.dragover) {
this.setState({ dragover: true });
}
}}
onDragLeave={() => this.setState({ dragover: false })}
onDrop={this.onDrop.bind(this)}
>
{this.state.dragover ? <SubmitDragger /> : null}
<ChatHeader {...props} />
<ChatWindow
history={props.history}
isChatMissing={isChatMissing}
isChatLoading={isChatLoading}
isChatUnsynced={isChatUnsynced}
unreadCount={unreadCount}
unreadMsg={unreadMsg}
pendingMessages={pendingMessages}
messages={props.envelopes}
length={props.length}
contacts={props.contacts}
association={props.association}
group={props.group}
stationPendingMessages={pendingMessages}
ship={props.match.params.ship}
station={props.station}
api={props.api}
hideNicknames={props.hideNicknames}
hideAvatars={props.hideAvatars}
/>
{...props} />
<ChatInput
ref={this.chatInput}
api={props.api}
numMsgs={lastMsgNum}
station={props.station}
@ -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>
);

View File

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

View File

@ -1,58 +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>
);
}
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,54 +1,140 @@
import React, { Component, Fragment } from "react";
import { Virtuoso as VirtualList, VirtuosoMethods } from 'react-virtuoso';
import { ChatMessage } from './chat-message';
import { ChatScrollContainer } from "./chat-scroll-container";
import { UnreadNotice } from "./unread-notice";
import { ResubscribeElement } from "./resubscribe-element";
import { BacklogElement } from "./backlog-element";
import { Envelope, IMessage } from "~/types/chat-update";
import { RouteComponentProps } from "react-router-dom";
import { Patp, Path } from "~/types/noun";
import { Contacts } from "~/types/contact-update";
import { Association } from "~/types/metadata-update";
import { Group } from "~/types/group-update";
import GlobalApi from "~/logic/api/global";
import _ from "lodash";
import { LocalUpdateRemoteContentPolicy } from "~/types";
import { ListRange } from "react-virtuoso/dist/engines/scrollSeekEngine";
const MAX_BACKLOG_SIZE = 1000;
const DEFAULT_BACKLOG_SIZE = 200;
const PAGE_SIZE = 50;
const INITIAL_LOAD = 20;
const DEFAULT_BACKLOG_SIZE = 200;
const IDLE_THRESHOLD = 3;
const Placeholder = ({ height, index, className = '', style = {}, ...props }) => (
<div className={`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ${className}`} style={{ height, ...style }} {...props}>
<div className="fl pr3 v-top bg-white bg-gray0-d">
<span
className="db bg-gray2 bg-white-d"
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
visibility: (index % 5 == 0) ? "initial" : "hidden",
}}
></span>
</div>
<div className="fr clamp-message white-d" style={{ flexGrow: 1, marginTop: -8 }}>
<div className="hide-child" style={{paddingTop: "6px", visibility: (index % 5 == 0) ? "initial" : "hidden" }}>
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
<span className="mw5 db"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></span>
</p>
<p className="v-mid mono f9 gray2 dib"><span className="bg-gray5 bg-gray1-d db w-100 h-100" style={{height: "1em", width: `${(index % 3 + 1) * 3}em`}}></span></p>
<p className="v-mid mono f9 ml2 gray2 dib child dn-s"><span className="bg-gray5 bg-gray1-d db w-100 h-100"></span></p>
</div>
<span className="bg-gray5 bg-gray1-d db w-100 h-100 db" style={{height: `1em`, width: `${(index % 5) * 20}%`}}></span>
</div>
</div>
);
export class ChatWindow extends Component {
type ChatWindowProps = RouteComponentProps<{
ship: Patp;
station: string;
}> & {
unreadCount: number;
envelopes: Envelope[];
isChatMissing: boolean;
isChatLoading: boolean;
isChatUnsynced: boolean;
unreadMsg: Envelope | false;
stationPendingMessages: IMessage[];
mailboxSize: number;
contacts: Contacts;
association: Association;
group: Group;
ship: Patp;
station: any;
api: GlobalApi;
hideNicknames: boolean;
hideAvatars: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
}
interface ChatWindowState {
fetchPending: boolean;
idle: boolean;
range: ListRange;
initialized: boolean;
}
export class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
private unreadReference: React.RefObject<Component>;
private virtualList: React.RefObject<VirtuosoMethods>;
constructor(props) {
super(props);
this.state = {
numPages: 1,
};
this.hasAskedForMessages = false;
this.state = {
fetchPending: false,
idle: (this.initialIndex() < props.mailboxSize - IDLE_THRESHOLD) ? true : false,
range: { startIndex: 0, endIndex: 0},
initialized: false
};
this.dismissUnread = this.dismissUnread.bind(this);
this.scrollIsAtBottom = this.scrollIsAtBottom.bind(this);
this.scrollIsAtTop = this.scrollIsAtTop.bind(this);
this.initialIndex = this.initialIndex.bind(this);
this.scrollToUnread = this.scrollToUnread.bind(this);
this.scrollReference = React.createRef();
this.unreadReference = React.createRef();
this.virtualList = React.createRef();
}
componentDidMount() {
this.initialFetch();
}
if (this.state.numPages === 1 && this.props.unreadCount < INITIAL_LOAD) {
this.dismissUnread();
this.scrollToBottom();
}
initialIndex() {
const { mailboxSize, unreadCount } = this.props;
return Math.min(Math.max(mailboxSize - 1 < INITIAL_LOAD
? 0
: unreadCount // otherwise if there are unread messages
? mailboxSize - unreadCount - 1 // put the one right before at the top
: mailboxSize - 1,
0), mailboxSize);
}
initialFetch() {
const { props } = this;
if (props.messages.length > 0) {
const unreadUnloaded = props.unreadCount - props.messages.length;
if (unreadUnloaded <= MAX_BACKLOG_SIZE &&
unreadUnloaded + INITIAL_LOAD > DEFAULT_BACKLOG_SIZE) {
this.fetchBacklog(unreadUnloaded + INITIAL_LOAD);
} else {
this.fetchBacklog(DEFAULT_BACKLOG_SIZE);
const { envelopes, mailboxSize, unreadCount } = this.props;
if (envelopes.length > 0) {
const start = Math.min(mailboxSize - unreadCount, mailboxSize - DEFAULT_BACKLOG_SIZE);
this.fetchMessages(start, start + DEFAULT_BACKLOG_SIZE, true);
const initialIndex = this.initialIndex();
if (initialIndex < mailboxSize - IDLE_THRESHOLD) {
this.setState({ idle: true });
}
if (unreadCount !== mailboxSize) {
this.virtualList.current?.scrollToIndex({
index: initialIndex,
align: initialIndex <= 1 ? 'end' : 'start'
});
setTimeout(() => {
this.setState({ initialized: true });
}, 500);
} else {
this.setState({ initialized: true });
}
} else {
setTimeout(() => {
this.initialFetch();
@ -57,140 +143,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>
);
}

View File

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

View File

@ -1,51 +1,54 @@
import React, { Component } from 'react';
import React, { memo } from 'react';
export const DeleteButton = memo(({ isOwner, station, changeLoading, association, contacts, api }) => {
const leaveButtonClasses = (!isOwner) ? 'pointer' : 'c-default';
const deleteButtonClasses = (isOwner) ?
'b--red2 red2 pointer bg-gray0-d' :
'b--gray3 gray3 bg-gray0-d c-default';
export const DeleteButton = (props) => {
const { isOwner, station, changeLoading, api } = props;
const leaveButtonClasses = (!isOwner) ? 'pointer' : 'c-default';
const deleteButtonClasses = (isOwner) ?
'b--red2 red2 pointer bg-gray0-d' :
'b--gray3 gray3 bg-gray0-d c-default';
const deleteChat = () => {
changeLoading(
true,
true,
isOwner ? 'Deleting chat...' : 'Leaving chat...',
() => {
api.chat.delete(station);
}
);
};
return (
<div className="w-100 cf">
<div className={'w-100 fl mt3 ' + ((isOwner) ? 'o-30' : '')}>
<p className="f8 mt3 lh-copy db">Leave Chat</p>
<p className="f9 gray2 db mb4">
Remove this chat from your chat list.{' '}
You will need to request for access again.
</p>
<a onClick={(!isOwner) ? deleteChat : null}
className={
'dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d ' +
leaveButtonClasses
}>
Leave this chat
</a>
</div>
<div className={'w-100 fl mt3 ' + ((!isOwner) ? 'o-30' : '')}>
<p className="f8 mt3 lh-copy db">Delete Chat</p>
<p className="f9 gray2 db mb4">
Permanently delete this chat.{' '}
All current members will no longer see this chat.
</p>
<a onClick={(isOwner) ? deleteChat : null}
className={'dib f9 ba pa2 ' + deleteButtonClasses}
>Delete this chat</a>
</div>
</div>
const deleteChat = () => {
changeLoading(
true,
true,
isOwner ? 'Deleting chat...' : 'Leaving chat...',
() => {
api.chat.delete(station);
}
);
};
};
const groupPath = association['group-path'];
const unmanagedVillage = !contacts[groupPath];
return (
<div className="w-100 cf">
<div className={'w-100 fl mt3 ' + ((isOwner) ? 'o-30' : '')}>
<p className="f8 mt3 lh-copy db">Leave Chat</p>
<p className="f9 gray2 db mb4">
Remove this chat from your chat list.{' '}
{unmanagedVillage
? 'You will need to request for access again'
: 'You will need to join again from the group page.'
}
</p>
<a onClick={(!isOwner) ? deleteChat : null}
className={
'dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d ' +
leaveButtonClasses
}>
Leave this chat
</a>
</div>
<div className={'w-100 fl mt3 ' + ((!isOwner) ? 'o-30' : '')}>
<p className="f8 mt3 lh-copy db">Delete Chat</p>
<p className="f9 gray2 db mb4">
Permanently delete this chat.{' '}
All current members will no longer see this chat.
</p>
<a onClick={(isOwner) ? deleteChat : null}
className={'dib f9 ba pa2 ' + deleteButtonClasses}
>Delete this chat</a>
</div>
</div>
);
})

View File

@ -1,11 +1,13 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { ChannelItem } from './channel-item';
import { deSig, cite } from "~/logic/lib/util";
export class GroupItem extends Component {
render() {
const { props } = this;
const association = props.association ? props.association : {};
const DEFAULT_TITLE_REGEX = new RegExp(`(( <-> )?~(?:${window.ship}|${deSig(cite(window.ship))})( <-> )?)`);
let title = association['app-path'] ? association['app-path'] : 'Direct Messages';
if (association.metadata && association.metadata.title) {
@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { Spinner } from '~/views/components/Spinner';
import { Link } from 'react-router-dom';
import { InviteSearch } from '~/views/components/InviteSearch';
import urbitOb from 'urbit-ob';
import { deSig } from '~/logic/lib/util';
import { deSig, cite } from '~/logic/lib/util';
export class NewDmScreen extends Component {
constructor(props) {
@ -91,7 +91,7 @@ export class NewDmScreen extends Component {
const aud = state.ship !== window.ship ? [`~${state.ships[0]}`] : [];
let title = `~${window.ship} <-> ~${state.ships[0]}`;
let title = `${cite(window.ship)} <-> ${cite(state.ships[0])}`;
if (state.title !== '') {
title = state.title;

View File

@ -1,15 +1,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}

View File

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

View File

@ -5,7 +5,7 @@ import { Link } from 'react-router-dom';
import { EditElement } from './edit-element';
import { Spinner } from '~/views/components/Spinner';
import { uxToHex } from '~/logic/lib/util';
import { S3Upload } from './s3-upload';
import { S3Upload } from '~/views/components/s3-upload';
export class ContactCard extends Component {
constructor(props) {
@ -492,7 +492,15 @@ export class ContactCard extends Component {
credentials={props.s3.credentials}
uploadSuccess={this.uploadSuccess.bind(this)}
uploadError={this.uploadError.bind(this)}
/>
accept="image/*"
>
<img
className="invert-d"
src="/~chat/img/ImageUpload.png"
width="32"
height="32"
/>
</S3Upload>
</span>
<EditElement
className="fr w-80"

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { FixedSizeList as List } from 'react-window';
import { Virtuoso as VirtualList } from 'react-virtuoso';
import { ContactItem } from './contact-item';
import { ShareSheet } from './share-sheet';
@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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