diff --git a/pkg/arvo/app/chat-view.hoon b/pkg/arvo/app/chat-view.hoon index 115b07648a..f12a64edbc 100644 --- a/pkg/arvo/app/chat-view.hoon +++ b/pkg/arvo/app/chat-view.hoon @@ -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] == diff --git a/pkg/arvo/app/dbug.hoon b/pkg/arvo/app/dbug.hoon index 5b2d517b2a..80c5fc1406 100644 --- a/pkg/arvo/app/dbug.hoon +++ b/pkg/arvo/app/dbug.hoon @@ -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?} diff --git a/pkg/arvo/app/graph-store.hoon b/pkg/arvo/app/graph-store.hoon index 8197f439ab..b30c6f7b4d 100644 --- a/pkg/arvo/app/graph-store.hoon +++ b/pkg/arvo/app/graph-store.hoon @@ -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 @ @ ~] diff --git a/pkg/arvo/app/language-server.hoon b/pkg/arvo/app/language-server.hoon index 3ad37f5166..0b2e5c31e4 100644 --- a/pkg/arvo/app/language-server.hoon +++ b/pkg/arvo/app/language-server.hoon @@ -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 diff --git a/pkg/arvo/app/lens.hoon b/pkg/arvo/app/lens.hoon index 73004cb27b..8c86f8f8d6 100644 --- a/pkg/arvo/app/lens.hoon +++ b/pkg/arvo/app/lens.hoon @@ -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 diff --git a/pkg/arvo/app/publish.hoon b/pkg/arvo/app/publish.hoon index 2ec74fb1ca..cf0654df80 100644 --- a/pkg/arvo/app/publish.hoon +++ b/pkg/arvo/app/publish.hoon @@ -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) == :: -- diff --git a/pkg/arvo/lib/graph-store.hoon b/pkg/arvo/lib/graph-store.hoon index bf099c89f6..4ed56c4a72 100644 --- a/pkg/arvo/lib/graph-store.hoon +++ b/pkg/arvo/lib/graph-store.hoon @@ -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 diff --git a/pkg/arvo/lib/pull-hook.hoon b/pkg/arvo/lib/pull-hook.hoon index e5d2fabac0..ce4954bb6c 100644 --- a/pkg/arvo/lib/pull-hook.hoon +++ b/pkg/arvo/lib/pull-hook.hoon @@ -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) diff --git a/pkg/arvo/lib/push-hook.hoon b/pkg/arvo/lib/push-hook.hoon index 776aa0f597..bbe6f73c66 100644 --- a/pkg/arvo/lib/push-hook.hoon +++ b/pkg/arvo/lib/push-hook.hoon @@ -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 diff --git a/pkg/arvo/lib/server.hoon b/pkg/arvo/lib/server.hoon index d51fa12c8d..1f450c3e50 100644 --- a/pkg/arvo/lib/server.hoon +++ b/pkg/arvo/lib/server.hoon @@ -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 diff --git a/pkg/arvo/ted/diff.hoon b/pkg/arvo/ted/diff.hoon new file mode 100644 index 0000000000..cd9cdc796a --- /dev/null +++ b/pkg/arvo/ted/diff.hoon @@ -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) +-- diff --git a/pkg/interface/package-lock.json b/pkg/interface/package-lock.json index 921fcbadf0..f379ea40f2 100644 --- a/pkg/interface/package-lock.json +++ b/pkg/interface/package-lock.json @@ -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", diff --git a/pkg/interface/package.json b/pkg/interface/package.json index 65a6a0181c..45e644e8b4 100644 --- a/pkg/interface/package.json +++ b/pkg/interface/package.json @@ -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", diff --git a/pkg/interface/src/logic/api/chat.ts b/pkg/interface/src/logic/api/chat.ts index bd16529cfc..ad6e5029d0 100644 --- a/pkg/interface/src/logic/api/chat.ts +++ b/pkg/interface/src/logic/api/chat.ts @@ -12,7 +12,7 @@ export default class ChatApi extends BaseApi { * 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({ diff --git a/pkg/interface/src/logic/api/global.ts b/pkg/interface/src/logic/api/global.ts index 7adc461662..d724e0073c 100644 --- a/pkg/interface/src/logic/api/global.ts +++ b/pkg/interface/src/logic/api/global.ts @@ -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 { @@ -24,10 +25,15 @@ export default class GlobalApi extends BaseApi { 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); } } diff --git a/pkg/interface/src/logic/api/graph.ts b/pkg/interface/src/logic/api/graph.ts new file mode 100644 index 0000000000..d583322021 --- /dev/null +++ b/pkg/interface/src/logic/api/graph.ts @@ -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 { + + private storeAction(action: any): Promise { + 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('graph-store', '/keys') + .then((keys) => { + this.store.handleEvent({ + data: keys + }); + }); + } + + getTags() { + this.scry('graph-store', '/tags') + .then((tags) => { + this.store.handleEvent({ + data: tags + }); + }); + } + + getTagQueries() { + this.scry('graph-store', '/tag-queries') + .then((tagQueries) => { + this.store.handleEvent({ + data: tagQueries + }); + }); + } + + getGraph(ship: string, resource: string) { + this.scry('graph-store', `/graph/${ship}/${resource}`) + .then((graph) => { + this.store.handleEvent({ + data: graph + }); + }); + } + + getGraphSubset(ship: string, resource: string, start: string, end: start) { + this.scry( + 'graph-store', + `/graph-subset/${ship}/${resource}/${end}/${start}` + ).then((subset) => { + this.store.handleEvent({ + data: subset + }); + }); + } + + getNode(ship: string, resource: string, index: string) { + this.scry( + 'graph-store', + `/node/${ship}/${resource}/${index}` + ).then((node) => { + this.store.handleEvent({ + data: node + }); + }); + } +} + diff --git a/pkg/interface/src/logic/api/links.ts b/pkg/interface/src/logic/api/links.ts index bb6e5812c8..e3ad42f5d1 100644 --- a/pkg/interface/src/logic/api/links.ts +++ b/pkg/interface/src/logic/api/links.ts @@ -44,8 +44,8 @@ export default class LinksApi extends BaseApi { 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); } diff --git a/pkg/interface/src/logic/api/local.ts b/pkg/interface/src/logic/api/local.ts index 976f337c5a..3b874e060d 100644 --- a/pkg/interface/src/logic/api/local.ts +++ b/pkg/interface/src/logic/api/local.ts @@ -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 { getBaseHash() { @@ -69,6 +69,16 @@ export default class LocalApi extends BaseApi { }); } + setRemoteContentPolicy(policy: LocalUpdateRemoteContentPolicy) { + this.store.handleEvent({ + data: { + local: { + remoteContentPolicy: policy + } + } + }); + } + dehydrate() { this.store.dehydrate(); } diff --git a/pkg/interface/src/logic/api/publish.ts b/pkg/interface/src/logic/api/publish.ts index 02648f651b..5b49aabfc9 100644 --- a/pkg/interface/src/logic/api/publish.ts +++ b/pkg/interface/src/logic/api/publish.ts @@ -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: [], diff --git a/pkg/interface/src/logic/lib/omnibox.js b/pkg/interface/src/logic/lib/omnibox.js index 60dc95c01f..ee616ddc93 100644 --- a/pkg/interface/src/logic/lib/omnibox.js +++ b/pkg/interface/src/logic/lib/omnibox.js @@ -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; }; diff --git a/pkg/interface/src/logic/lib/tokenizeMessage.js b/pkg/interface/src/logic/lib/tokenizeMessage.js new file mode 100644 index 0000000000..6f43c421c8 --- /dev/null +++ b/pkg/interface/src/logic/lib/tokenizeMessage.js @@ -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 }; \ No newline at end of file diff --git a/pkg/interface/src/logic/lib/useWaitForProps.ts b/pkg/interface/src/logic/lib/useWaitForProps.ts index 5a2d752e6a..80e92e92a8 100644 --- a/pkg/interface/src/logic/lib/useWaitForProps.ts +++ b/pkg/interface/src/logic/lib/useWaitForProps.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; -export function useWaitForProps

(props: P, timeout: number) { +export function useWaitForProps

(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

(props: P, timeout: number) { setReady(() => r); return new Promise((resolve, reject) => { setResolve(() => resolve); - setTimeout(() => { - reject(new Error("Timed out")); - }, timeout); + if(timeout > 0) { + setTimeout(() => { + reject(new Error("Timed out")); + }, timeout); + } }); }, [setResolve, setReady, timeout] diff --git a/pkg/interface/src/logic/reducers/chat-update.ts b/pkg/interface/src/logic/reducers/chat-update.ts index 2921750381..531d97453a 100644 --- a/pkg/interface/src/logic/reducers/chat-update.ts +++ b/pkg/interface/src/logic/reducers/chat-update.ts @@ -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; @@ -49,8 +50,11 @@ export default class ChatReducer { 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 + ); } } diff --git a/pkg/interface/src/logic/reducers/graph-update.js b/pkg/interface/src/logic/reducers/graph-update.js new file mode 100644 index 0000000000..c8253f229c --- /dev/null +++ b/pkg/interface/src/logic/reducers/graph-update.js @@ -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 + } + + }); + } +}; + diff --git a/pkg/interface/src/logic/reducers/group-update.ts b/pkg/interface/src/logic/reducers/group-update.ts index 09adb35b26..5862aaca1d 100644 --- a/pkg/interface/src/logic/reducers/group-update.ts +++ b/pkg/interface/src/logic/reducers/group-update.ts @@ -78,6 +78,7 @@ export default class GroupReducer { this.addGroup(data, state); this.removeGroup(data, state); this.changePolicy(data, state); + this.expose(data, state); } } @@ -187,6 +188,15 @@ export default class GroupReducer { } } + 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; diff --git a/pkg/interface/src/logic/reducers/local.ts b/pkg/interface/src/logic/reducers/local.ts index 805e57e273..89a3fa8227 100644 --- a/pkg/interface/src/logic/reducers/local.ts +++ b/pkg/interface/src/logic/reducers/local.ts @@ -3,7 +3,7 @@ import { StoreState } from '~/store/type'; import { Cage } from '~/types/cage'; import { LocalUpdate, BackgroundConfig } from '~/types/local-update'; -type LocalState = Pick; +type LocalState = Pick; export default class LocalReducer { rehydrate(state: S) { @@ -18,7 +18,7 @@ export default class LocalReducer { } 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 { 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 { } } + 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; diff --git a/pkg/interface/src/logic/store/links.js b/pkg/interface/src/logic/store/links.js index d8d7fd7dd4..eb395603fa 100644 --- a/pkg/interface/src/logic/store/links.js +++ b/pkg/interface/src/logic/store/links.js @@ -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); } } diff --git a/pkg/interface/src/logic/store/store.ts b/pkg/interface/src/logic/store/store.ts index 1b9d7aeb00..8bb5794d9b 100644 --- a/pkg/interface/src/logic/store/store.ts +++ b/pkg/interface/src/logic/store/store.ts @@ -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 { 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 { }, groups: {}, groupKeys: new Set(), + graphs: {}, + graphKeys: new Set(), launch: { firstTime: false, tileOrdering: [], @@ -106,5 +115,6 @@ export default class GlobalStore extends BaseStore { this.launchReducer.reduce(data, this.state); this.linkListenReducer.reduce(data, this.state); this.connReducer.reduce(data, this.state); + GraphReducer(data, this.state); } } diff --git a/pkg/interface/src/logic/store/type.ts b/pkg/interface/src/logic/store/type.ts index 620ea71136..ffb989fa50 100644 --- a/pkg/interface/src/logic/store/type.ts +++ b/pkg/interface/src/logic/store/type.ts @@ -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; permissions: Permissions; s3: S3State; + graphs: Object; + graphKeys: Set; // App specific states diff --git a/pkg/interface/src/logic/subscription/global.ts b/pkg/interface/src/logic/subscription/global.ts index 6a3bb37015..8a07dde076 100644 --- a/pkg/interface/src/logic/subscription/global.ts +++ b/pkg/interface/src/logic/subscription/global.ts @@ -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 = { chat: chatSubscriptions, publish: publishSubscriptions, link: linkSubscriptions, - groups: groupSubscriptions + groups: groupSubscriptions, + graph: graphSubscriptions }; export default class GlobalSubscription extends BaseSubscription { @@ -40,8 +46,10 @@ export default class GlobalSubscription extends BaseSubscription { 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 { 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) { diff --git a/pkg/interface/src/types/chat-update.ts b/pkg/interface/src/types/chat-update.ts index 63b23eee11..6d03048f4e 100644 --- a/pkg/interface/src/types/chat-update.ts +++ b/pkg/interface/src/types/chat-update.ts @@ -73,6 +73,10 @@ export interface Envelope { letter: Letter; } +export type IMessage = Envelope & { + pending?: boolean +}; + interface LetterText { text: string; } diff --git a/pkg/interface/src/types/local-update.ts b/pkg/interface/src/types/local-update.ts index 25d679d467..a5739fd218 100644 --- a/pkg/interface/src/types/local-update.ts +++ b/pkg/interface/src/types/local-update.ts @@ -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; \ No newline at end of file diff --git a/pkg/interface/src/views/App.js b/pkg/interface/src/views/App.js index 497c5677b9..e72d0b1d16 100644 --- a/pkg/interface/src/views/App.js +++ b/pkg/interface/src/views/App.js @@ -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} + /> @@ -143,6 +143,5 @@ class App extends React.Component { } } - export default process.env.NODE_ENV === 'production' ? App : hot(App); diff --git a/pkg/interface/src/views/apps/chat/app.tsx b/pkg/interface/src/views/apps/chat/app.tsx index 032211ba5f..45790f2030 100644 --- a/pkg/interface/src/views/apps/chat/app.tsx +++ b/pkg/interface/src/views/apps/chat/app.tsx @@ -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 { pendingMessages, groups, hideAvatars, - hideNicknames + hideNicknames, + remoteContentPolicy } = props; const renderChannelSidebar = (props, station?) => ( @@ -108,7 +108,7 @@ export default class ChatApp extends React.Component { return ( <> - + {totalUnreads > 0 ? `(${totalUnreads}) ` : ''}OS1 - Chat @@ -231,56 +231,57 @@ export default class ChatApp extends React.Component { 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 ( - + - - - ); - }} + chatInitialized={chatInitialized} + hideAvatars={hideAvatars} + hideNicknames={hideNicknames} + remoteContentPolicy={remoteContentPolicy} + {...props} + /> + + ); + }} /> ; + dragover: boolean; } export class ChatScreen extends Component { + private chatInput: React.RefObject; lastNumPending = 0; activityTimeout: NodeJS.Timeout | null = null; @@ -53,8 +58,11 @@ export class ChatScreen extends Component { 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 { }); } + 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 { !(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 (

- + 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 ? : null} + + {...props} /> { deleteMessage={() => this.setState({ messages: this.state.messages.set(props.station, "") })} + hideAvatars={props.hideAvatars} />
); diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-editor.js b/pkg/interface/src/views/apps/chat/components/lib/chat-editor.js index be7dd64bef..681c6828f2 100644 --- a/pkg/interface/src/views/apps/chat/components/lib/chat-editor.js +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-editor.js @@ -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 {
this.messageChange(e, d, v)} editorDidMount={(editor) => { @@ -137,6 +145,7 @@ export default class ChatEditor extends Component { editor.focus(); } }} + {...props} />
); diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-header.js b/pkg/interface/src/views/apps/chat/components/lib/chat-header.js index df43d08ac6..ab63978f26 100644 --- a/pkg/interface/src/views/apps/chat/components/lib/chat-header.js +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-header.js @@ -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 (
- {"⟵ All Chats"} + style={{ height: '1rem' }} + > + {'⟵ All Chats'}
+ style={{ height: 48 }} + > + to={'/~chat/' + isInPopout + 'room' + props.station} + className="pt2 white-d" + >

+ style={{ width: 'max-content' }} + > {title}

-
); -} +}; diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-input.js b/pkg/interface/src/views/apps/chat/components/lib/chat-input.js deleted file mode 100644 index 4e49680a12..0000000000 --- a/pkg/interface/src/views/apps/chat/components/lib/chat-input.js +++ /dev/null @@ -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)) - ? - : ; - - return ( -
-
- {avatar} -
- -
- -
-
- -
-
- ); - } -} diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-input.tsx b/pkg/interface/src/views/apps/chat/components/lib/chat-input.tsx new file mode 100644 index 0000000000..ba76714638 --- /dev/null +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-input.tsx @@ -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 { + public s3Uploader: React.RefObject; + private chatEditor: React.RefObject; + + 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) + ) + ? + : ; + + return ( +
+
+ {avatar} +
+ +
+ + + +
+
+ +
+
+ ); + } +} diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-message.tsx b/pkg/interface/src/views/apps/chat/components/lib/chat-message.tsx index 3d8573927b..e29e18802c 100644 --- a/pkg/interface/src/views/apps/chat/components/lib/chat-message.tsx +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-message.tsx @@ -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 = ( - - ); - - if (props.isLastUnread) { - return ( - - {messageElem} -
-
-

New messages below

-
- {dayBreak && ( -

- {moment(_.get(msg, when)).calendar()} -

- )} -
-
-
+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 = ( + ); - } else if (dayBreak) { - return ( - - {messageElem} -
-

{moment(_.get(msg, when)).calendar()}

-
-
- ); - } else { - return messageElem; + + if (isFirstUnread) { + return ( + + {messageElem} +
+
+

New messages below

+
+ {dayBreak && ( +

+ {moment(_.get(msg, when)).calendar()} +

+ )} +
+
+
+ ); + } else if (dayBreak) { + return ( + +
+

{moment(_.get(msg, when)).calendar()}

+
+ {messageElem} +
+ ); + } else { + return messageElem; + } } -}; - +} \ No newline at end of file diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-scroll-container.js b/pkg/interface/src/views/apps/chat/components/lib/chat-scroll-container.js deleted file mode 100644 index 74580f9954..0000000000 --- a/pkg/interface/src/views/apps/chat/components/lib/chat-scroll-container.js +++ /dev/null @@ -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 ( -
-
-
- {this.props.children} -
-
- ); - } - - normalScrollContainer() { - return ( -
-
- {this.props.children} -
- ); - } - - 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 - ); - } - } - -} diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-window.tsx b/pkg/interface/src/views/apps/chat/components/lib/chat-window.tsx index 725abaf0fe..b5ca26c32e 100644 --- a/pkg/interface/src/views/apps/chat/components/lib/chat-window.tsx +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-window.tsx @@ -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 }) => ( +
+
+ +
+
+
+

+ +

+

+

+
+ +
+
+); -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 { + private unreadReference: React.RefObject; + private virtualList: React.RefObject; + 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 ( - - - - { messages.map((msg, i) => ( - 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} - /> - )) - } - + unreadCount={unreadCount} + unreadMsg={unreadMsg} + dismissUnread={this.dismissUnread} + onClick={this.scrollToUnread} + /> + + + {messages.length ? Math.abs(velocity) > 2000, + exit: velocity => Math.abs(velocity) < 200, + change: (_velocity, _range) => {}, + placeholder: this.state.initialized ? Placeholder : () =>
+ }} + 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 ; + } + return + }} + /> :
}
); } diff --git a/pkg/interface/src/views/apps/chat/components/lib/content/url.js b/pkg/interface/src/views/apps/chat/components/lib/content/url.js deleted file mode 100644 index a6f1f989a5..0000000000 --- a/pkg/interface/src/views/apps/chat/components/lib/content/url.js +++ /dev/null @@ -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 = ( - - ); - return ( - - {contents} - - ); - } else if (ytMatch) { - contents = ( -
- -
- ); - return ( -
- - {content.url} - - - {contents} -
- ); - } else { - return ( - - {contents} - - ); - } - } -} diff --git a/pkg/interface/src/views/apps/chat/components/lib/delete-button.js b/pkg/interface/src/views/apps/chat/components/lib/delete-button.js index cb7b75ae75..1abb19f7e9 100644 --- a/pkg/interface/src/views/apps/chat/components/lib/delete-button.js +++ b/pkg/interface/src/views/apps/chat/components/lib/delete-button.js @@ -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 ( -
-
-

Leave Chat

-

- Remove this chat from your chat list.{' '} - You will need to request for access again. -

- - Leave this chat - -
-
-

Delete Chat

-

- Permanently delete this chat.{' '} - All current members will no longer see this chat. -

- Delete this chat -
-
+ const deleteChat = () => { + changeLoading( + true, + true, + isOwner ? 'Deleting chat...' : 'Leaving chat...', + () => { + api.chat.delete(station); + } ); -}; + }; + const groupPath = association['group-path']; + const unmanagedVillage = !contacts[groupPath]; + + return ( +
+
+

Leave Chat

+

+ 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.' + } +

+ + Leave this chat + +
+
+

Delete Chat

+

+ Permanently delete this chat.{' '} + All current members will no longer see this chat. +

+ Delete this chat +
+
+ ); +}) \ No newline at end of file diff --git a/pkg/interface/src/views/apps/chat/components/lib/group-item.js b/pkg/interface/src/views/apps/chat/components/lib/group-item.js index 1f0e200b96..6f047232a3 100644 --- a/pkg/interface/src/views/apps/chat/components/lib/group-item.js +++ b/pkg/interface/src/views/apps/chat/components/lib/group-item.js @@ -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; diff --git a/pkg/interface/src/views/apps/chat/components/lib/message-content.js b/pkg/interface/src/views/apps/chat/components/lib/message-content.js index 865ab3af20..41321470a8 100644 --- a/pkg/interface/src/views/apps/chat/components/lib/message-content.js +++ b/pkg/interface/src/views/apps/chat/components/lib/message-content.js @@ -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 ; } else if ('url' in content) { - return ; + return ( + + ); } else if ('me' in content) { return (

diff --git a/pkg/interface/src/views/apps/chat/components/lib/message.js b/pkg/interface/src/views/apps/chat/components/lib/message.js index 2220658d66..6174afd0bb 100644 --- a/pkg/interface/src/views/apps/chat/components/lib/message.js +++ b/pkg/interface/src/views/apps/chat/components/lib/message.js @@ -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) ) : (

{timestamp}

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

- { - writeText(props.msg.author); - }} - title={`~${props.msg.author}`} - > - {name} - -

-

{timestamp}

-

- {datestamp} -

-
- -
-
- ); + 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 ( +
+ +
+
+

+ { + writeText(props.msg.author); + }} + title={`~${props.msg.author}`} + > + {name} + +

+

{timestamp}

+

+ {datestamp} +

+
+ +
+
+ ); +} + diff --git a/pkg/interface/src/views/apps/chat/components/lib/s3-upload.js b/pkg/interface/src/views/apps/chat/components/lib/s3-upload.js deleted file mode 100644 index b497413cfd..0000000000 --- a/pkg/interface/src/views/apps/chat/components/lib/s3-upload.js +++ /dev/null @@ -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
; - } else { - const classes = props.className ? - 'pointer ' + props.className : 'pointer'; - return ( -
- - -
- ); - } - } -} - diff --git a/pkg/interface/src/views/apps/chat/components/lib/unread-notice.js b/pkg/interface/src/views/apps/chat/components/lib/unread-notice.js index 2724d23c9b..954ed33578 100644 --- a/pkg/interface/src/views/apps/chat/components/lib/unread-notice.js +++ b/pkg/interface/src/views/apps/chat/components/lib/unread-notice.js @@ -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" }> -

+

{unreadCount} new messages since{' '} {datestamp && ( <> diff --git a/pkg/interface/src/views/apps/chat/components/new-dm.js b/pkg/interface/src/views/apps/chat/components/new-dm.js index 5e69eaa6f7..3f2798b16b 100644 --- a/pkg/interface/src/views/apps/chat/components/new-dm.js +++ b/pkg/interface/src/views/apps/chat/components/new-dm.js @@ -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; diff --git a/pkg/interface/src/views/apps/chat/components/settings.js b/pkg/interface/src/views/apps/chat/components/settings.js index 9b1b339367..a79011ded0 100644 --- a/pkg/interface/src/views/apps/chat/components/settings.js +++ b/pkg/interface/src/views/apps/chat/components/settings.js @@ -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} /> + resource="chat" + app="chat" + /> - + accept="image/*" + > + + Channels {shareSheet}

Members

- - {({ index, 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 - }
)} -
+ } + /> diff --git a/pkg/interface/src/views/apps/groups/components/lib/group-detail.js b/pkg/interface/src/views/apps/groups/components/lib/group-detail.js index 3dbe30dac0..4d7b6ff942 100644 --- a/pkg/interface/src/views/apps/groups/components/lib/group-detail.js +++ b/pkg/interface/src/views/apps/groups/components/lib/group-detail.js @@ -324,11 +324,13 @@ export class GroupDetail extends Component { { 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 diff --git a/pkg/interface/src/views/apps/groups/components/lib/s3-upload.js b/pkg/interface/src/views/apps/groups/components/lib/s3-upload.js deleted file mode 100644 index b94b14728a..0000000000 --- a/pkg/interface/src/views/apps/groups/components/lib/s3-upload.js +++ /dev/null @@ -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
; - } else { - let classes = !!props.className ? - "pointer " + props.className : "pointer"; - return ( -
- - -
- ); - } - } -} - diff --git a/pkg/interface/src/views/apps/links/app.js b/pkg/interface/src/views/apps/links/app.js index 30b4965479..080aefae7d 100644 --- a/pkg/interface/src/views/apps/links/app.js +++ b/pkg/interface/src/views/apps/links/app.js @@ -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 ( <> - + {totalUnseen > 0 ? `(${totalUnseen}) ` : ''}OS1 - Links @@ -263,6 +256,7 @@ export class LinksApp extends Component { api={api} hideNicknames={hideNicknames} hideAvatars={hideAvatars} + s3={s3} /> ); @@ -325,6 +319,7 @@ export class LinksApp extends Component { api={api} hideAvatars={hideAvatars} hideNicknames={hideNicknames} + remoteContentPolicy={remoteContentPolicy} /> ); diff --git a/pkg/interface/src/views/apps/links/components/lib/comment-item.js b/pkg/interface/src/views/apps/links/components/lib/comment-item.js index 30bc74f70a..dfe9b34032 100644 --- a/pkg/interface/src/views/apps/links/components/lib/comment-item.js +++ b/pkg/interface/src/views/apps/links/components/lib/comment-item.js @@ -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 ? @@ -49,22 +49,20 @@ export class CommentItem extends Component { />; return ( -
-
- {img} -

- + + + {img} + + {showNickname ? props.nickname : cite(props.ship)} - - + + {this.state.timeSinceComment} - -

-
-

{props.content}

-
+ + + + {props.content} + ); } } diff --git a/pkg/interface/src/views/apps/links/components/lib/comments.js b/pkg/interface/src/views/apps/links/components/lib/comments.js index f7ce2719c9..8982867fd3 100644 --- a/pkg/interface/src/views/apps/links/components/lib/comments.js +++ b/pkg/interface/src/views/apps/links/components/lib/comments.js @@ -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} /> ); }); diff --git a/pkg/interface/src/views/apps/links/components/lib/invite-element.js b/pkg/interface/src/views/apps/links/components/lib/invite-element.js deleted file mode 100644 index 297f693593..0000000000 --- a/pkg/interface/src/views/apps/links/components/lib/invite-element.js +++ /dev/null @@ -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 ( -
- - - -
- ); - } -} diff --git a/pkg/interface/src/views/apps/links/components/lib/link-detail-preview.js b/pkg/interface/src/views/apps/links/components/lib/link-detail-preview.js index 0dd5ecadc9..bc7d286d73 100644 --- a/pkg/interface/src/views/apps/links/components/lib/link-detail-preview.js +++ b/pkg/interface/src/views/apps/links/components/lib/link-detail-preview.js @@ -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 = ( + + ); + 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 = - - ; - } - - if (ytMatch) { - embed = ( - - ); - } - const showNickname = props.nickname && !props.hideNicknames; const nameClass = showNickname ? 'inter' : 'mono'; @@ -119,9 +75,9 @@ export class LinkPreview extends Component { return (
- {embed ||
} + {embed}
diff --git a/pkg/interface/src/views/apps/links/components/lib/link-submit.js b/pkg/interface/src/views/apps/links/components/lib/link-submit.js deleted file mode 100644 index 780df3d88e..0000000000 --- a/pkg/interface/src/views/apps/links/components/lib/link-submit.js +++ /dev/null @@ -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 ( -
-