├── .gitignore ├── LICENSE ├── README.md ├── assets ├── noavatar.svg ├── old-barn-wood-background-recolored-2-tiled.jpg ├── openclipart-1392738806.svg ├── openclipart-223534.png ├── openclipart-223534.svg ├── openclipart-261618.svg ├── screenshot.jpg └── seabed-155896303267m-optimized.jpg ├── browser.html ├── click-catcher.js ├── convert-to-inline.sh ├── css ├── NotoColorEmoji-LICENSE.txt ├── NotoColorEmoji-Small.ttf ├── NotoColorEmoji.ttf ├── Silverblade-LICENSE.txt ├── SilverbladeDecorative.ttf ├── blocuswebfont-LICENSE.txt ├── blocuswebfont.ttf ├── channels.css ├── connections.css ├── groups.css ├── main.css ├── modal.css ├── private.css ├── profile.css ├── public.css ├── theme-dark.css ├── theme-ethereal.css ├── theme-floralgardenbird.css ├── theme-pirateship.css ├── theme-seigaihasubtle.css ├── thread.css └── threads.css ├── dedupe.sh ├── defaultprefs.json ├── favicon.ico ├── hermies.png ├── indexes └── channels.js ├── localprefs.js ├── manifest.json ├── messages.json ├── net.js ├── npm-shrinkwrap.json ├── package.json ├── profile.js ├── search.js ├── ssb-singleton-setup.js ├── start-server.sh ├── ui ├── browser.js ├── channel.js ├── channels.js ├── common-contextmenu.js ├── components.js ├── connected.js ├── connections.js ├── dht-invite.js ├── group.js ├── groups.js ├── helpers.js ├── markdown-editor.js ├── markdown.js ├── new-private-messages.js ├── new-public-messages.js ├── notifications.js ├── onboarding-dialog.js ├── private.js ├── profile.js ├── public.js ├── search.js ├── settings.js ├── ssb-msg-preview.js ├── ssb-msg.js ├── ssb-profile-link.js ├── ssb-profile-name-link.js ├── thread.js ├── threads.js └── view-source.js ├── usergroups.js └── write-dist.js /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | build 3 | dist 4 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | "THE BEER-WARE LICENSE" (Revision 42): 2 | wrote this file. As long as you retain this notice you can do whatever you want with this stuff. If we meet some day, and you think this stuff is worth it, you can buy me a beer in return 3 | Anders Rune Jensen 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSB browser demo 2 | 3 | ![Screenshot of ssb browser demo][screenshot] 4 | 5 | A secure scuttlebutt client interface running 100% in the browser. Built using 6 | [ssb-browser-core]. This was originally made as a demo for my bornhack 7 | [talk][bornhack-talk]. 8 | 9 | The client was made for two purposes: test ssb in a browser and for 10 | evaluating different partial replication strategies. 11 | 12 | Feeds are stored in full for feeds you follow directly. For feeds 13 | followed by a feed you follow (hops 2), you only store a partial 14 | replica of their messages. 15 | 16 | If you are a new user, the best way to get started is to get the id of 17 | a person already on the network, go to their profile page and start 18 | following that person. After this you will start seeing their messages 19 | and the messages of their extended graph. In order for them to see 20 | your messages, they will need to follow you back. You can get your id 21 | on the profile page. 22 | 23 | As a way to let people explore the messages from users outside ones 24 | follow graph, the [ssb-partial-replication] plugin is used to get 25 | threads from the server. 26 | 27 | The UI is written in vue.js and can display posts and self assigned 28 | profile about messages. 29 | 30 | Things that work: 31 | - partial replication 32 | - direct connections to other peers using [rooms] 33 | - viewing posts and threads, including forks, backlinks and reactions 34 | - posting, adding reactions, replying to messages including posting blobs 35 | - automatic exif stripping (such as GPS coordinates) on images for better privacy 36 | - automatic image resizing of large images 37 | - viewing profiles and setting up your own profile 38 | - private messages including private blobs 39 | - offline support and PWA (mobile) 40 | - ooo messages for messages from people outside your current follow graph 41 | - deleting messages included a whole profile 42 | - blocking 43 | - channels 44 | - groups 45 | - forum like view 46 | - notifications 47 | - themes 48 | - translations 49 | - backup / restore feed using mnemonics 50 | - easily run alternative [networks][pub-setup] 51 | 52 | Tested with Chrome and Firefox. Chrome is faster because it uses fs 53 | instead of indexeddb. Also tested on android using Chrome and iOS 54 | using safari. 55 | 56 | An online version is available for testing [here][test-server] 57 | 58 | # Running locally 59 | 60 | For testing this locally, one needs to run a local http server like 61 | `npm i -g http-server`. 62 | 63 | # Server 64 | 65 | I made a [blog post][pub-setup] on how to run a server pub to relay messages to other nodes through. 66 | 67 | # Building 68 | 69 | `npm run build` for developing and `npm run release` for a much smaller bundle. You can also run `npm run inline` to genereate a single monolithic index.html file with all resources included. 70 | 71 | # Enabling WebSockets in ssb-room 72 | 73 | To run an ssb-room which this can connect to, you will need to enable WebSockets support. This requires three things: 74 | 75 | 1. In ssb-room's config.js, add a line to `connections` to configure the WebSockets port (external, key, and cert need to be customized for your installation): 76 | ``` 77 | connections: { 78 | incoming: { 79 | net: [{ port: 8888, host: "0.0.0.0", scope: "public", transform: "shs" }], 80 | ws: [{ port: 9999, host: "::", scope: "public", transform: "shs", external: ["example.com"], http: true }], 81 | // Or, to use secure WebSockets: 82 | // ws: [{ port: 9999, host: "::", scope: "public", transform: "shs", external: ["example.com"], key: "/etc/letsencrypt/live/example.com/privkey.pem", cert: "/etc/letsencrypt/live/example.com/cert.pem" }], 83 | }, 84 | outgoing: { 85 | net: [{transform: 'shs'}], 86 | }, 87 | }, 88 | ``` 89 | 2. Run either `npm install -g ssb-ws` to install the WebSockets connector globally, or cd into the ssb-room directory and `npm install ssb-ws` 90 | 3. In index.js, add the following line to the SecretStack section just below `ssb-logging`: 91 | ``` 92 | .use(require('ssb-ws')) 93 | ``` 94 | 95 | After that, your ssb-room will be compatible with ssb-browser-demo. 96 | 97 | # Other 98 | 99 | ## How to help with translating 100 | 101 | If you know a language other than English, we need your help! Take a look at our translation efforts here: 102 | 103 | [Issue #103](https://github.com/arj03/ssb-browser-demo/issues/103) 104 | 105 | We currently have support for English (US), English (UK), English (Pirate), and mostly machine-translated Japanese. To add a translation, take a look at messages.json. You'll find sections in there for the different languages we support. Just take an existing language block (like "en") and copy it to a new block with the locale's name as the key, and start translating! 106 | 107 | As a side note, we use [vue-i18n](https://github.com/kazupon/vue-i18n) for internationalization, which supports fallback locales. So, for example, if your locale is "en-US", then when it finds that we don't have a specific "en-US" translation, it falls back to the generic "en". The same would be true of "fr-CH" if we had an "fr" translation, for example. So if your translations are generic enough to be used by multiple local variants, please put them into a generic language block. 108 | 109 | ## Force WASM locally (outside browser) 110 | 111 | rm -rf node_modules/sodium-chloride/ 112 | 113 | ## check contents of db 114 | 115 | ``` 116 | var pull = require("pull-stream") 117 | 118 | pull( 119 | store.stream(), 120 | pull.drain((msg) => { 121 | console.log(msg) 122 | }) 123 | ) 124 | ``` 125 | 126 | List all files in browser 127 | 128 | ``` javascript 129 | function listDir(fs, path) 130 | { 131 | fs.root.getDirectory(path, {}, function(dirEntry){ 132 | var dirReader = dirEntry.createReader(); 133 | dirReader.readEntries(function(entries) { 134 | for(var i = 0; i < entries.length; i++) { 135 | var entry = entries[i]; 136 | if (entry.isDirectory) { 137 | console.log('Directory: ' + entry.fullPath); 138 | listDir(fs, entry.fullPath) 139 | } 140 | else if (entry.isFile) 141 | console.log('File: ' + entry.fullPath); 142 | } 143 | }) 144 | }) 145 | } 146 | 147 | window.webkitRequestFileSystem(window.PERSISTENT, 0, function (fs) { 148 | listDir(fs, '/.ssb-lite/') 149 | }) 150 | ``` 151 | 152 | [screenshot]: assets/screenshot.jpg 153 | [ssb-browser-core]: https://github.com/arj03/ssb-browser-core 154 | [bornhack-talk]: https://people.iola.dk/arj/2019/08/11/bornhack-talk/ 155 | [ssb-partial-replication]: https://github.com/arj03/ssb-partial-replication 156 | [ssb-peer-invites]: https://github.com/ssbc/ssb-peer-invites 157 | [test-server]: https://between-two-worlds.dk/browser.html 158 | [pub-setup]: https://people.iola.dk/arj/2020/03/04/how-to-setup-a-pub-for-ssb-browser/ 159 | [rooms]: https://github.com/staltz/ssb-room 160 | -------------------------------------------------------------------------------- /assets/noavatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 62 | 69 | 76 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /assets/old-barn-wood-background-recolored-2-tiled.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/assets/old-barn-wood-background-recolored-2-tiled.jpg -------------------------------------------------------------------------------- /assets/openclipart-1392738806.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 21 | 30 | 35 | 40 | 42 | 44 | 67 | 73 | 83 | 89 | 91 | 93 | 95 | 97 | image/svg+xml 100 | 103 | 106 | 108 | 111 | Openclipart 114 | 116 | 118 | 120 | 123 | 126 | 129 | 132 | 134 | 136 | 138 | 140 | -------------------------------------------------------------------------------- /assets/openclipart-223534.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/assets/openclipart-223534.png -------------------------------------------------------------------------------- /assets/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/assets/screenshot.jpg -------------------------------------------------------------------------------- /assets/seabed-155896303267m-optimized.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/assets/seabed-155896303267m-optimized.jpg -------------------------------------------------------------------------------- /browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | SSB browser demo 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | 42 | 43 |

{{ appTitle }}

44 | 55 | 69 | 70 | 71 | 72 | 73 | 84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /click-catcher.js: -------------------------------------------------------------------------------- 1 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 2 | const copy = require("clipboard-copy") 3 | 4 | const MutationObserver = window.MutationObserver || window.WebKitMutationObserver 5 | 6 | function onClick(e) { 7 | if (e.button == 1 || (e.button == 0 && e.ctrlKey)) { 8 | ssbSingleton.openWindow(e.currentTarget ? e.currentTarget.href : e.target.href) 9 | e.preventDefault() 10 | e.stopPropagation() 11 | return false 12 | } 13 | } 14 | 15 | function openInNewTab(a) { 16 | ssbSingleton.openWindow(a.href) 17 | } 18 | 19 | function onContextMenu(e) { 20 | // Is it even an internal link? 21 | var a = (e.currentTarget || e.target) 22 | var href = a.getAttribute("href") // Can't just use a.href because that gets absolutized. 23 | var vueHolder = a 24 | while (!vueHolder.__vue__ && vueHolder.parentNode) vueHolder = vueHolder.parentNode 25 | if (!vueHolder.__vue__ || !href || href.indexOf("://") >= 0 || href.startsWith("javascript:")) return 26 | 27 | var options = [ 28 | { name: "Open in new tab", cb: openInNewTab } 29 | ] 30 | if (href.startsWith("#/profile/")) { 31 | var id = decodeURIComponent(href.substring(("#/profile/").length)) 32 | options.push({ 33 | name: "Copy ID", 34 | cb: () => { copy(id) } 35 | }) 36 | 37 | [ err, SSB ] = ssbSingleton.getSSB() 38 | if (SSB && (name = SSB.getProfileName(id))) { 39 | options.push({ 40 | name: "Copy Markdown link", 41 | cb: () => { copy("[@" + name + "](" + id + ")") } 42 | }) 43 | } 44 | } else if (href.startsWith("#/thread/")) { 45 | var id = "%" + decodeURIComponent(href.substring(("#/thread/").length)) 46 | options.push({ 47 | name: "Copy ID", 48 | cb: () => { copy(id) } 49 | }) 50 | } 51 | 52 | const contextMenu = vueHolder.__vue__.$root.$refs.commonContextMenu 53 | contextMenu.showMenu(e, options, a) 54 | 55 | e.preventDefault() 56 | e.stopPropagation() 57 | return false 58 | } 59 | 60 | function addHandlersIfNeeded(a) { 61 | if (a.classList.contains("click-caught")) 62 | return 63 | 64 | a.addEventListener("click", onClick) 65 | a.addEventListener("auxclick", onClick) 66 | a.addEventListener("contextmenu", onContextMenu) 67 | 68 | a.classList.add("click-caught") 69 | } 70 | 71 | function observerCallback(mutationsList, observer) { 72 | for (const mutation of mutationsList) { 73 | if (mutation.type === 'childList') { 74 | for (var n = 0; n < mutation.addedNodes.length; ++n) { 75 | var node = mutation.addedNodes[n] 76 | if (node && node.tagName) { 77 | if (node.tagName.toLowerCase() == "a") { 78 | addHandlersIfNeeded(node) 79 | } else { 80 | var allA = node.getElementsByTagName("a") 81 | for (var a = 0; a < allA.length; ++a) 82 | addHandlersIfNeeded(allA[a]) 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | 90 | module.exports.start = function() { 91 | // First, add the handler to all links which already exist in the DOM. 92 | var allA = document.getElementsByTagName("a") 93 | for (var a = 0; a < allA.length; ++a) 94 | addHandlersIfNeeded(allA[a]) 95 | 96 | // Now install a mutation observer to watch for new elements being added. 97 | if (!window.clickObserver) { 98 | window.clickObserver = new MutationObserver(observerCallback) 99 | window.clickObserver.observe(document.documentElement, { childList: true, subtree: true }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /convert-to-inline.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | node node_modules/inline-source-cli/dist/index.js --compress false --attribute '' --root dist/ dist/index.html dist/index-inlined.html 3 | echo "Starting size is $(du dist/index-inlined.html | cut -f 1)k" 4 | for SVG in assets/*.svg; do 5 | echo "Inlining ${SVG}" 6 | sed -i -f - dist/index-inlined.html << EOF 7 | s~../${SVG}~data:image/svg+xml;base64,$(base64 -w 0 "${SVG}")~ 8 | EOF 9 | echo "Size is now $(du dist/index-inlined.html | cut -f 1)k" 10 | done 11 | for JPEG in assets/*.jpg; do 12 | echo "Inlining ${JPEG}" 13 | sed -i -f - dist/index-inlined.html << EOF 14 | s~../${JPEG}~data:image/jpeg;base64,$(base64 -w 0 "${JPEG}")~ 15 | EOF 16 | echo "Size is now $(du dist/index-inlined.html | cut -f 1)k" 17 | done 18 | for FONT in css/*.ttf; do 19 | FONT_LOCAL="$(basename -- "${FONT}")" 20 | echo "Inlining ${FONT_LOCAL}" 21 | if [[ "${FONT_LOCAL}" == "NotoColorEmoji.ttf" ]]; then 22 | FONT="css/NotoColorEmoji-Small.ttf" 23 | fi 24 | sed -i -f - dist/index-inlined.html << EOF 25 | s~./${FONT_LOCAL}~data:font-ttf;base64,$(base64 -w 0 "${FONT}")~ 26 | EOF 27 | echo "Size is now $(du dist/index-inlined.html | cut -f 1)k" 28 | done 29 | echo "Final size is $(du dist/index-inlined.html | cut -f 1)k" 30 | pushd dist 31 | find . \! -name 'index-inlined.html' \! -name 'favicon.ico' -delete 32 | mv index-inlined.html index.html 33 | popd 34 | -------------------------------------------------------------------------------- /css/NotoColorEmoji-LICENSE.txt: -------------------------------------------------------------------------------- 1 | This Font Software is licensed under the SIL Open Font License, 2 | Version 1.1. 3 | 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | ----------------------------------------------------------- 8 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 9 | ----------------------------------------------------------- 10 | 11 | PREAMBLE 12 | The goals of the Open Font License (OFL) are to stimulate worldwide 13 | development of collaborative font projects, to support the font 14 | creation efforts of academic and linguistic communities, and to 15 | provide a free and open framework in which fonts may be shared and 16 | improved in partnership with others. 17 | 18 | The OFL allows the licensed fonts to be used, studied, modified and 19 | redistributed freely as long as they are not sold by themselves. The 20 | fonts, including any derivative works, can be bundled, embedded, 21 | redistributed and/or sold with any software provided that any reserved 22 | names are not used by derivative works. The fonts and derivatives, 23 | however, cannot be released under any other type of license. The 24 | requirement for fonts to remain under this license does not apply to 25 | any document created using the fonts or their derivatives. 26 | 27 | DEFINITIONS 28 | "Font Software" refers to the set of files released by the Copyright 29 | Holder(s) under this license and clearly marked as such. This may 30 | include source files, build scripts and documentation. 31 | 32 | "Reserved Font Name" refers to any names specified as such after the 33 | copyright statement(s). 34 | 35 | "Original Version" refers to the collection of Font Software 36 | components as distributed by the Copyright Holder(s). 37 | 38 | "Modified Version" refers to any derivative made by adding to, 39 | deleting, or substituting -- in part or in whole -- any of the 40 | components of the Original Version, by changing formats or by porting 41 | the Font Software to a new environment. 42 | 43 | "Author" refers to any designer, engineer, programmer, technical 44 | writer or other person who contributed to the Font Software. 45 | 46 | PERMISSION & CONDITIONS 47 | Permission is hereby granted, free of charge, to any person obtaining 48 | a copy of the Font Software, to use, study, copy, merge, embed, 49 | modify, redistribute, and sell modified and unmodified copies of the 50 | Font Software, subject to the following conditions: 51 | 52 | 1) Neither the Font Software nor any of its individual components, in 53 | Original or Modified Versions, may be sold by itself. 54 | 55 | 2) Original or Modified Versions of the Font Software may be bundled, 56 | redistributed and/or sold with any software, provided that each copy 57 | contains the above copyright notice and this license. These can be 58 | included either as stand-alone text files, human-readable headers or 59 | in the appropriate machine-readable metadata fields within text or 60 | binary files as long as those fields can be easily viewed by the user. 61 | 62 | 3) No Modified Version of the Font Software may use the Reserved Font 63 | Name(s) unless explicit written permission is granted by the 64 | corresponding Copyright Holder. This restriction only applies to the 65 | primary font name as presented to the users. 66 | 67 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 68 | Software shall not be used to promote, endorse or advertise any 69 | Modified Version, except to acknowledge the contribution(s) of the 70 | Copyright Holder(s) and the Author(s) or with their explicit written 71 | permission. 72 | 73 | 5) The Font Software, modified or unmodified, in part or in whole, 74 | must be distributed entirely under this license, and must not be 75 | distributed under any other license. The requirement for fonts to 76 | remain under this license does not apply to any document created using 77 | the Font Software. 78 | 79 | TERMINATION 80 | This license becomes null and void if any of the above conditions are 81 | not met. 82 | 83 | DISCLAIMER 84 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 85 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 86 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 87 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 88 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 89 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 90 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 91 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 92 | OTHER DEALINGS IN THE FONT SOFTWARE. 93 | -------------------------------------------------------------------------------- /css/NotoColorEmoji-Small.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/css/NotoColorEmoji-Small.ttf -------------------------------------------------------------------------------- /css/NotoColorEmoji.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/css/NotoColorEmoji.ttf -------------------------------------------------------------------------------- /css/Silverblade-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2004, Frank Baranowski, 2 | with Reserved Font Name "Silverblade". 3 | 4 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 5 | This license is copied below, and is also available with a FAQ at: 6 | http://scripts.sil.org/OFL 7 | 8 | 9 | ----------------------------------------------------------- 10 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 11 | ----------------------------------------------------------- 12 | 13 | PREAMBLE 14 | The goals of the Open Font License (OFL) are to stimulate worldwide 15 | development of collaborative font projects, to support the font creation 16 | efforts of academic and linguistic communities, and to provide a free and 17 | open framework in which fonts may be shared and improved in partnership 18 | with others. 19 | 20 | The OFL allows the licensed fonts to be used, studied, modified and 21 | redistributed freely as long as they are not sold by themselves. The 22 | fonts, including any derivative works, can be bundled, embedded, 23 | redistributed and/or sold with any software provided that any reserved 24 | names are not used by derivative works. The fonts and derivatives, 25 | however, cannot be released under any other type of license. The 26 | requirement for fonts to remain under this license does not apply 27 | to any document created using the fonts or their derivatives. 28 | 29 | DEFINITIONS 30 | "Font Software" refers to the set of files released by the Copyright 31 | Holder(s) under this license and clearly marked as such. This may 32 | include source files, build scripts and documentation. 33 | 34 | "Reserved Font Name" refers to any names specified as such after the 35 | copyright statement(s). 36 | 37 | "Original Version" refers to the collection of Font Software components as 38 | distributed by the Copyright Holder(s). 39 | 40 | "Modified Version" refers to any derivative made by adding to, deleting, 41 | or substituting -- in part or in whole -- any of the components of the 42 | Original Version, by changing formats or by porting the Font Software to a 43 | new environment. 44 | 45 | "Author" refers to any designer, engineer, programmer, technical 46 | writer or other person who contributed to the Font Software. 47 | 48 | PERMISSION & CONDITIONS 49 | Permission is hereby granted, free of charge, to any person obtaining 50 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 51 | redistribute, and sell modified and unmodified copies of the Font 52 | Software, subject to the following conditions: 53 | 54 | 1) Neither the Font Software nor any of its individual components, 55 | in Original or Modified Versions, may be sold by itself. 56 | 57 | 2) Original or Modified Versions of the Font Software may be bundled, 58 | redistributed and/or sold with any software, provided that each copy 59 | contains the above copyright notice and this license. These can be 60 | included either as stand-alone text files, human-readable headers or 61 | in the appropriate machine-readable metadata fields within text or 62 | binary files as long as those fields can be easily viewed by the user. 63 | 64 | 3) No Modified Version of the Font Software may use the Reserved Font 65 | Name(s) unless explicit written permission is granted by the corresponding 66 | Copyright Holder. This restriction only applies to the primary font name as 67 | presented to the users. 68 | 69 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 70 | Software shall not be used to promote, endorse or advertise any 71 | Modified Version, except to acknowledge the contribution(s) of the 72 | Copyright Holder(s) and the Author(s) or with their explicit written 73 | permission. 74 | 75 | 5) The Font Software, modified or unmodified, in part or in whole, 76 | must be distributed entirely under this license, and must not be 77 | distributed under any other license. The requirement for fonts to 78 | remain under this license does not apply to any document created 79 | using the Font Software. 80 | 81 | TERMINATION 82 | This license becomes null and void if any of the above conditions are 83 | not met. 84 | 85 | DISCLAIMER 86 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 87 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 88 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 89 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 90 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 91 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 92 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 93 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 94 | OTHER DEALINGS IN THE FONT SOFTWARE. 95 | -------------------------------------------------------------------------------- /css/SilverbladeDecorative.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/css/SilverbladeDecorative.ttf -------------------------------------------------------------------------------- /css/blocuswebfont-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 10 07, Martin Desinde, 2 | with Reserved Font Name Blocus. 3 | 4 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 5 | This license is copied below, and is also available with a FAQ at: 6 | http://scripts.sil.org/OFL 7 | 8 | 9 | ----------------------------------------------------------- 10 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 11 | ----------------------------------------------------------- 12 | 13 | PREAMBLE 14 | The goals of the Open Font License (OFL) are to stimulate worldwide 15 | development of collaborative font projects, to support the font creation 16 | efforts of academic and linguistic communities, and to provide a free and 17 | open framework in which fonts may be shared and improved in partnership 18 | with others. 19 | 20 | The OFL allows the licensed fonts to be used, studied, modified and 21 | redistributed freely as long as they are not sold by themselves. The 22 | fonts, including any derivative works, can be bundled, embedded, 23 | redistributed and/or sold with any software provided that any reserved 24 | names are not used by derivative works. The fonts and derivatives, 25 | however, cannot be released under any other type of license. The 26 | requirement for fonts to remain under this license does not apply 27 | to any document created using the fonts or their derivatives. 28 | 29 | DEFINITIONS 30 | "Font Software" refers to the set of files released by the Copyright 31 | Holder(s) under this license and clearly marked as such. This may 32 | include source files, build scripts and documentation. 33 | 34 | "Reserved Font Name" refers to any names specified as such after the 35 | copyright statement(s). 36 | 37 | "Original Version" refers to the collection of Font Software components as 38 | distributed by the Copyright Holder(s). 39 | 40 | "Modified Version" refers to any derivative made by adding to, deleting, 41 | or substituting -- in part or in whole -- any of the components of the 42 | Original Version, by changing formats or by porting the Font Software to a 43 | new environment. 44 | 45 | "Author" refers to any designer, engineer, programmer, technical 46 | writer or other person who contributed to the Font Software. 47 | 48 | PERMISSION & CONDITIONS 49 | Permission is hereby granted, free of charge, to any person obtaining 50 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 51 | redistribute, and sell modified and unmodified copies of the Font 52 | Software, subject to the following conditions: 53 | 54 | 1) Neither the Font Software nor any of its individual components, 55 | in Original or Modified Versions, may be sold by itself. 56 | 57 | 2) Original or Modified Versions of the Font Software may be bundled, 58 | redistributed and/or sold with any software, provided that each copy 59 | contains the above copyright notice and this license. These can be 60 | included either as stand-alone text files, human-readable headers or 61 | in the appropriate machine-readable metadata fields within text or 62 | binary files as long as those fields can be easily viewed by the user. 63 | 64 | 3) No Modified Version of the Font Software may use the Reserved Font 65 | Name(s) unless explicit written permission is granted by the corresponding 66 | Copyright Holder. This restriction only applies to the primary font name as 67 | presented to the users. 68 | 69 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 70 | Software shall not be used to promote, endorse or advertise any 71 | Modified Version, except to acknowledge the contribution(s) of the 72 | Copyright Holder(s) and the Author(s) or with their explicit written 73 | permission. 74 | 75 | 5) The Font Software, modified or unmodified, in part or in whole, 76 | must be distributed entirely under this license, and must not be 77 | distributed under any other license. The requirement for fonts to 78 | remain under this license does not apply to any document created 79 | using the Font Software. 80 | 81 | TERMINATION 82 | This license becomes null and void if any of the above conditions are 83 | not met. 84 | 85 | DISCLAIMER 86 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 87 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 88 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 89 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 90 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 91 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 92 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 93 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 94 | OTHER DEALINGS IN THE FONT SOFTWARE. 95 | -------------------------------------------------------------------------------- /css/blocuswebfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/css/blocuswebfont.ttf -------------------------------------------------------------------------------- /css/channels.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/css/channels.css -------------------------------------------------------------------------------- /css/connections.css: -------------------------------------------------------------------------------- 1 | #connections > div > input { 2 | width: 100%; 3 | padding: 5px; 4 | } 5 | 6 | #connections .progressOuter { 7 | display: inline-block; 8 | width: 30%; 9 | margin-left: 1.2em; 10 | border: 1px solid #000; 11 | border-radius: 2px; 12 | vertical-align: middle; 13 | background: #fff; 14 | } 15 | 16 | #connections .progressOuter > div { 17 | margin: 2px; 18 | } 19 | 20 | #connections .progressInner { 21 | background: #999; 22 | background-image: linear-gradient(#777, #555); 23 | height: 1.5em; 24 | 25 | /* Default to 1% filled so there's a little sliver there */ 26 | width: 1%; 27 | } 28 | 29 | #connections div > div > input { 30 | padding: 5px; 31 | } 32 | 33 | #connections div > div > select { 34 | padding: 5px; 35 | } 36 | -------------------------------------------------------------------------------- /css/groups.css: -------------------------------------------------------------------------------- 1 | #groups > div > input { 2 | padding: 5px; 3 | margin: 0 0px 10px 0px; 4 | width: 13rem; 5 | } 6 | 7 | #groups ul.groupMembers 8 | { 9 | list-style: none; 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | #groups ul.groupMembers li > a > img 15 | { 16 | width: 50px; 17 | height: 50px; 18 | } 19 | 20 | #groups ul.groupMembers li 21 | { 22 | float: left; 23 | padding-right: 0.5rem; 24 | } 25 | 26 | #groups .clearingDiv 27 | { 28 | clear: both; 29 | } 30 | 31 | #groups .group 32 | { 33 | margin-bottom: 4em; 34 | } 35 | 36 | #groups .group > h4 37 | { 38 | margin-top: 0; 39 | float: left; 40 | } 41 | 42 | #groups .group > .groupActions 43 | { 44 | float: right; 45 | } 46 | -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,NotoColorEmoji; 3 | line-height: 1.5; 4 | overflow-wrap: break-word; 5 | } 6 | 7 | body { 8 | max-width: 45rem; 9 | padding: 1rem; 10 | margin: auto; 11 | background-color: #fdf6e3; 12 | 13 | /* mobile fix */ 14 | -webkit-text-size-adjust: none; 15 | -moz-text-size-adjust: none; 16 | -ms-text-size-adjust: none; 17 | } 18 | 19 | .router-link-exact-active { 20 | font-weight: bold; 21 | color: #000; 22 | text-decoration: none; 23 | } 24 | 25 | a { 26 | color: #000; 27 | text-decoration: underline; 28 | } 29 | 30 | #navigation { 31 | font-weight: 600; 32 | float: left; 33 | margin: 1em 0; 34 | } 35 | 36 | #navigation > a { 37 | color: #000; 38 | } 39 | 40 | #messages, #settings { 41 | font-weight: 400; 42 | font-style: normal; 43 | font-family: system-ui, sans-serif 44 | } 45 | 46 | #searchBox { 47 | display: inline-block; 48 | float: right; 49 | width: 150px; 50 | margin: 1em 0; 51 | } 52 | 53 | #goTo { 54 | float: right; 55 | width: 160px; 56 | padding: 5px; 57 | box-shadow: 0 0 0 rgba(0, 0, 0, 0); 58 | transition: width 0.5s, box-shadow 0.5s; 59 | } 60 | 61 | #goTo::placeholder { 62 | text-align: right; 63 | } 64 | 65 | #searchBox.expanded #goTo { 66 | width: 200px; 67 | box-shadow: 0 1px 1em rgba(0, 0, 0, 0.5); 68 | } 69 | 70 | #suggestionPositioning { 71 | position: relative; 72 | clear: right; 73 | } 74 | 75 | #searchBox .suggestion-box { 76 | width: 200px; 77 | position: absolute; 78 | right: 0; 79 | opacity: 0; 80 | visibility: hidden; 81 | transition: opacity 0.5s, visibility 0.5s; 82 | } 83 | 84 | #searchBox.expanded .suggestion-box { 85 | opacity: 1; 86 | visibility: visible; 87 | } 88 | 89 | #searchBox .tui-popup-body { 90 | font-size: medium; 91 | } 92 | 93 | #searchBox .suggestion-box img { 94 | width: 1.5em; 95 | height: 1.5em; 96 | margin-right: 2px; 97 | margin-left: -8px; 98 | vertical-align: middle; 99 | } 100 | 101 | #navClear { 102 | clear: both; 103 | } 104 | 105 | .message > span img { 106 | max-width: 100%; 107 | } 108 | 109 | .message > span video { 110 | max-width: 100%; 111 | } 112 | 113 | code { 114 | white-space: pre-wrap; 115 | } 116 | 117 | @font-face { 118 | font-family: 'NotoColorEmoji'; 119 | src: url('./NotoColorEmoji.ttf') format('truetype'); 120 | } 121 | 122 | .emoji { 123 | font-family: NotoColorEmoji; 124 | font-size: 120%; 125 | line-height: 1; 126 | } 127 | 128 | .theme-dark #channels ol, 129 | .message { 130 | padding: 1.5rem 1.5rem 0.5rem 1.5rem; 131 | margin: 1rem 0; 132 | border-radius: 2px; 133 | border: 1px solid hsl(240, 12%, 92%); 134 | word-wrap: break-word; 135 | background-color: white; 136 | } 137 | 138 | #public .message { 139 | max-height: 800px; 140 | overflow: hidden; 141 | } 142 | 143 | .message .contactMessage .contactLink, 144 | .message > .header { 145 | display: table; 146 | width: 100%; 147 | } 148 | 149 | .message .contactMessage .contactLink .profile .avatar, 150 | .message > .header > span > a > .avatar { 151 | width: 50px; 152 | height: 50px; 153 | padding-right: 10px; 154 | display: table-cell; 155 | } 156 | 157 | .message .contactMessage .contactLink .profile, 158 | .message > .header > .profile { 159 | display: table-cell; 160 | width: 50px; 161 | } 162 | 163 | .message .contactMessage .contactLink .text { 164 | vertical-align: middle; 165 | display: table-cell; 166 | } 167 | 168 | .message > .header > .text { 169 | vertical-align: top; 170 | display: table-cell; 171 | } 172 | 173 | .message > .header > .channel { 174 | vertical-align: top; 175 | display: table-cell; 176 | text-align: right; 177 | } 178 | 179 | .message > .header > .text > .date { 180 | font-size: small; 181 | margin-bottom: 5px; 182 | } 183 | 184 | .message .profileUpdate img.avatar { 185 | width: 50px; 186 | height: 50px; 187 | vertical-align: middle; 188 | } 189 | 190 | .messageTitle { 191 | width: 100%; 192 | margin-bottom: 0.5em; 193 | } 194 | 195 | .messageText { 196 | height: 10rem; 197 | width: 100%; 198 | max-width: 45rem; 199 | padding: 5px; 200 | } 201 | 202 | .clickButton { 203 | margin-top: 5px; 204 | text-transform: uppercase; 205 | font-weight: bold; 206 | font-size: .7rem; 207 | letter-spacing: 1.4px; 208 | color: #666; 209 | background-color: #fff; 210 | min-width: 6rem; 211 | min-height: 1.8rem; 212 | padding: .5rem 1rem; 213 | cursor: pointer; 214 | border: 1px solid rgb(169, 169, 169); 215 | } 216 | 217 | .newPublic, .newPrivate { 218 | cursor: pointer; 219 | } 220 | 221 | .fileInput { 222 | margin-left: 2rem; 223 | } 224 | 225 | /* pull to refresh */ 226 | body.refreshing #public { 227 | filter: blur(1px); 228 | touch-action: none; /* prevent scrolling */ 229 | } 230 | body.refreshing .refresher { 231 | transform: translate3d(0,150%,0) scale(1); 232 | z-index: 1; 233 | visibility: visible; 234 | } 235 | .refresher { 236 | pointer-events: none; 237 | --refresh-width: 100px; 238 | width: var(--refresh-width); 239 | height: var(--refresh-width); 240 | position: absolute; 241 | left: calc(50% - var(--refresh-width) / 2); 242 | visibility: hidden; 243 | } 244 | .refresher img { 245 | width: 100px; 246 | height: 100px; 247 | } 248 | 249 | body.ssbError .modalError { 250 | display: table; 251 | } 252 | .modalError { 253 | display: none; 254 | position: fixed; 255 | top: 0; 256 | left: 0; 257 | width: 100%; 258 | height: 100%; 259 | z-index: 9999; 260 | background: rgba(0, 0, 0, 0.7); 261 | backdrop-filter: blur(6px); 262 | } 263 | .modalError > div { 264 | display: table-cell; 265 | text-align: center; 266 | vertical-align: middle; 267 | } 268 | .modalError > div > div { 269 | display: inline-block; 270 | padding: 3em; 271 | background: #fff; 272 | color: #000; 273 | } 274 | 275 | body, html { 276 | overscroll-behavior-y: contain; /* disable pull to refresh, keeps glow effects */ 277 | } 278 | 279 | #footer { 280 | position: fixed; 281 | bottom: 0; 282 | margin-top: -1.5rem; 283 | height: 1.5rem; 284 | background-color: #fff; 285 | text-align: center; 286 | border-top: 1px solid #ccc; 287 | justify-content: space-evenly; 288 | display: none; 289 | width: 92%; 290 | } 291 | 292 | #footer span { 293 | font-size: x-small; 294 | } 295 | 296 | .navButton { 297 | margin: 0rem 0.5rem; 298 | text-transform: uppercase; 299 | font-size: x-small; 300 | font-weight: bold; 301 | color: #000; 302 | padding: 0.3rem 0rem; 303 | cursor: pointer; 304 | text-decoration: none; 305 | vertical-align: middle; 306 | } 307 | 308 | #navigation .avatar { 309 | width: 1.2em; 310 | height: 1.2em; 311 | vertical-align: middle; 312 | } 313 | 314 | #navigation .iconButton { 315 | text-decoration: none; 316 | } 317 | 318 | .navButton .avatar { 319 | width: 1.4em; 320 | height: 1.4em; 321 | vertical-align: middle; 322 | } 323 | 324 | #navigation .connected-indicator { 325 | width: 0.5em; 326 | display: inline-block; 327 | margin-left: -0.5em; 328 | position: relative; 329 | top: -0.5em; 330 | } 331 | 332 | #footer .newPublic, 333 | #footer .newPrivate, 334 | #footer .connected-indicator { 335 | margin-left: -1em; 336 | position: relative; 337 | top: -3px; 338 | } 339 | 340 | @media (max-width: 40em) 341 | { 342 | body { 343 | padding: 0.5em; 344 | } 345 | 346 | #app > h1 { 347 | margin-bottom: 0; 348 | } 349 | 350 | #searchBox { 351 | margin: 0.5em 0 0 0; 352 | } 353 | 354 | .message { 355 | padding: 1rem 1rem 0.25rem 1rem; 356 | } 357 | } 358 | 359 | /* Only switch to footer-based navigation when we're clearly on a mobile device and the screen's wide enough to support it. */ 360 | @media (min-resolution: 2dppx) and (max-width: 40em) and (min-width: 22em) 361 | { 362 | #navigation { 363 | display: none; 364 | } 365 | 366 | #footer { 367 | display: flex; 368 | width: 100%; 369 | margin-left: -0.5em; 370 | } 371 | } 372 | 373 | .reactions 374 | { 375 | display: flex; 376 | justify-content: space-between; 377 | border-top: 1px solid #ccc; 378 | } 379 | 380 | .reactions .reactions-existing, 381 | .reactions .reactions-mine, 382 | .reactions .reactions-new 383 | { 384 | display: inline-block; 385 | padding: 0.5em; 386 | } 387 | 388 | .reactions .reactions-mine, 389 | .reactions .reactions-new 390 | { 391 | border-left: 1px solid #ccc; 392 | } 393 | 394 | .reactions .reactions-mine a, 395 | .reactions .reactions-existing a, 396 | .reactions .reactions-new a 397 | { 398 | text-decoration: none; 399 | } 400 | 401 | .reactions .reactions-existing > span, 402 | .reactions .reactions-mine > span, 403 | .reactions .reactions-new > span 404 | { 405 | padding: 3px; 406 | } 407 | 408 | #channels ol 409 | { 410 | list-style: none; 411 | margin: 0; 412 | padding: 0; 413 | column-count: 3; 414 | column-gap: 3em; 415 | line-height: 125%; 416 | } 417 | 418 | #channels ol li 419 | { 420 | display: block; 421 | } 422 | 423 | #channels ol li a 424 | { 425 | display: inline-block; 426 | padding: 0.75em; 427 | } 428 | 429 | .get-started-button 430 | { 431 | background-color: #393; 432 | color: #fff; 433 | } 434 | 435 | .get-started-button:hover 436 | { 437 | background-color: #6c6; 438 | } 439 | 440 | a.blockedAvatar 441 | { 442 | display: inline-block; 443 | position: relative; 444 | } 445 | 446 | a.blockedAvatar img 447 | { 448 | filter: blur(4px); 449 | -moz-filter: blur(4px); 450 | } 451 | 452 | a.blockedAvatar span.blockedSymbol 453 | { 454 | display: block; 455 | font-size: 35px; 456 | line-height: 50px; 457 | position: absolute; 458 | top: 0; 459 | left: 0; 460 | width: 50px; 461 | text-align: center; 462 | } 463 | 464 | /* Emoji menu */ 465 | .message 466 | { 467 | position: relative; 468 | } 469 | 470 | .message .vue-simple-context-menu 471 | { 472 | max-width: 40%; 473 | font-family: inherit; 474 | } 475 | 476 | .message .vue-simple-context-menu li.vue-simple-context-menu__item 477 | { 478 | display: inline-block; 479 | padding: 5px; 480 | } 481 | 482 | textarea.source 483 | { 484 | width: 100%; 485 | height: 20em; 486 | white-space: nowrap; 487 | } 488 | 489 | .markdown-editor 490 | { 491 | background-color: #fff; 492 | } 493 | 494 | .vue-tablist 495 | { 496 | margin-bottom: 0; 497 | } 498 | 499 | .vue-tab 500 | { 501 | background: #e6e8eb; 502 | } 503 | 504 | .vue-tab[aria-selected="true"], 505 | .vue-tabpanel 506 | { 507 | background: #f7f9fc; 508 | } 509 | 510 | .vue-tabpanel 511 | { 512 | border: 1px solid #e2e2e2; 513 | border-top: none; 514 | padding: 0 10px 5px 10px; 515 | } 516 | 517 | .tab-clear-margin 518 | { 519 | font-size: 0; 520 | } 521 | 522 | p.warning, 523 | p.note 524 | { 525 | padding: 5px 10px; 526 | } 527 | 528 | p.warning 529 | { 530 | border: 1px solid #993333; 531 | background: #ffdddd; 532 | } 533 | 534 | p.note 535 | { 536 | border: 1px solid #dddd00; 537 | background: #ffff99; 538 | } 539 | -------------------------------------------------------------------------------- /css/modal.css: -------------------------------------------------------------------------------- 1 | .modal-mask { 2 | position: fixed; 3 | z-index: 9998; 4 | top: 0; 5 | left: 0; 6 | width: 100%; 7 | height: 100%; 8 | background-color: rgba(0, 0, 0, .5); 9 | display: table; 10 | transition: opacity .3s ease; 11 | } 12 | 13 | .modal-wrapper { 14 | display: table-cell; 15 | vertical-align: middle; 16 | } 17 | 18 | .modal-container { 19 | max-width: 42rem; 20 | margin: 0px auto; 21 | padding: 30px; 22 | background-color: #fff; 23 | color: #333; 24 | border-radius: 2px; 25 | box-shadow: 0 2px 8px rgba(0, 0, 0, .33); 26 | transition: all .3s ease; 27 | } 28 | 29 | .modal-body { 30 | margin: 20px 0; 31 | max-height: 600px; 32 | overflow: auto; 33 | } 34 | 35 | .modal-footer { 36 | height: 30px; 37 | } 38 | 39 | .modal-default-button { 40 | float: right; 41 | } 42 | 43 | .modal-enter { 44 | opacity: 0; 45 | } 46 | 47 | .modal-leave-active { 48 | opacity: 0; 49 | } 50 | 51 | .modal-enter .modal-container, 52 | .modal-leave-active .modal-container { 53 | -webkit-transform: scale(1.1); 54 | transform: scale(1.1); 55 | } 56 | 57 | .modal-container > h3 { 58 | margin-block-start: 0px; 59 | text-align: right; 60 | } 61 | -------------------------------------------------------------------------------- /css/private.css: -------------------------------------------------------------------------------- 1 | #private > span > .v-select { 2 | width: 500px; 3 | background-color: white; 4 | } 5 | 6 | #private > span > .v-select > ul > li > img.tinyAvatar { 7 | width: 35px; 8 | height: 35px; 9 | } 10 | 11 | #private > span > .v-select > ul > li > span { 12 | vertical-align: top; 13 | } 14 | 15 | #private > span > #recipients { 16 | padding: 5px; 17 | width: 45rem; 18 | margin: 10 0 10 0px; 19 | } 20 | 21 | #private > span > #subject { 22 | padding: 5px; 23 | width: 45rem; 24 | margin: 0 0 10 0px; 25 | } 26 | 27 | #private input { 28 | font-size: 1em; 29 | } 30 | 31 | #private > span > #subject { 32 | margin-top: 10px; 33 | } 34 | -------------------------------------------------------------------------------- /css/profile.css: -------------------------------------------------------------------------------- 1 | #profile > span { 2 | display: flex; 3 | } 4 | 5 | #profile > span > .avatar { 6 | flex: none; 7 | width: 13rem; 8 | margin-right: 1em; 9 | margin-bottom: 1em; 10 | } 11 | 12 | #profile > span > .avatar > img { 13 | width: 13rem; 14 | height: 13rem; 15 | } 16 | 17 | #profile .avatar.blockedAvatar 18 | { 19 | display: inline-block; 20 | position: relative; 21 | } 22 | 23 | #profile .avatar.blockedAvatar img 24 | { 25 | filter: blur(20px); 26 | -moz-filter: blur(20px); 27 | } 28 | 29 | #profile .avatar.blockedAvatar span.blockedSymbol 30 | { 31 | display: block; 32 | font-size: 9rem; 33 | line-height: 13rem; 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | width: 13rem; 38 | text-align: center; 39 | } 40 | 41 | #profile > span > .description { 42 | flex-grow: 4; 43 | } 44 | 45 | #profile > span > .description h2 { 46 | margin-top: 0; 47 | } 48 | 49 | #profile > h2 { 50 | clear: both; 51 | } 52 | 53 | #profile > h2 a { 54 | text-decoration: none; 55 | } 56 | 57 | #profile .profileButtons { 58 | clear: both; 59 | margin: 16px 0px 0px 0px; 60 | } 61 | 62 | #profile .profileButtons span.addToGroup div.v-select, 63 | #profile .profileButtons button.clickButton { 64 | margin-left: 1rem; 65 | margin-top: 16px; 66 | margin-bottom: 16px; 67 | } 68 | 69 | #profile .profileButtons hr { 70 | clear: both; 71 | } 72 | 73 | #profile > div#follows > div > a > img, 74 | #profile > div#blocked > div > a > img, 75 | #profile > div#blockingUs > div > a > img, 76 | #profile > div#followers > div > a > img { 77 | width: 50px; 78 | height: 50px; 79 | } 80 | 81 | #profile > div#follows > div, 82 | #profile > div#blocked > div, 83 | #profile > div#blockingUs > div, 84 | #profile > div#followers > div { 85 | float: left; 86 | padding-right: 0.5rem; 87 | } 88 | 89 | /* edit */ 90 | 91 | #profile > span > div > textarea { 92 | width: 100%; 93 | height: 15rem; 94 | } 95 | 96 | #profile .profileButtons button { 97 | float: right; 98 | } 99 | 100 | #profile > span > div > input#name { 101 | padding: 5px; 102 | margin: 0 0px 10px 0px; 103 | width: 100%; 104 | } 105 | 106 | #profile > span > div > input { 107 | width: 13rem; 108 | } 109 | 110 | /* mnemonic */ 111 | 112 | #profile > div.modal-mask > div > div > div > textarea { 113 | width: 100%; 114 | height: 10rem; 115 | } 116 | 117 | #profile span.addToGroup { 118 | display: inline-block; 119 | float: right; 120 | } 121 | 122 | #profile span.addToGroup div.v-select { 123 | display: inline-block; 124 | width: 20em; 125 | } 126 | 127 | @media (max-width: 40em) 128 | { 129 | #profile > span > .avatar { 130 | width: 7rem; 131 | } 132 | 133 | #profile > span > .avatar > img { 134 | width: 7rem; 135 | height: 7rem; 136 | } 137 | } 138 | 139 | @media (max-width: 22em) 140 | { 141 | #profile > span > .avatar { 142 | width: 3rem; 143 | } 144 | 145 | #profile > span > .avatar > img { 146 | width: 3rem; 147 | height: 3rem; 148 | } 149 | } 150 | 151 | -------------------------------------------------------------------------------- /css/public.css: -------------------------------------------------------------------------------- 1 | #public > #syncData { 2 | float: right; 3 | } 4 | 5 | #onboarding-dialog { 6 | position: absolute; 7 | } 8 | 9 | #public .channel-selector { 10 | float: right; 11 | width: 15rem; 12 | } 13 | 14 | #public .filter-line { 15 | clear: both; 16 | } 17 | -------------------------------------------------------------------------------- /css/theme-dark.css: -------------------------------------------------------------------------------- 1 | .theme-dark 2 | { 3 | background-color: #222; 4 | background-image: linear-gradient(#222, #000); 5 | color: #999; 6 | } 7 | 8 | .theme-dark #profile .description a, 9 | .theme-dark #navigation > a 10 | { 11 | color: #999; 12 | } 13 | 14 | .theme-dark .message, 15 | .theme-dark #threads > div, 16 | .theme-dark #channels ol 17 | { 18 | background-color: #999; 19 | color: #000; 20 | } 21 | 22 | .theme-dark #threads > div, 23 | .theme-dark #threads > div th, 24 | .theme-dark #threads > div td, 25 | .theme-dark .message a 26 | { 27 | color: #333; 28 | } 29 | 30 | .theme-dark .clickButton 31 | { 32 | background-color: #111; 33 | background-image: linear-gradient(#333, #000); 34 | color: #999; 35 | border-radius: 3px; 36 | } 37 | 38 | .theme-dark input[type="text"] 39 | { 40 | background-color: #000; 41 | background-image: linear-gradient(#000, #333); 42 | color: #999; 43 | border: 1px solid rgb(169, 169, 169) 44 | } 45 | 46 | .theme-dark h2 > a.refresh 47 | { 48 | color: #ccc; 49 | } 50 | -------------------------------------------------------------------------------- /css/theme-ethereal.css: -------------------------------------------------------------------------------- 1 | .theme-ethereal 2 | { 3 | background-color: #667b9e; 4 | background-image: url("../assets/openclipart-1392738806.svg"); /* By: anarres, from: https://openclipart.org/detail/191047/background-design */ 5 | background-attachment: fixed; 6 | background-size: 100%; 7 | background-repeat: none; 8 | color: #fff; 9 | } 10 | 11 | .theme-ethereal #profile .description a, 12 | .theme-ethereal #navigation > a 13 | { 14 | color: #fff; 15 | } 16 | 17 | .theme-ethereal .message, 18 | .theme-ethereal #threads > div, 19 | .theme-ethereal #channels ol 20 | { 21 | background-color: rgba(255, 255, 255, 0.5); 22 | backdrop-filter: blur(8px); 23 | color: #000; 24 | border-radius: 5px; 25 | } 26 | 27 | .theme-ethereal #threads > div, 28 | .theme-ethereal #threads > div th, 29 | .theme-ethereal #threads > div td 30 | { 31 | color: #555; 32 | } 33 | 34 | .theme-ethereal .message a 35 | { 36 | color: #333; 37 | } 38 | 39 | .theme-ethereal .clickButton 40 | { 41 | background-color: #85abc6; 42 | background-image: linear-gradient(#a7ceea, #495f80); 43 | color: #fff; 44 | border-radius: 3px; 45 | border-color: #fff; 46 | } 47 | 48 | .theme-ethereal input[type="text"] 49 | { 50 | background-color: #85abc6; 51 | background-image: linear-gradient(#495f80, #85abc6); 52 | color: #fff; 53 | border: 1px solid #fff; 54 | } 55 | 56 | .theme-ethereal input[type="text"]::placeholder 57 | { 58 | color: #a7ceea; 59 | } 60 | -------------------------------------------------------------------------------- /css/theme-floralgardenbird.css: -------------------------------------------------------------------------------- 1 | @font-face 2 | { 3 | /* From: https://fontlibrary.org/en/font/blocus */ 4 | font-family: "BlocusRegular"; 5 | src: url("./blocuswebfont.ttf"); 6 | } 7 | 8 | .theme-floralgardenbird 9 | { 10 | background-color: #fbebf4ff; 11 | background-image: url("../assets/openclipart-223534.png"); /* By: GDJ, from: https://openclipart.org/detail/223534/floral-garden-seamless-pattern */ 12 | background-size: 50%; 13 | color: #000; 14 | } 15 | 16 | .theme-floralgardenbird h1, 17 | .theme-floralgardenbird h2 18 | { 19 | color: #8d2a89; 20 | font-family: "BlocusRegular", monospace; 21 | } 22 | 23 | .theme-floralgardenbird h1 24 | { 25 | font-size: 400%; 26 | margin: 0; 27 | } 28 | 29 | .theme-floralgardenbird h2 30 | { 31 | font-size: 170%; 32 | } 33 | 34 | .theme-floralgardenbird #app 35 | { 36 | margin: -3em; 37 | padding: 3em; 38 | background-color: rgba(255, 255, 255, 0.5); 39 | backdrop-filter: blur(12px); 40 | -webkit-backdrop-filter: blur(12px); 41 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); 42 | } 43 | 44 | .theme-floralgardenbird #profile .description a, 45 | .theme-floralgardenbirg #navigation > a 46 | { 47 | color: #fff; 48 | } 49 | 50 | .theme-floralgardenbird .message, 51 | .theme-floatlgardenbird #channels ol 52 | { 53 | border-color: #ccc; 54 | border-radius: 5px; 55 | color: #000; 56 | } 57 | 58 | .theme-floralgardenbird .message a 59 | { 60 | color: #333; 61 | } 62 | 63 | .theme-floralgardenbird .clickButton 64 | { 65 | background-color: #ccc; 66 | background-image: linear-gradient(#fff, #aaa); 67 | color: #000; 68 | border-radius: 3px; 69 | border-color: #000; 70 | } 71 | 72 | .theme-floralgardenbird input[type="text"] 73 | { 74 | background-color: #ddd; 75 | background-image: linear-gradient(#bbb, #fff); 76 | color: #000; 77 | border: 1px solid #000; 78 | } 79 | 80 | @media (min-resolution: 2dppx) and (max-width: 40em) 81 | { 82 | .theme-floralgardenbird #app 83 | { 84 | /* This messes up the positioning of the footer if not removed, so in the interest of readability, we're going to use a solid color instead. */ 85 | backdrop-filter: none; 86 | -webkit-backdrop-filter: none; 87 | background: #fbebf4ff; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /css/theme-pirateship.css: -------------------------------------------------------------------------------- 1 | @font-face 2 | { 3 | /* From: https://fontlibrary.org/en/font/silverblade */ 4 | font-family: "SilverbladeDecorative"; 5 | src: url("./SilverbladeDecorative.ttf"); 6 | } 7 | 8 | .theme-pirateship 9 | { 10 | background-color: #0193cc; 11 | background-image: url("../assets/seabed-155896303267m-optimized.jpg"); /* By: Petr Kratochvil, licensed as CC0, from: https://www.publicdomainpictures.net/en/view-image.php?image=295900 */ 12 | background-attachment: fixed; 13 | background-size: 100%; 14 | background-repeat: none; 15 | background-position: center bottom; 16 | color: #fff; 17 | } 18 | 19 | .theme-pirateship a 20 | { 21 | color: #fff; 22 | } 23 | 24 | .theme-pirateship #app 25 | { 26 | margin: -3em; 27 | padding: 3em; 28 | background-image: url("../assets/old-barn-wood-background-recolored-2-tiled.jpg"); /* By: Sheila Brown, licensed as CC0, from: https://www.publicdomainpictures.net/en/view-image.php?image=265825 */ 29 | background-color: #35271b; 30 | background-repeat: repeat; 31 | background-size: 100%; 32 | box-shadow: 0 0 100px rgba(0, 0, 0, 1.0); 33 | border-left: 0.25em solid #5c4c3e; 34 | border-right: 0.5em solid #26190b; 35 | } 36 | 37 | .theme-pirateship h1, 38 | .theme-pirateship h2 39 | { 40 | font-family: "SilverbladeDecorative", serif, -apple-system,BlinkMacSystemFont,Segoe UI,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,NotoColorEmoji; 41 | } 42 | 43 | .theme-pirateship h1 44 | { 45 | font-size: 405%; 46 | margin: 0; 47 | text-align: justify; 48 | } 49 | 50 | .theme-pirateship h2 51 | { 52 | font-size: 170%; 53 | } 54 | 55 | .theme-pirateship #profile .description a, 56 | .theme-pirateship #navigation > a 57 | { 58 | color: #fff; 59 | } 60 | 61 | .theme-pirateship .message, 62 | .theme-pirateship #threads > div, 63 | .theme-pirateship #channels ol 64 | { 65 | background-color: rgba(255, 255, 255, 0.5); 66 | backdrop-filter: blur(8px); 67 | color: #000; 68 | border-radius: 5px; 69 | } 70 | 71 | .theme-pirateship #threads > div, 72 | .theme-pirateship #threads > div th, 73 | .theme-pirateship #threads > div td 74 | { 75 | color: #333; 76 | } 77 | 78 | .theme-pirateship #threads > div td a 79 | { 80 | color: #111; 81 | } 82 | 83 | .theme-pirateship .message a 84 | { 85 | color: #333; 86 | } 87 | 88 | .theme-pirateship .clickButton 89 | { 90 | background-color: #333; 91 | background-image: linear-gradient(#999, #000); 92 | color: #fff; 93 | border-radius: 3px; 94 | border: 3px solid #fff; 95 | } 96 | 97 | .theme-pirateship input[type="text"], 98 | .theme-pirateship .v-select 99 | { 100 | background-color: #666; 101 | background-image: linear-gradient(#000, #333); 102 | color: #fff; 103 | border: 3px solid #fff; 104 | } 105 | 106 | .theme-pirateship .v-select input[type="search"] 107 | { 108 | color: #ccc; 109 | } 110 | 111 | .theme-pirateship input[type="text"]::placeholder 112 | { 113 | color: #999; 114 | } 115 | 116 | .theme-pirateship #footer .newPublic > span, 117 | .theme-pirateship #footer .newPrivate > span, 118 | .theme-pirateship .newPublic > span, 119 | .theme-pirateship .newPrivate > span 120 | { 121 | font-size: 0; 122 | } 123 | 124 | .theme-pirateship #footer .newPublic > span:before, 125 | .theme-pirateship #footer .newPrivate > span:before, 126 | .theme-pirateship .newPublic > span:before, 127 | .theme-pirateship .newPrivate > span:before 128 | { 129 | content: "☠️"; 130 | font-size: medium; 131 | padding-left: 0.25em; 132 | } 133 | 134 | .theme-pirateship #footer a 135 | { 136 | color: #000; 137 | } 138 | 139 | @media (min-resolution: 2dppx) and (max-width: 40em) 140 | { 141 | .theme-pirateship h1 142 | { 143 | font-size: 210%; 144 | } 145 | 146 | .theme-pirateship h2 147 | { 148 | font-size: 150%; 149 | } 150 | 151 | .theme-pirateship #footer 152 | { 153 | margin-left: -0.75em; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /css/theme-seigaihasubtle.css: -------------------------------------------------------------------------------- 1 | .theme-seigaihasubtle 2 | { 3 | background-color: #f7ebe9; 4 | background-image: url("../assets/openclipart-261618.svg"); /* By: yamachem, from: https://openclipart.org/detail/261618/seigaiha-subtle-color */ 5 | background-attachment: fixed; 6 | color: #000; 7 | } 8 | 9 | .theme-seigaihasubtle #profile .description a, 10 | .theme-seigaihasubtle #navigation > a 11 | { 12 | color: #000; 13 | } 14 | 15 | .theme-seigaihasubtle .message, 16 | .theme-seigaihasubtle #threads > div, 17 | .theme-seigaihasubtle #channels ol 18 | { 19 | background-color: #fcf9f9; 20 | backdrop-filter: blur(8px); 21 | color: #000; 22 | border-radius: 5px; 23 | } 24 | 25 | .theme-seigaihasubtle .message a 26 | { 27 | color: #333; 28 | } 29 | 30 | .theme-seigaihasubtle .clickButton 31 | { 32 | background-color: #e6dad8; 33 | background-image: linear-gradient(#fcf9f9, #e6dad8); 34 | color: #000; 35 | border-radius: 3px; 36 | border-color: #fff; 37 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); 38 | } 39 | 40 | .theme-seigaihasubtle input[type="text"] 41 | { 42 | background-color: #85abc6; 43 | background-image: linear-gradient(#e6dad8, #f7ebe9); 44 | color: #000; 45 | border: 1px solid #fff; 46 | box-shadow: 1px 2px 6px #fff; 47 | } 48 | 49 | .theme-seigaihasubtle input[type="text"]::placeholder 50 | { 51 | color: #777; 52 | } 53 | -------------------------------------------------------------------------------- /css/thread.css: -------------------------------------------------------------------------------- 1 | #thread > div#blockingReplies > a > img { 2 | width: 50px; 3 | height: 50px; 4 | } 5 | 6 | #thread > div#blockingReplies > a { 7 | display: inline-block; 8 | float: left; 9 | padding-right: 0.5rem; 10 | } 11 | 12 | #thread > div#blockingReplies .clearingDiv { 13 | clear: both; 14 | margin-bottom: 1em; 15 | } 16 | -------------------------------------------------------------------------------- /css/threads.css: -------------------------------------------------------------------------------- 1 | #threads table 2 | { 3 | width: 100%; 4 | } 5 | 6 | #threads table tr th, 7 | #threads table tr td 8 | { 9 | padding: 0.5em; 10 | } 11 | 12 | #threads table tr th 13 | { 14 | text-align: left; 15 | } 16 | 17 | #threads table tr td 18 | { 19 | border-top: 1px solid rgba(0, 0, 0, 0.25) 20 | } 21 | 22 | #threads table tr:nth-of-type(even) td 23 | { 24 | background: rgba(0, 0, 0, 0.03) 25 | } 26 | 27 | #threads table tr td:nth-of-type(2) 28 | { 29 | background: rgba(0, 0, 0, 0.05) 30 | } 31 | 32 | #threads table tr th:last-of-type, 33 | #threads table tr td:last-of-type 34 | { 35 | max-width: 10em; 36 | white-space: nowrap; 37 | overflow: hidden; 38 | text-overflow: ellipsis; 39 | } 40 | 41 | #threads img.avatar 42 | { 43 | width: 1em; 44 | height: 1em; 45 | vertical-align: middle; 46 | } 47 | -------------------------------------------------------------------------------- /dedupe.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Dedupe pull-stream. 3 | sed -i 's/2.26.0/3.6.14/' node_modules/pull-async-filter/package.json 4 | rm -rf node_modules/pull-async-filter/node_modules/pull-stream 5 | sed -i 's/3.5.0/3.6.14/' node_modules/pull-goodbye/package.json 6 | rm -rf node_modules/pull-goodbye/node_modules/pull-stream 7 | 8 | # Patch out Highlight.js. 9 | sed -i -e '/highlight: function/,+8 d' -e '/highlight/d' node_modules/ssb-markdown/lib/block.js 10 | sed -i '/highlight.js/d' node_modules/ssb-markdown/package.json 11 | rm -rf node_modules/highlight.js 12 | 13 | # Dedupe tweetnacl. 14 | sed -i 's/0.14.1/1.0.3/' node_modules/sodium-browserify/package.json 15 | rm -rf node_modules/sodium-browserify/node_modules/tweetnacl 16 | sed -i 's/0.x.x/1.0.3/' node_modules/tweetnacl-auth/package.json 17 | rm -rf node_modules/tweetnacl-auth/node_modules/tweetnacl 18 | sed -i 's/0.x.x/1.0.3/' node_modules/ed2curve/package.json 19 | rm -rf node_modules/ed2curve/node_modules/tweetnacl 20 | -------------------------------------------------------------------------------- /defaultprefs.json: -------------------------------------------------------------------------------- 1 | { 2 | "appTitle": "SSB Browser Demo", 3 | "replicationHops": 2, 4 | "publicFilters": "", 5 | "favoriteChannels": [], 6 | "hiddenChannels": [], 7 | "autorefresh": false, 8 | "offlineMode": false, 9 | "dhtEnabled": false, 10 | "detailedLogging": false, 11 | "suggestPeers": [ 12 | { "name": "Between Two Worlds Pub", "type": "pub", "address": "wss:between-two-worlds.dk:8989~shs:lbocEWqF2Fg6WMYLgmfYvqJlMfL7hiqVAV6ANjHWNw8=" }, 13 | { "name": "Between Two Worlds Room", "type": "room", "address": "wss:between-two-worlds.dk:9999~shs:7R5/crt8/icLJNpGwP2D7Oqz2WUd7ObCIinFKVR6kNY=", "default": false } 14 | ], 15 | "suggestFollows": [ 16 | { "name": "arj03 Phone", "key": "@VIOn+8a/vaQvv/Ew3+KriCngyUXHxHbjXkj4GafBAY0=.ed25519" }, 17 | { "name": "arj03 Desktop", "key": "@6CAxOI3f+LUOVrbAl0IemqiS7ATpQvr9Mdw9LC4+Uv0=.ed25519", "default": false }, 18 | { "name": "andrestaltz", "key": "@QlCTpvY7p9ty2yOFrv1WU1AE88aoQc4Y7wYal7PFc+w=.ed25519", "default": false } 19 | ], 20 | "theme": "default" 21 | } 22 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/favicon.ico -------------------------------------------------------------------------------- /hermies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arj03/ssb-browser-demo/b05fa82ce5a07830f1cb1489df14406665387dfb/hermies.png -------------------------------------------------------------------------------- /indexes/channels.js: -------------------------------------------------------------------------------- 1 | const bipf = require('bipf') 2 | const pull = require('pull-stream') 3 | const pl = require('pull-level') 4 | const Plugin = require('ssb-db2/indexes/plugin') 5 | 6 | const bValue = Buffer.from('value') 7 | const bContent = Buffer.from('content') 8 | const bType = Buffer.from('type') 9 | const bChannel = Buffer.from('channel') 10 | const bPost = Buffer.from('post') 11 | 12 | module.exports = class Channel extends Plugin { 13 | constructor(log, dir) { 14 | super(log, dir, 'channels', 2, undefined, 'json') 15 | this.channels = {} 16 | } 17 | 18 | processRecord(record, processed) { 19 | const recBuffer = record.value 20 | if (!recBuffer) return // deleted 21 | 22 | let p = 0 // note you pass in p! 23 | p = bipf.seekKey(recBuffer, p, bValue) 24 | if (p < 0) return 25 | 26 | const pContent = bipf.seekKey(recBuffer, p, bContent) 27 | if (pContent < 0) return 28 | 29 | const pType = bipf.seekKey(recBuffer, pContent, bType) 30 | if (pType < 0) return 31 | 32 | if (bipf.compareString(recBuffer, pType, bPost) === 0) { 33 | const pChannel = bipf.seekKey(recBuffer, pContent, bChannel) 34 | if (pChannel < 0) return 35 | 36 | let channel = bipf.decode(recBuffer, pChannel) 37 | if (!channel || channel == '') return 38 | channel = channel.replace(/^[#]+/, '') 39 | 40 | if (!this.channels[channel]) 41 | this.channels[channel] = { id: channel, count: 0 } 42 | ++this.channels[channel].count 43 | 44 | this.batch.push({ 45 | type: 'put', 46 | key: channel, 47 | value: this.channels[channel] 48 | }) 49 | } 50 | } 51 | 52 | onLoaded(cb) { 53 | console.time("start channels get") 54 | pull( 55 | pl.read(this.level, { 56 | gte: '', 57 | lte: undefined, 58 | keyEncoding: this.keyEncoding, 59 | valueEncoding: this.valueEncoding, 60 | keys: true 61 | }), 62 | pull.drain( 63 | (data) => this.channels[data.key] = data.value, 64 | () => { 65 | console.timeEnd("start channels get") 66 | cb() 67 | }) 68 | ) 69 | } 70 | 71 | getChannels() { 72 | return Object.keys(this.channels) 73 | } 74 | 75 | getChannelUsage(channel) { 76 | return (this.channels[channel] && this.channels[channel].count) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /localprefs.js: -------------------------------------------------------------------------------- 1 | const defaultPrefs = require("./defaultprefs.json") 2 | 3 | function getPref(prefName, defValue) { 4 | const pref = localStorage.getItem('/.ssb-browser-demo/' + prefName); 5 | if(pref && pref != '') 6 | return pref 7 | 8 | return defValue 9 | } 10 | 11 | function setPref(prefName, value) { 12 | localStorage.setItem('/.ssb-browser-demo/' + prefName, value) 13 | } 14 | 15 | exports.getHops = function() { return getPref('replicationHops', (defaultPrefs.replicationHops || 1)) } 16 | 17 | exports.setHops = function(hops) { setPref('replicationHops', hops) } 18 | 19 | exports.getCaps = function() { 20 | const caps = require("ssb-caps") 21 | return getPref('caps', caps.shs) 22 | } 23 | 24 | exports.setCaps = function(caps) { setPref('caps', caps) } 25 | 26 | exports.getAppTitle = function() { return getPref('appTitle', (defaultPrefs.appTitle || 'SSB Browser Demo')) } 27 | 28 | exports.setAppTitle = function(title) { setPref('appTitle', title) } 29 | 30 | exports.getTheme = function() { return getPref('theme', (defaultPrefs.theme || 'default')) } 31 | 32 | exports.setTheme = function(theme) { setPref('theme', theme) } 33 | 34 | exports.getPublicFilters = function() { return getPref('publicFilters', (defaultPrefs.publicFilters || '')) } 35 | 36 | exports.setPublicFilters = function(filterNamesSeparatedByPipes) { setPref('publicFilters', filterNamesSeparatedByPipes) } 37 | 38 | exports.getFavoriteChannels = function() { return JSON.parse(getPref('favoriteChannels', JSON.stringify(defaultPrefs.favoriteChannels || []))) } 39 | 40 | exports.setFavoriteChannels = function(favoriteChannelsArray) { setPref('favoriteChannels', JSON.stringify(favoriteChannelsArray)) } 41 | 42 | exports.getFavoriteGroups = function() { return JSON.parse(getPref('favoriteGroups', JSON.stringify(defaultPrefs.favoriteGroups || []))) } 43 | 44 | exports.setFavoriteGroups = function(favoriteGroupsArray) { setPref('favoriteGroups', JSON.stringify(favoriteGroupsArray)) } 45 | 46 | exports.getHiddenChannels = function() { return JSON.parse(getPref('hiddenChannels', JSON.stringify(defaultPrefs.hiddenChannels || []))) } 47 | 48 | exports.setHiddenChannels = function(hiddenChannelsArray) { setPref('hiddenChannels', JSON.stringify(hiddenChannelsArray)) } 49 | 50 | exports.getLocale = function() { return getPref('locale', (defaultPrefs.locale || '')) } 51 | 52 | exports.setLocale = function(locale) { setPref('locale', locale) } 53 | 54 | exports.getAutorefresh = function() { return (getPref('autorefresh', (typeof defaultPrefs.autorefresh != 'undefined' ? (defaultPrefs.autorefresh ? 'true' : 'false') : 'false')) != 'false') } 55 | 56 | exports.setAutorefresh = function(isOn) { setPref('autorefresh', (isOn ? 'true' : 'false')) } 57 | 58 | exports.getSearchDepth = function() { return getPref('searchDepth', (defaultPrefs.searchDepth || 10000)) } 59 | 60 | exports.setSearchDepth = function(numMessages) { setPref('searchDepth', numMessages) } 61 | 62 | exports.getOfflineMode = function() { return (getPref('offlineMode', (typeof defaultPrefs.offlineMode != 'undefined' ? (defaultPrefs.offlineMode ? 'true' : 'false') : 'true')) == 'true') } 63 | 64 | exports.setOfflineMode = function(isOn) { setPref('offlineMode', (isOn ? 'true' : 'false')) } 65 | 66 | exports.getUserGroups = function() { return JSON.parse(getPref('userGroups', JSON.stringify(defaultPrefs.userGroups || []))) } 67 | 68 | exports.setUserGroups = function(userGroupArray) { setPref('userGroups', JSON.stringify(userGroupArray)) } 69 | 70 | exports.getDetailedLogging = function() { return (getPref('detailedLogging', (typeof defaultPrefs.detailedLogging != 'undefined' ? (defaultPrefs.detailedLogging ? 'true' : 'false') : 'false')) != 'false') } 71 | 72 | exports.setDetailedLogging = function(isOn) { setPref('detailedLogging', (isOn ? 'true' : 'false')) } 73 | 74 | exports.getDHTEnabled = function() { return (getPref('dhtEnabled', (typeof defaultPrefs.dhtEnabled != 'undefined' ? (defaultPrefs.dhtEnabled ? 'true' : 'false') : 'false')) != 'false') } 75 | 76 | exports.setDHTEnabled = function(isEnabled) { setPref('dhtEnabled', (isEnabled ? 'true' : 'false')) } 77 | 78 | exports.updateStateFromSettings = function() { 79 | // Update the running state to match the stored settings. 80 | document.body.classList.add('theme-' + this.getTheme()) 81 | for(var i = 0; i < document.body.classList.length; ++i) { 82 | const cur = document.body.classList.item(i) 83 | if(cur.substring(0, ('theme-').length) == 'theme-' && cur != 'theme-' + this.getTheme()) { 84 | document.body.classList.remove(cur) 85 | --i 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SSB browser demo", 3 | "short_name": "SSBBrowserDemo", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#fdf6e3", 7 | "icons": [{ 8 | "src": "hermies.png", 9 | "type": "image/png", 10 | "sizes": "256x256" 11 | }] 12 | } 13 | -------------------------------------------------------------------------------- /net.js: -------------------------------------------------------------------------------- 1 | // this file is loaded from ui/browser.js when SSB is ready 2 | const pull = require('pull-stream') 3 | 4 | // this uses https://github.com/arj03/ssb-partial-replication 5 | SSB.syncFeedAfterFollow = function(feedId) { 6 | SSB.syncFeedFromSequence(feedId, 0) 7 | } 8 | 9 | SSB.syncFeedFromSequence = function(feedId, sequence, cb) { 10 | let rpc = SSB.getPeer() 11 | 12 | var seqStart = sequence - 100 13 | if (seqStart < 0) 14 | seqStart = 0 15 | 16 | console.log(`seq ${seqStart} feedId: ${feedId}`) 17 | console.time("downloading messages") 18 | 19 | pull( 20 | rpc.partialReplication.getFeed({ id: feedId, seq: seqStart, keys: false }), 21 | pull.asyncMap(SSB.db.addOOO), 22 | pull.collect((err, msgs) => { 23 | if (err) throw err 24 | 25 | console.timeEnd("downloading messages") 26 | console.log(msgs.length) 27 | 28 | if (cb) cb() 29 | }) 30 | ) 31 | } 32 | 33 | SSB.syncFeedFromLatest = function(feedId, cb) { 34 | let rpc = SSB.getPeer() 35 | 36 | console.time("downloading messages") 37 | 38 | pull( 39 | rpc.partialReplication.getFeedReverse({ id: feedId, keys: false, limit: 25 }), 40 | pull.asyncMap(SSB.db.addOOO), 41 | pull.collect((err, msgs) => { 42 | if (err) throw err 43 | 44 | console.timeEnd("downloading messages") 45 | 46 | if (cb) cb() 47 | }) 48 | ) 49 | } 50 | 51 | syncThread = function(messages, cb) { 52 | pull( 53 | pull.values(messages), 54 | pull.filter((msg) => msg && msg.content.type == "post"), 55 | pull.asyncMap(SSB.db.addOOO), 56 | pull.collect(cb) 57 | ) 58 | } 59 | 60 | // this uses https://github.com/arj03/ssb-partial-replication 61 | SSB.getThread = function(msgId, cb) { 62 | SSB.connectedWithData(() => { 63 | let rpc = SSB.getPeer() 64 | 65 | rpc.partialReplication.getTangle(msgId, (err, messages) => { 66 | if (err) return cb(err) 67 | 68 | syncThread(messages, cb) 69 | }) 70 | }) 71 | } 72 | 73 | SSB.activeConnections = 0 74 | SSB.activeConnectionsWithData = 0 75 | SSB.callbacksWaitingForConnection = [] 76 | SSB.callbacksWaitingForConnectionWithData = [] 77 | SSB.callbacksWaitingForDisconnect = [] 78 | function runConnectedCallbacks() { 79 | while(SSB.callbacksWaitingForConnection.length > 0) { 80 | const cb = SSB.callbacksWaitingForConnection.shift() 81 | cb(SSB) 82 | } 83 | } 84 | 85 | function runConnectedWithDataCallbacks() { 86 | while(SSB.callbacksWaitingForConnectionWithData.length > 0) { 87 | const cb = SSB.callbacksWaitingForConnectionWithData.shift() 88 | cb(SSB) 89 | } 90 | } 91 | 92 | function runDisconnectedCallbacks() { 93 | while(SSB.callbacksWaitingForDisconnect.length > 0) { 94 | const cb = SSB.callbacksWaitingForDisconnect.shift() 95 | cb(SSB) 96 | } 97 | } 98 | 99 | SSB.isConnected = function() { 100 | return (SSB.activeConnections > 0) 101 | } 102 | 103 | SSB.isConnectedWithData = function() { 104 | return (SSB.activeConnectionsWithData > 0) 105 | } 106 | 107 | SSB.connected = function(cb) { 108 | // Add the callback to the list. 109 | SSB.callbacksWaitingForConnection.push(cb); 110 | 111 | if(SSB.isConnected()) { 112 | // Already connected. Run all the callbacks. 113 | runConnectedCallbacks() 114 | } 115 | } 116 | 117 | SSB.connectedWithData = function(cb) { 118 | // Register a callback for when we're connected to a peer with data (not a room). 119 | SSB.callbacksWaitingForConnectionWithData.push(cb); 120 | 121 | if(SSB.isConnectedWithData()) { 122 | // Already connected. Run all the callbacks. 123 | runConnectedWithDataCallbacks() 124 | } 125 | } 126 | 127 | SSB.disconnected = function(cb) { 128 | // Register a callback for when we're no longer connected to any peer. 129 | SSB.callbacksWaitingForDisconnect.push(cb); 130 | 131 | if(!SSB.isConnected()) { 132 | // Already connected. Run all the callbacks. 133 | runDisconnectedCallbacks() 134 | } 135 | } 136 | 137 | // Register for the connect event so we can keep track of it. 138 | SSB.net.on('rpc:connect', (rpc) => { 139 | // Now we're connected. Run all the callbacks. 140 | ++SSB.activeConnections 141 | runConnectedCallbacks() 142 | 143 | // See if we're operating on a connection with actual data (not a room). 144 | let connPeers = Array.from(SSB.net.conn.hub().entries()) 145 | connPeers = connPeers.filter(([,x])=>!!x.key).map(([address,data])=>({ 146 | address, 147 | data 148 | })) 149 | var peer = connPeers.find(x=>x.data.key == rpc.id) 150 | if (peer && peer.data.type != 'room') { 151 | // It's not a room. 152 | ++SSB.activeConnectionsWithData 153 | runConnectedWithDataCallbacks() 154 | 155 | // Register another callback to decrement our "connections with data" reference count. 156 | rpc.on('closed', () => --SSB.activeConnectionsWithData) 157 | } 158 | 159 | // Register an event handler for disconnects so we know to trigger waiting again. 160 | rpc.on('closed', () => { 161 | --SSB.activeConnections 162 | if (SSB.activeConnections == 0) { 163 | runDisconnectedCallbacks() 164 | } 165 | }) 166 | }) 167 | 168 | SSB.getOOO = function(msgId, cb) { 169 | SSB.connectedWithData((rpc) => { 170 | SSB.net.ooo.get(msgId, cb) 171 | }) 172 | } 173 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssb-browser-demo", 3 | "description": "", 4 | "version": "5.6.0", 5 | "homepage": "https://github.com/arj03/ssb-browser-demo", 6 | "repository": { 7 | "type": "git", 8 | "url": "git@github.com:arj03/ssb-browser-demo.git" 9 | }, 10 | "dependencies": { 11 | "@toast-ui/vue-editor": "^3.0.0", 12 | "clipboard-copy": "^4.0.1", 13 | "envify": "^4.1.0", 14 | "human-time": "0.0.2", 15 | "minisearch": "^3.0.2", 16 | "node-emoji": "^1.10.0", 17 | "pull-async-filter": "^1.0.0", 18 | "pull-cat": "^1.1.11", 19 | "pull-stream": "^3.6.14", 20 | "rimraf": "^3.0.2", 21 | "ssb-browser-core": "^11.2.0", 22 | "ssb-caps": "^1.1.0", 23 | "ssb-keys-mnemonic": "^1.0.1", 24 | "ssb-markdown": "^6.0.7", 25 | "ssb-mentions": "^0.5.2", 26 | "ssb-ref": "^2.14.3", 27 | "ssb-sort": "^1.1.3", 28 | "ssb-threads": "^8.0.0", 29 | "vue": "^2.6.12", 30 | "vue-i18n": "^8.22.4", 31 | "vue-router": "^3.5.1", 32 | "vue-select": "^3.11.2", 33 | "vue-simple-context-menu": "^3.4.1", 34 | "vue-slim-tabs": "^0.4.0" 35 | }, 36 | "devDependencies": { 37 | "browserify": "^17.0.0", 38 | "esmify": "^2.1.1", 39 | "inline-source-cli": "^2.0.0", 40 | "terser": "^5.6.0", 41 | "uglifyify": "^5.0.2", 42 | "workbox-build": "^6.1.0" 43 | }, 44 | "scripts": { 45 | "postinstall": "./dedupe.sh", 46 | "build": "mkdir -p build && NODE_ENV=production browserify -g envify -p esmify --full-paths ui/browser.js > build/bundle-ui.js && node write-dist.js", 47 | "release": "mkdir -p build && NODE_ENV=production browserify -g envify -g uglifyify -p esmify ui/browser.js | terser -c passes=2 --safari10 -o build/bundle-ui.js && node write-dist.js", 48 | "inline": "mkdir -p build && browserify -g uglifyify -p esmify ui/browser.js | terser -c passes=2 --ie8 --safari10 -o build/bundle-ui.js && node write-dist.js && ./convert-to-inline.sh", 49 | "finddupes": "npx find-duplicate-dependencies" 50 | }, 51 | "author": "arj", 52 | "license": "Beerware" 53 | } 54 | -------------------------------------------------------------------------------- /profile.js: -------------------------------------------------------------------------------- 1 | SSB.getProfileName = function(profileId) { 2 | const profile = SSB.db.getIndex("aboutSelf").getProfile(profileId) 3 | if (profile) return profile.name 4 | else return '' 5 | } 6 | 7 | SSB.getProfile = function(profileId) { 8 | return SSB.db.getIndex("aboutSelf").getProfile(profileId) 9 | } 10 | 11 | SSB.searchProfiles = function(search, results = 10) { 12 | const profilesDict = SSB.db.getIndex("aboutSelf").getProfiles() 13 | const profiles = [] 14 | for (let p in profilesDict) { 15 | const pValue = profilesDict[p] 16 | profiles.push({ id: p, name: pValue.name, imageURL: pValue.imageURL }) 17 | } 18 | const lowerSearch = search.toLowerCase() 19 | return profiles.filter(x => x.name && x.name.toLowerCase().startsWith(lowerSearch)).slice(0, results) 20 | } 21 | -------------------------------------------------------------------------------- /search.js: -------------------------------------------------------------------------------- 1 | const MiniSearch = require('minisearch').default 2 | const { and, descending, paginate, type, isPublic, toPullStream } = SSB.dbOperators 3 | const pull = require('pull-stream') 4 | const localPrefs = require('./localprefs') 5 | 6 | var mostRecentMessage = null 7 | 8 | function indexNewPosts(cb) { 9 | pull( 10 | SSB.db.query( 11 | and(type('post'), isPublic()), 12 | descending(), 13 | paginate(SSB.search.depth), 14 | toPullStream() 15 | ), 16 | pull.take(1), 17 | pull.drain((msgs) => { 18 | for (m in msgs) { 19 | if (msgs[m].key == mostRecentMessage) 20 | break 21 | if (msgs[m].value && msgs[m].value.content && msgs[m].value.content.text) 22 | SSB.search.miniSearch.add({ key: msgs[m].key, text: msgs[m].value.content.text }) 23 | } 24 | if (msgs && msgs.length > 0) 25 | mostRecentMessage = msgs[0].key 26 | cb() 27 | }) 28 | ) 29 | } 30 | 31 | if (!SSB.search) { 32 | SSB.search = { 33 | depth: localPrefs.getSearchDepth(), 34 | miniSearch: new MiniSearch({ 35 | fields: ['text'], 36 | idField: 'key' 37 | }), 38 | resetIndex: function() { 39 | mostRecentMessage = null 40 | }, 41 | fullTextSearch: function(searchTerm, cb) { 42 | indexNewPosts(() => { 43 | try { 44 | var results = SSB.search.miniSearch.search(searchTerm, { fuzzy: 0.1 }) 45 | } catch(e) { 46 | cb(e) 47 | return 48 | } 49 | 50 | // Sometimes MiniSearch returns duplicates. Deduplicate them. 51 | var seenKeys = [] 52 | var filteredResults = [] 53 | for (r in results) { 54 | if (seenKeys.indexOf(results[r].id) < 0) { 55 | filteredResults.push(results[r]) 56 | seenKeys.push(results[r].id) 57 | } 58 | } 59 | 60 | cb(null, filteredResults) 61 | }) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ssb-singleton-setup.js: -------------------------------------------------------------------------------- 1 | const localPrefs = require('./localprefs') 2 | const pull = require('pull-stream') 3 | 4 | const config = { 5 | caps: { shs: Buffer.from(localPrefs.getCaps(), 'base64') }, 6 | friends: { 7 | hops: localPrefs.getHops(), 8 | hookReplicate: false 9 | }, 10 | connections: (localPrefs.getDHTEnabled() ? { 11 | incoming: { 12 | tunnel: [{ scope: 'public', transform: 'shs' }], 13 | dht: [{ scope: 'public', transform: 'shs' }] 14 | }, 15 | outgoing: { 16 | net: [{ transform: 'shs' }], 17 | ws: [{ transform: 'shs' }, { transform: 'noauth' }], 18 | tunnel: [{ transform: 'shs' }], 19 | dht: [{ transform: 'shs' }] 20 | } 21 | } : { 22 | incoming: { 23 | tunnel: [{ scope: 'public', transform: 'shs' }] 24 | }, 25 | outgoing: { 26 | net: [{ transform: 'shs' }], 27 | ws: [{ transform: 'shs' }, { transform: 'noauth' }], 28 | tunnel: [{ transform: 'shs' }] 29 | } 30 | } 31 | ), 32 | hops: localPrefs.getHops(), 33 | core: { 34 | startOffline: localPrefs.getOfflineMode() 35 | }, 36 | ebt: { 37 | logging: localPrefs.getDetailedLogging() 38 | }, 39 | conn: { 40 | autostart: false, 41 | hops: localPrefs.getHops(), 42 | populatePubs: false 43 | } 44 | } 45 | 46 | function extraModules(secretStack) { 47 | return secretStack.use({ 48 | init: function (sbot, config) { 49 | sbot.db.registerIndex(require('ssb-db2/indexes/full-mentions')) 50 | } 51 | }) 52 | .use({ 53 | init: function (sbot, config) { 54 | sbot.db.registerIndex(require('ssb-db2/indexes/about-self')) 55 | } 56 | }) 57 | .use({ 58 | init: function (sbot, config) { 59 | sbot.db.registerIndex(require('./indexes/channels')) 60 | } 61 | }) 62 | .use(require("ssb-threads")) 63 | } 64 | 65 | function ssbLoaded() { 66 | // add helper methods 67 | SSB = window.singletonSSB 68 | require('./net') 69 | require('./profile') 70 | require('./search') 71 | 72 | pull(SSB.net.conn.hub().listen(), pull.drain((ev) => { 73 | if (ev.type.indexOf("failed") >= 0) 74 | console.warn("Connection error: ", ev) 75 | })) 76 | } 77 | 78 | require('ssb-browser-core/ssb-singleton').init(config, extraModules, ssbLoaded) 79 | -------------------------------------------------------------------------------- /start-server.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | npm install 3 | npm run build 4 | pushd dist 5 | npx http-server 6 | popd 7 | -------------------------------------------------------------------------------- /ui/browser.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | const componentsState = require('./components')() 3 | const VueI18n = require('vue-i18n').default 4 | const i18nMessages = require('../messages.json') 5 | const helpers = require('./helpers') 6 | const pull = require('pull-stream') 7 | const ref = require('ssb-ref') 8 | require('../ssb-singleton-setup') 9 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 10 | const localPrefs = require('../localprefs') 11 | const clickCatcher = require('../click-catcher') 12 | 13 | // Load local preferences. 14 | localPrefs.updateStateFromSettings(); 15 | 16 | if ((location.hostname == 'localhost' || location.protocol === 'https:') && 'serviceWorker' in navigator) { 17 | window.addEventListener('load', function() { 18 | navigator.serviceWorker.register('sw.js'); 19 | }) 20 | } 21 | 22 | function loadVue() { 23 | // Make sure settings have been applied. 24 | localPrefs.updateStateFromSettings(); 25 | 26 | // Start this before Vue so that it gets first dibs on events. 27 | clickCatcher.start() 28 | 29 | const Public = require('./public')(componentsState) 30 | const Profile = require('./profile')() 31 | const Notifications = require('./notifications')() 32 | const Channel = require('./channel')() 33 | const Group = require('./group')() 34 | const Channels = require('./channels')() 35 | const Groups = require('./groups')() 36 | const Thread = require('./thread')() 37 | const Threads = require('./threads')(componentsState) 38 | const Private = require('./private')(componentsState) 39 | const Connections = require('./connections')() 40 | const Settings = require('./settings')() 41 | const Search = require('./search')() 42 | 43 | const routes = [ 44 | { name: 'public', path: '/public', component: Public }, 45 | { name: 'channels', path: '/channels', component: Channels }, 46 | { name: 'groups', path: '/groups', component: Groups }, 47 | { name: 'channel', path: '/channel/:channel', component: Channel, props: true }, 48 | { name: 'group', path: '/group/:group', component: Group, props: true }, 49 | { name: 'threads', path: '/threads', component: Threads }, 50 | { name: 'thread', path: '/thread/:rootId', component: Thread, props: true }, 51 | { name: 'profile', path: '/profile/:feedId', component: Profile, props: true }, 52 | { name: 'search', path: '/search/:search', component: Search, props: true }, 53 | { name: 'notifications', path: '/notifications', component: Notifications }, 54 | { path: '/private', component: Private }, 55 | { name: 'private-feed', path: '/private/:feedId', component: Private, props: true }, 56 | { path: '/connections', component: Connections }, 57 | { path: '/settings', component: Settings }, 58 | { path: '/', redirect: 'public' }, 59 | ] 60 | 61 | const router = new VueRouter({ 62 | routes 63 | }) 64 | 65 | var defaultLocale = (navigator.language || (navigator.languages ? navigator.languages[0] : navigator.browserLanguage ? navigator.browserLanguage : null)) 66 | var localePref = localPrefs.getLocale() 67 | if(localePref && localePref != '') 68 | defaultLocale = localePref 69 | if (!i18nMessages[defaultLocale]) 70 | defaultLocale = 'en' 71 | const i18n = new VueI18n({ 72 | locale: defaultLocale, 73 | fallbackLocale: 'en', 74 | silentFallbackWarn: true, 75 | messages: i18nMessages 76 | }) 77 | 78 | // Just in case a tab doesn't set this. 79 | document.title = localPrefs.getAppTitle() 80 | 81 | const app = new Vue({ 82 | router, 83 | 84 | i18n, 85 | 86 | data: function() { 87 | return { 88 | appTitle: localPrefs.getAppTitle(), 89 | showSource: false, 90 | sourceHtml: "", 91 | suggestions: [], 92 | feedId: "", 93 | goToTargetText: "" 94 | } 95 | }, 96 | 97 | methods: { 98 | targetFocus: function() { 99 | document.getElementById("searchBox").className = "expanded" 100 | }, 101 | 102 | targetBlur: function() { 103 | document.getElementById("searchBox").className = "" 104 | }, 105 | 106 | openSource: function(sourceHtml) { 107 | this.sourceHtml = sourceHtml 108 | this.showSource = true 109 | }, 110 | 111 | closeSource: function() { 112 | this.showSource = false 113 | }, 114 | 115 | suggestTarget: function() { 116 | [ err, SSB ] = ssbSingleton.getSSB() 117 | 118 | if (this.goToTargetText.startsWith('@')) { 119 | const profiles = SSB.searchProfiles(this.goToTargetText.substring(1), 5) 120 | // For consistency with the Markdown editor. 121 | const newSuggestions = profiles.map((x) => { return { type: "profile", id: x.id, text: "@" + x.name, icon: x.imageURL || helpers.getMissingProfileImage() }}) 122 | this.suggestions = newSuggestions 123 | } else if (this.goToTargetText.startsWith('#')) { 124 | const searchForChannel = this.goToTargetText.substring(1) 125 | const allChannels = SSB.db.getIndex("channels").getChannels() 126 | const sortFunc = (new Intl.Collator()).compare 127 | const filteredChannels = allChannels.filter((x) => { return x.toLowerCase().startsWith(searchForChannel.toLowerCase()) }) 128 | .sort(sortFunc) 129 | .slice(0, 5) 130 | var newSuggestions = [] 131 | for (c in filteredChannels) 132 | newSuggestions.push({ type: "channel", text: "#" + filteredChannels[c], value: filteredChannels[c] }) 133 | this.suggestions = newSuggestions 134 | } else { 135 | this.suggestions = [] 136 | } 137 | }, 138 | 139 | useSuggestion: function(suggestion) { 140 | this.goToTargetText = suggestion.text 141 | this.goToTarget() 142 | }, 143 | 144 | goToTarget: function() { 145 | [ err, SSB ] = ssbSingleton.getSSB() 146 | 147 | if (this.goToTargetText != '' && this.goToTargetText.startsWith('%')) { 148 | if (ref.isMsg(this.goToTargetText)) { 149 | router.push({ name: 'thread', params: { rootId: this.goToTargetText.substring(1) } }) 150 | this.goToTargetText = "" 151 | this.suggestions = [] 152 | } else 153 | alert(this.$root.$t('common.searchErrorInvalidMsg')) 154 | } else if (this.goToTargetText != '' && this.goToTargetText.startsWith('#')) { 155 | const searchForChannel = this.goToTargetText.substring(1) 156 | const allChannels = SSB.db.getIndex("channels").getChannels() 157 | if (allChannels.indexOf(this.goToTargetText.substring(1)) < 0 && this.suggestions.length > 0) 158 | this.goToTargetText = this.suggestions[0].text 159 | router.push({ name: 'channel', params: { channel: this.goToTargetText.substring(1) } }) 160 | this.goToTargetText = "" 161 | this.suggestions = [] 162 | } else if (this.goToTargetText != '' && this.goToTargetText.startsWith('@')) { 163 | // If it's not a valid profile ID, try doing a text search. 164 | const profiles = SSB.db.getIndex("aboutSelf") 165 | const profile = (ref.isFeed(this.goToTargetText) ? profiles.getProfile(this.goToTargetText) : null) 166 | if (!profile || Object.keys(profile).length == 0) { 167 | // We could use searchProfiles here, but this gives us a little more exact results in case the user skipped the suggestions. 168 | const profilesDict = profiles.profiles 169 | const searchText = this.goToTargetText.substring(1) 170 | var exactMatch = null 171 | var caselessMatch = null 172 | var similar = null 173 | for (p in profilesDict) { 174 | if (profilesDict[p].name == searchText) { 175 | exactMatch = p 176 | break 177 | } else if (!caselessMatch && profilesDict[p].name && profilesDict[p].name.toLowerCase() == searchText.toLowerCase()) { 178 | caselessMatch = p 179 | } else if (!similar && profilesDict[p].name && profilesDict[p].name.toLowerCase().startsWith(searchText.toLowerCase())) { 180 | similar = p 181 | } 182 | } 183 | this.goToTargetText = (exactMatch || caselessMatch || similar || this.goToTargetText) 184 | if (!exactMatch && !caselessMatch && !similar) 185 | alert(this.$root.$t('common.unableToFindProfile')) 186 | } 187 | 188 | router.push({ name: 'profile', params: { feedId: this.goToTargetText } }) 189 | this.goToTargetText = "" 190 | this.suggestions = [] 191 | } else { 192 | router.push({ name: 'search', params: { search: this.goToTargetText } }) 193 | this.goToTargetText = "" 194 | this.suggestions = [] 195 | } 196 | } 197 | } 198 | 199 | }).$mount('#app') 200 | 201 | function updateFeedId() { 202 | if (app.$data.feedId == "") { 203 | [ err, SSB ] = ssbSingleton.getSSB() 204 | 205 | if (SSB && SSB.net && SSB.net.id) 206 | app.$data.feedId = SSB.net.id 207 | else 208 | setTimeout(updateFeedId, 500) 209 | } 210 | } 211 | updateFeedId() 212 | } 213 | 214 | ssbSingleton.onError(function(err) { 215 | document.body.classList.add('ssbError') 216 | document.getElementById("modalErrorMessage").innerHTML = err 217 | }) 218 | ssbSingleton.onSuccess(function() { 219 | document.body.classList.remove('ssbError') 220 | }); 221 | 222 | // Attempt to start SSB. 223 | [ err, SSB ] = ssbSingleton.getSSB() 224 | 225 | loadVue() 226 | })() 227 | -------------------------------------------------------------------------------- /ui/channel.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | const pull = require('pull-stream') 3 | const ssbMentions = require('ssb-mentions') 4 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 5 | 6 | return { 7 | template: ` 8 |
9 |

{{ $t('channel.title', { name: channel }) }}

10 | 11 | 12 | 13 | 14 |

{{ $t('common.lastXMessages', { count: pageSize }) }} 15 | 16 |

17 | 18 | 19 |

{{ $t('common.noMessages') }}

20 |

{{ $t('common.showingMessagesFrom') }} 1-{{ displayPageEnd }}
21 | 22 |

23 | 24 |
`, 25 | 26 | props: ['channel'], 27 | 28 | data: function() { 29 | return { 30 | componentStillLoaded: false, 31 | postMessageVisible: false, 32 | postText: "", 33 | offset: 0, 34 | pageSize: 50, 35 | displayPageEnd: 50, 36 | autorefreshTimer: 0, 37 | showPreview: false, 38 | messages: [] 39 | } 40 | }, 41 | 42 | methods: { 43 | loadMore: function() { 44 | ssbSingleton.getSimpleSSBEventually(() => this.componentStillLoaded, this.loadMoreCB) 45 | }, 46 | 47 | loadMoreCB: function(err, SSB) { 48 | const { where, and, or, channel, isPublic, type, descending, startFrom, paginate, toCallback } = SSB.dbOperators 49 | SSB.db.query( 50 | where( 51 | and( 52 | or( 53 | channel(this.channel), 54 | channel("#" + this.channel) 55 | ), 56 | type('post'), 57 | isPublic() 58 | ) 59 | ), 60 | descending(), 61 | startFrom(this.offset), 62 | paginate(this.pageSize), 63 | toCallback((err, answer) => { 64 | this.messages = this.messages.concat(answer.results) 65 | this.displayPageEnd = this.offset + this.pageSize 66 | this.offset += this.pageSize // If we go by result length and we have filtered out all messages, we can never get more. 67 | }) 68 | ) 69 | }, 70 | 71 | onScroll: function() { 72 | const scrollTop = (typeof document.body.scrollTop != 'undefined' ? document.body.scrollTop : window.scrollY) 73 | 74 | if (scrollTop == 0) { 75 | // At the top of the page. Enable autorefresh 76 | var self = this 77 | this.autorefreshTimer = setTimeout(() => { 78 | self.autorefreshTimer = 0 79 | self.onScroll() 80 | self.refresh() 81 | }, (this.messages.length > 0 ? 30000 : 3000)) 82 | } else { 83 | clearTimeout(this.autorefreshTimer) 84 | this.autorefreshTimer = 0 85 | } 86 | }, 87 | 88 | render: function () { 89 | this.loadMore() 90 | }, 91 | 92 | onFileSelect: function(ev) { 93 | var self = this 94 | helpers.handleFileSelect(ev, false, (err, text) => { 95 | self.postText += text 96 | }) 97 | }, 98 | 99 | closePreview: function() { 100 | this.showPreview = false 101 | }, 102 | 103 | onPost: function() { 104 | if (!this.postMessageVisible) { 105 | this.postMessageVisible = true 106 | return 107 | } 108 | 109 | this.postText = this.$refs.markdownEditor.getMarkdown() 110 | 111 | // Make sure the full post (including headers) is not larger than the 8KiB limit. 112 | var postData = this.buildPostData() 113 | if (JSON.stringify(postData).length > 8192) { 114 | alert(this.$root.$t('common.postTooLarge')) 115 | return 116 | } 117 | 118 | if (this.postText == '') { 119 | alert(this.$root.$t('channel.blankFieldError')) 120 | return 121 | } 122 | 123 | this.showPreview = true 124 | }, 125 | 126 | buildPostData: function() { 127 | var mentions = ssbMentions(this.postText) 128 | 129 | var postData = { type: 'post', channel: this.channel, text: this.postText, mentions: mentions } 130 | 131 | return postData 132 | }, 133 | 134 | confirmPost: function() { 135 | [ err, SSB ] = ssbSingleton.getSSB() 136 | if (!SSB || !SSB.db) { 137 | alert("Can't post right now. Couldn't lock the database. Please make sure you only have one running instance of ssb-browser.") 138 | return 139 | } 140 | 141 | var self = this 142 | 143 | var postData = this.buildPostData() 144 | 145 | SSB.db.publish(postData, (err) => { 146 | if (err) console.log(err) 147 | 148 | self.postText = "" 149 | self.postMessageVisible = false 150 | self.showPreview = false 151 | 152 | self.refresh() 153 | }) 154 | }, 155 | 156 | refresh: function() { 157 | console.log("Refreshing") 158 | this.messages = [] 159 | this.offset = 0 160 | this.render() 161 | } 162 | }, 163 | 164 | created: function () { 165 | this.componentStillLoaded = true 166 | 167 | document.title = this.$root.appTitle + " - " + this.$root.$t('channel.title', { name: this.channel }) 168 | 169 | window.addEventListener('scroll', this.onScroll) 170 | this.onScroll() 171 | this.render() 172 | }, 173 | 174 | destroyed: function() { 175 | this.componentStillLoaded = false 176 | window.removeEventListener('scroll', this.onScroll) 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /ui/channels.js: -------------------------------------------------------------------------------- 1 | module.exports = function (componentsState) { 2 | const pull = require('pull-stream') 3 | const localPrefs = require('../localprefs') 4 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 5 | 6 | return { 7 | template: ` 8 |
9 |

{{ $t('channels.title') }}

10 |
11 |

{{ $t('channels.favoriteChannels') }}

12 |
    13 |
  1. 14 | #{{ channel }} 15 |
  2. 16 |
17 |

{{ $t('channels.otherChannels') }}

18 |
19 | 24 |
    25 |
  1. 26 | #{{ channel }}[ {{ count }} ] 27 |
  2. 28 |
29 |

{{ $t('channels.loading') }}

30 |
`, 31 | 32 | data: function() { 33 | return { 34 | channels: [], 35 | favoriteChannels: [], 36 | componentStillLoaded: false, 37 | sortMode: "recent" 38 | } 39 | }, 40 | 41 | methods: { 42 | load: function() { 43 | ssbSingleton.getSimpleSSBEventually(() => this.componentStillLoaded, this.renderChannelsCB) 44 | }, 45 | 46 | renderChannelsCB: function(err, SSB) { 47 | const { where, and, not, isPublic, type, channel, startFrom, paginate, descending, toCallback } = SSB.dbOperators 48 | document.body.classList.add('refreshing') 49 | 50 | // Get favorite channels from preferences. 51 | this.favoriteChannels = localPrefs.getFavoriteChannels().map(x => x.replace(/^#+/, '')).sort(Intl.Collator().compare) 52 | 53 | console.time("channel list") 54 | 55 | const resultCallback = toCallback((err, answer) => { 56 | if (!err) { 57 | var newChannels = {} 58 | 59 | var posts = (answer.results ? answer.results : answer); 60 | 61 | for (r in posts) { 62 | var channel = posts[r].value.content.channel 63 | 64 | if(channel && channel.charAt(0) == '#') 65 | channel = channel.substring(1, channel.length) 66 | 67 | if (channel && channel != '' && channel != '"') { 68 | if(!newChannels[channel]) 69 | newChannels[channel] = 0 70 | 71 | ++newChannels[channel] 72 | } 73 | } 74 | 75 | // Sort. 76 | this.channels = {}; 77 | var sortFunc = Intl.Collator().compare 78 | if(this.sortMode == "recent" || this.sortMode == "popular") 79 | sortFunc = (a, b) => { 80 | // Compare based on number of posts. 81 | if(newChannels[a] < newChannels[b]) 82 | return 1 83 | 84 | if(newChannels[a] > newChannels[b]) 85 | return -1 86 | 87 | return 0 88 | } 89 | 90 | Object.keys(newChannels).sort(sortFunc).forEach((item, index, array) => { 91 | this.channels[item] = newChannels[item] 92 | }); 93 | } 94 | 95 | document.body.classList.remove('refreshing') 96 | console.timeEnd("channel list") 97 | }) 98 | 99 | if(this.sortMode == "recent") { 100 | // Only look at the most recent posts. 101 | SSB.db.query( 102 | where( 103 | and( 104 | not(channel('')), 105 | type('post'), 106 | isPublic() 107 | ) 108 | ), 109 | descending(), 110 | startFrom(0), 111 | paginate(500), 112 | resultCallback 113 | ) 114 | } else { 115 | // Just pull channels. 116 | const channelIndex = SSB.db.getIndex("channels") 117 | const allChannels = channelIndex.getChannels() 118 | const sortFunc = (new Intl.Collator()).compare 119 | var transformedChannels = {} 120 | if (this.sortMode == "popular") { 121 | for (c in allChannels) 122 | transformedChannels[allChannels[c]] = (channelIndex.getChannelUsage(allChannels[c]) || 0) 123 | var newChannels = {} 124 | const sortByPopularity = (a, b) => { 125 | if(transformedChannels[a] < transformedChannels[b]) 126 | return 1 127 | if(transformedChannels[a] > transformedChannels[b]) 128 | return -1 129 | return 0 130 | } 131 | Object.keys(transformedChannels).sort(sortByPopularity).forEach((item, index, array) => { 132 | newChannels[item] = transformedChannels[item] 133 | }); 134 | this.channels = newChannels 135 | } else { 136 | const filteredChannels = allChannels.sort(sortFunc) 137 | for (c in filteredChannels) 138 | transformedChannels[filteredChannels[c]] = 0 139 | this.channels = transformedChannels 140 | } 141 | document.body.classList.remove('refreshing') 142 | console.timeEnd("channel list") 143 | } 144 | }, 145 | }, 146 | 147 | created: function () { 148 | this.componentStillLoaded = true 149 | 150 | document.title = this.$root.appTitle + " - " + this.$root.$t('channels.title') 151 | 152 | this.load() 153 | }, 154 | 155 | destroyed: function () { 156 | this.componentStillLoaded = false 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /ui/common-contextmenu.js: -------------------------------------------------------------------------------- 1 | Vue.component('common-contextmenu', { 2 | template: ` 3 |
4 | 5 |
`, 6 | 7 | data: function() { 8 | return { 9 | options: [] 10 | } 11 | }, 12 | 13 | methods: { 14 | showMenu: function(event, options, context) { 15 | this.options = options 16 | this.$refs.internalContextMenu.showMenu(event, context) 17 | }, 18 | 19 | optionClicked: function(event) { 20 | event.option.cb(event.item) 21 | } 22 | }, 23 | 24 | created: function() { 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /ui/components.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | require('./ssb-profile-link') 3 | require('./ssb-profile-name-link') 4 | require('./ssb-msg') 5 | require('./ssb-msg-preview') 6 | require('./dht-invite') 7 | require('./onboarding-dialog') 8 | require('./common-contextmenu') 9 | require('./view-source') 10 | require('./connected') 11 | const { Editor } = require('@toast-ui/vue-editor') 12 | const { VueSimpleContextMenu } = require("vue-simple-context-menu") 13 | const { Tabs, Tab } = require("vue-slim-tabs") 14 | require('./markdown-editor') 15 | 16 | Vue.component('v-select', VueSelect.VueSelect) 17 | Vue.component('vue-simple-context-menu', VueSimpleContextMenu) 18 | Vue.component('tui-editor', Editor) 19 | Vue.component('tabs', Tabs) 20 | Vue.component('tab', Tab) 21 | 22 | const state = { 23 | publicRefreshTimer: 0, 24 | newPublicMessages: false, 25 | newPrivateMessages: false 26 | } 27 | 28 | require('./new-public-messages')(state) 29 | require('./new-private-messages')(state) 30 | 31 | return state 32 | } 33 | -------------------------------------------------------------------------------- /ui/connected.js: -------------------------------------------------------------------------------- 1 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 2 | 3 | Vue.component('connected', { 4 | template: ` 5 | 6 | 7 | 8 | 9 | 10 | 11 | `, 12 | 13 | data: function() { 14 | return { 15 | initializedSSB: false, 16 | greenIndicator: false, 17 | yellowIndicator: false, 18 | connected: false, 19 | synced: false 20 | } 21 | }, 22 | 23 | methods: { 24 | onConnected: function() { 25 | this.connected = true 26 | this.updateIndicators() 27 | SSB.disconnected(this.onDisconnected) 28 | }, 29 | 30 | onDisconnected: function() { 31 | this.connected = false 32 | this.updateIndicators() 33 | SSB.connected(this.onConnected) 34 | }, 35 | 36 | updateIndicators: function() { 37 | this.greenIndicator = this.connected && this.synced 38 | this.yellowIndicator = this.connected && !this.synced 39 | } 40 | }, 41 | 42 | created: function() { 43 | var self = this 44 | 45 | ssbSingleton.onChangeSSB(function() { 46 | self.initializedSSB = false 47 | }) 48 | setInterval(function() { 49 | [ err, SSB ] = ssbSingleton.getSSB() 50 | if (SSB && SSB.net.feedReplication) { 51 | if (!self.initializedSSB) { 52 | self.initializedSSB = true 53 | self.onDisconnected() 54 | } 55 | self.synced = SSB.net.feedReplication.inSync() 56 | } 57 | 58 | self.updateIndicators() 59 | }, 1000) 60 | } 61 | }) 62 | -------------------------------------------------------------------------------- /ui/dht-invite.js: -------------------------------------------------------------------------------- 1 | Vue.component('dht-invite', { 2 | template: ` 3 | 4 | 19 | `, 20 | 21 | props: ['inviteCode', 'onClose', 'show'], 22 | 23 | computed: { 24 | inviteCodeDisplay: function() { 25 | if (this.show) 26 | return this.inviteCode 27 | else 28 | return "" 29 | } 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /ui/group.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | const pull = require('pull-stream') 3 | const ssbMentions = require('ssb-mentions') 4 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 5 | const userGroups = require('../usergroups') 6 | 7 | return { 8 | template: ` 9 |
10 |

{{ $t('group.title', { name: groupName }) }}

11 | 12 | 13 | 14 | 15 |

{{ $t('common.lastXMessages', { count: pageSize }) }} 16 | 17 |

18 | 19 | 20 |

{{ $t('common.searchingForMessages') }}

21 |

{{ $t('common.noMessages') }}

22 |

{{ $t('common.showingMessagesFrom') }} 1-{{ displayPageEnd }}
23 | 24 |

25 | 26 |
`, 27 | 28 | props: ['group'], 29 | 30 | data: function() { 31 | return { 32 | componentStillLoaded: false, 33 | groupName: "Loading...", 34 | groupMembers: [], 35 | postMessageVisible: false, 36 | postText: "", 37 | offset: 0, 38 | pageSize: 50, 39 | displayPageEnd: 50, 40 | autorefreshTimer: 0, 41 | showPreview: false, 42 | triedToLoadMessages: false, 43 | messages: [] 44 | } 45 | }, 46 | 47 | methods: { 48 | loadMore: function() { 49 | ssbSingleton.getSimpleSSBEventually(() => this.componentStillLoaded, this.loadMoreCB) 50 | }, 51 | 52 | loadMoreCB: function(err, SSB) { 53 | const { where, and, or, author, isPublic, type, descending, startFrom, paginate, toCallback } = SSB.dbOperators 54 | try { 55 | SSB.db.query( 56 | where( 57 | and( 58 | or(...this.groupMembers.map(x => author(x))), 59 | isPublic(), 60 | type('post') 61 | ) 62 | ), 63 | descending(), 64 | startFrom(this.offset), 65 | paginate(this.pageSize), 66 | toCallback((err, answer) => { 67 | this.triedToLoadMessages = true 68 | this.messages = this.messages.concat(answer.results) 69 | this.displayPageEnd = this.offset + this.pageSize 70 | this.offset += this.pageSize // If we go by result length and we have filtered out all messages, we can never get more. 71 | }) 72 | ) 73 | } catch(e) { 74 | // Probably no messages and crashed the query with "TypeError: Cannot set property 'meta' of undefined" 75 | this.triedToLoadMessages = true 76 | } 77 | }, 78 | 79 | render: function () { 80 | this.loadMore() 81 | }, 82 | 83 | onFileSelect: function(ev) { 84 | var self = this 85 | helpers.handleFileSelect(ev, false, (err, text) => { 86 | self.postText += text 87 | }) 88 | }, 89 | 90 | closePreview: function() { 91 | this.showPreview = false 92 | }, 93 | 94 | onPost: function() { 95 | if (!this.postMessageVisible) { 96 | this.postMessageVisible = true 97 | return 98 | } 99 | 100 | this.postText = this.$refs.markdownEditor.getMarkdown() 101 | 102 | // Make sure the full post (including headers) is not larger than the 8KiB limit. 103 | var postData = this.buildPostData() 104 | if (JSON.stringify(postData).length > 8192) { 105 | alert(this.$root.$t('common.postTooLarge')) 106 | return 107 | } 108 | 109 | if (this.postText == '') { 110 | alert(this.$root.$t('channel.blankFieldError')) 111 | return 112 | } 113 | 114 | this.showPreview = true 115 | }, 116 | 117 | buildPostData: function() { 118 | var mentions = ssbMentions(this.postText) 119 | 120 | var postData = { type: 'post', text: this.postText, mentions: mentions } 121 | 122 | return postData 123 | }, 124 | 125 | confirmPost: function() { 126 | [ err, SSB ] = ssbSingleton.getSSB() 127 | if (!SSB || !SSB.db) { 128 | alert("Can't post right now. Couldn't lock the database. Please make sure you only have one running instance of ssb-browser.") 129 | return 130 | } 131 | 132 | var self = this 133 | 134 | var postData = this.buildPostData() 135 | 136 | SSB.db.publish(postData, (err) => { 137 | if (err) console.log(err) 138 | 139 | self.postText = "" 140 | self.postMessageVisible = false 141 | self.showPreview = false 142 | 143 | self.refresh() 144 | }) 145 | }, 146 | 147 | refresh: function() { 148 | console.log("Refreshing") 149 | this.messages = [] 150 | this.offset = 0 151 | this.render() 152 | } 153 | }, 154 | 155 | created: function () { 156 | this.componentStillLoaded = true 157 | 158 | document.title = this.$root.appTitle + " - " + this.$root.$t('group.title', { name: this.groupName }) 159 | 160 | var self = this 161 | userGroups.getGroups((err, groups) => { 162 | for (g in groups) { 163 | if (groups[g].id == self.group) 164 | self.groupName = groups[g].name 165 | } 166 | }) 167 | userGroups.getMembers(self.group, (err, groupId, members) => { 168 | self.groupMembers = members 169 | this.render() 170 | }) 171 | }, 172 | 173 | destroyed: function () { 174 | this.componentStillLoaded = false 175 | }, 176 | 177 | watch: { 178 | groupName: function(oldVal, newVal) { 179 | document.title = this.$root.appTitle + " - " + this.$root.$t('group.title', { name: this.groupName }) 180 | } 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /ui/groups.js: -------------------------------------------------------------------------------- 1 | module.exports = function (componentsState) { 2 | const pull = require('pull-stream') 3 | const localPrefs = require('../localprefs') 4 | const userGroups = require('../usergroups') 5 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 6 | 7 | return { 8 | template: ` 9 |
10 |

{{ $t('groups.yourGroups') }}

11 |
12 |   13 |
14 |
15 |

{{ group.name }}

16 |
17 | 18 | 19 |
20 |
21 |
    22 |
  • 23 | 24 |
  • 25 |
26 |
27 |
{{ $t('common.noMessages') }}
28 | 29 |
30 |
31 |
`, 32 | 33 | data: function() { 34 | return { 35 | componentStillLoaded: false, 36 | groups: [], 37 | groupName: '' 38 | } 39 | }, 40 | 41 | methods: { 42 | groupMemberInfoCB: function(err, groupId, members) { 43 | for (g in this.groups) { 44 | if (this.groups[g].id == groupId) { 45 | this.groups[g].members = members 46 | if (!this.groups[g].latestMessages && !this.groups[g].noMessages) { 47 | this.fetchLatestMessage(groupId) 48 | } 49 | return 50 | } 51 | } 52 | }, 53 | 54 | fetchLatestMessage: function(groupId) { 55 | ssbSingleton.getSimpleSSBEventually(() => this.componentStillLoaded, 56 | (err, ssb) => this.fetchLatestMessageCB(err, ssb, groupId)) 57 | }, 58 | 59 | fetchLatestMessageCB: function(err, SSB, groupId) { 60 | const { where, and, or, author, not, isPublic, type, channel, 61 | startFrom, paginate, descending, toCallback } = SSB.dbOperators 62 | 63 | var self = this 64 | for (g in this.groups) { 65 | if (this.groups[g].id == groupId) { 66 | try { 67 | SSB.db.query( 68 | where( 69 | and( 70 | or(...this.groups[g].members.map(x => author(x))), 71 | isPublic(), 72 | type('post') 73 | ) 74 | ), 75 | descending(), 76 | paginate(1), 77 | toCallback((err, answer) => { 78 | // We have to go back through this again, because it 79 | // may have been sorted to a different position by 80 | // the time we get results. 81 | for (g in this.groups) { 82 | if (this.groups[g].id == groupId) { 83 | if (answer && answer.results && answer.results.length > 0) { 84 | // Leaving this open to having multiple messages in case we ever want to. 85 | self.groups[g].latestMessages = answer.results 86 | 87 | // And since Vue doesn't seem to like to pick up message changes. 88 | self.$forceUpdate() 89 | } else 90 | self.groups[g].noMessages = true 91 | } 92 | } 93 | }) 94 | ) 95 | } catch(e) { 96 | self.groups[g].noMessages = true 97 | } 98 | break 99 | } 100 | } 101 | }, 102 | 103 | loadGroups: function() { 104 | var self = this 105 | userGroups.getGroups((err, groups) => { 106 | for (g in groups) { 107 | var found = false 108 | var fetchMembers = true 109 | var fetchMessage = true 110 | for (l in self.groups) { 111 | if (groups[g].id == self.groups[l].id) { 112 | found = true 113 | if (self.groups[l].members && self.groups[l].members.length > 0) { 114 | // Already have member info. 115 | groups[g].members = self.groups[l].members 116 | fetchMembers = false 117 | } 118 | if (self.groups[l].latestMessages || self.groups[l].noMessages) 119 | fetchMessage = false 120 | } 121 | } 122 | 123 | if (!found) { 124 | groups[g].members = [] 125 | self.groups.push(groups[g]) 126 | } 127 | if (fetchMembers) { 128 | (function(groupId) { 129 | userGroups.getMembers(groupId, self.groupMemberInfoCB) 130 | })(groups[g].id) 131 | } else if (fetchMessage) { 132 | // Already fetched members, but don't have a message, so we need to fetch it here because the membership callback won't do it. 133 | (function(groupId) { 134 | this.fetchLatestMessage(groupId) 135 | })(groups[g].id) 136 | } 137 | } 138 | 139 | // Normally I'd like to do this with a temporary variable and storing to the data variable once at the end, but in this case we've got too many potential member callbacks - complexity would be too high. 140 | const sortFunc = (new Intl.Collator()).compare 141 | self.groups = self.groups.sort((a, b) => { return sortFunc(a.name, b.name) }) 142 | }) 143 | }, 144 | 145 | addGroup: function() { 146 | var self = this 147 | userGroups.addGroup({ name: this.groupName }, (err, groupId) => { 148 | if (err) { 149 | alert(err) 150 | } else { 151 | self.loadGroups() 152 | self.groupName = '' 153 | } 154 | }) 155 | }, 156 | 157 | renameGroup: function(group) { 158 | var self = this 159 | const newName = prompt(this.$root.$t('groups.enterGroupName', { group: group.name }), group.name) 160 | if (newName && newName.trim() != '' && newName != group.name) { 161 | group.name = newName 162 | userGroups.updateGroup(group.id, group, (err, success) => { 163 | if (err) 164 | alert(err) 165 | else { 166 | self.groups = [] 167 | self.loadGroups() 168 | } 169 | }) 170 | } 171 | }, 172 | 173 | deleteGroup: function(group) { 174 | var self = this 175 | if (confirm(this.$root.$t('groups.confirmDeleteGroup', { group: group.name }))) { 176 | userGroups.deleteGroup(group.id, (err, success) => { 177 | if (err) 178 | alert(err) 179 | else { 180 | self.groups = [] 181 | self.loadGroups() 182 | } 183 | }) 184 | } 185 | }, 186 | 187 | load: function() { 188 | this.loadGroups() 189 | }, 190 | }, 191 | 192 | created: function () { 193 | this.componentStillLoaded = true 194 | 195 | document.title = this.$root.appTitle + " - " + this.$root.$t('groups.title') 196 | 197 | this.load() 198 | }, 199 | 200 | destroyed: function () { 201 | this.componentStillLoaded = false 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /ui/helpers.js: -------------------------------------------------------------------------------- 1 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 2 | 3 | exports.handleFileSelectParts = function(files, isPrivate, cb) { 4 | [ err, SSB ] = ssbSingleton.getSSB() 5 | if (!SSB || !SSB.blobFiles) { 6 | cb("SSB is not currently available") 7 | return 8 | } 9 | 10 | var opts = { 11 | stripExif: true, 12 | quality: 0.9, 13 | resize: { width: 1024, height: 1024 }, 14 | isPrivate 15 | } 16 | 17 | SSB.blobFiles(files, SSB.net, opts, (err, res) => { 18 | cb(null, res) 19 | }) 20 | } 21 | 22 | exports.handleFileSelect = function(ev, isPrivate, cb) { 23 | this.handleFileSelectParts(ev.target.files, isPrivate, (err, res) => { 24 | if (err) { 25 | cb(err) 26 | return 27 | } 28 | cb(null, " ![" + res.name + "](" + res.link + ")") 29 | }) 30 | } 31 | 32 | exports.getMessageTitle = function(msgId, msg) { 33 | // Patchwork supports adding titles to posts. 34 | // If we're looking at one of those, display its title. 35 | if (msg.content) 36 | if (msg.content.title) 37 | return msg.content.title 38 | else if (msg.content.text) { 39 | if (!msg.content.text.lastIndexOf) { 40 | console.log("Encountered message with text not a string:") 41 | console.log(msg) 42 | return msgId 43 | } 44 | const maxLength = 40 45 | const breakCharacters = ' .,/[]()#' 46 | var lastBreakChar = 0 47 | for (var c = 0; c < breakCharacters.length; ++c) { 48 | var lastIndex = msg.content.text.lastIndexOf(breakCharacters.charAt(c), maxLength) 49 | if (lastIndex > lastBreakChar) 50 | lastBreakChar = lastIndex 51 | } 52 | if(lastBreakChar == 0) 53 | lastBreakChar = Math.min(maxLength, msg.content.text.length) 54 | return (msg.content.text.substring(0, lastBreakChar) + (msg.content.text.length > lastBreakChar ? "..." : "")).trim() 55 | } 56 | 57 | // Fallback - use the message ID. 58 | return msgId 59 | } 60 | 61 | exports.getMessagePreview = function(msg, maxLength) { 62 | if (!msg || !msg.content || !msg.content.text) 63 | return "" 64 | else if (msg.content.text.length > maxLength) 65 | return msg.content.text.substring(0, maxLength - 3) + "..." 66 | else 67 | return msg.content.text.substring(0, maxLength) 68 | } 69 | 70 | exports.getMissingProfileImage = function() { 71 | // This is centralized here so that when building to a single inlined HTML file, 72 | // we're only swapping in the base64-encoded version once. 73 | return "assets/noavatar.svg" 74 | } 75 | -------------------------------------------------------------------------------- /ui/markdown-editor.js: -------------------------------------------------------------------------------- 1 | const helpers = require('./helpers') 2 | const ref = require('ssb-ref') 3 | 4 | Vue.component('markdown-editor', { 5 | template: `
6 | 7 |
`, 8 | 9 | props: ['initialValue', 'privateBlobs'], 10 | 11 | data: function() { 12 | var self = this 13 | return { 14 | postText: this.initialValue, 15 | editorOptions: { 16 | usageStatistics: false, 17 | hideModeSwitch: true, 18 | initialEditType: 'markdown', 19 | hooks: { 20 | addImageBlobHook: self.addImageBlobHook 21 | }, 22 | customHTMLRenderer: { 23 | image(node, context) { 24 | const { destination } = node 25 | const { getChildrenText, skipChildren } = context 26 | 27 | skipChildren() 28 | 29 | return { 30 | type: "openTag", 31 | tagName: "img", 32 | selfClose: true, 33 | attributes: { 34 | src: self.blobUrlCache[destination], 35 | alt: getChildrenText(node) 36 | } 37 | } 38 | } 39 | } 40 | }, 41 | blobUrlCache: [] 42 | } 43 | }, 44 | 45 | methods: { 46 | addImageBlobHook: function(blob, cb) { 47 | var self = this 48 | helpers.handleFileSelectParts([ blob ], this.privateBlobs, (err, res) => { 49 | if (self.privateBlobs) { 50 | var link = ref.parseLink(res.link) 51 | if (link.query && link.query.unbox) { 52 | // Have to unbox it first. 53 | SSB.net.blobs.privateGet(link.link, link.query.unbox, (err, newLink) => { 54 | self.blobUrlCache[res.link] = newLink 55 | cb(res.link, res.name) 56 | }) 57 | } else { 58 | SSB.net.blobs.privateFsURL(res.link, (err, blobURL) => { 59 | self.blobUrlCache[res.link] = blobURL 60 | cb(res.link, res.name) 61 | }) 62 | } 63 | } else { 64 | // Public blob. 65 | SSB.net.blobs.fsURL(res.link, (err, blobURL) => { 66 | self.blobUrlCache[res.link] = blobURL 67 | cb(res.link, res.name) 68 | }) 69 | } 70 | }) 71 | return false 72 | }, 73 | 74 | addBlobURLToCache: function(blobId, blobURL) { 75 | this.blobUrlCache[blobId] = blobURL 76 | }, 77 | 78 | suggestChannels: function(searchString, cb) { 79 | const searchForChannel = searchString.substring(1, searchString.length) 80 | const allChannels = SSB.db.getIndex("channels").getChannels() 81 | const sortFunc = (new Intl.Collator()).compare 82 | const filteredChannels = allChannels.filter((x) => { return x.toLowerCase().startsWith(searchForChannel.toLowerCase()) }) 83 | .sort(sortFunc) 84 | .slice(0, 5) 85 | var suggestions = [] 86 | for (c in filteredChannels) 87 | suggestions.push({ text: "#" + filteredChannels[c], value: filteredChannels[c] }) 88 | cb(null, suggestions) 89 | }, 90 | 91 | clickChannel: function(channel) { 92 | return "#" + channel 93 | }, 94 | 95 | suggestPeople: function(searchString, cb) { 96 | const matches = SSB.searchProfiles(searchString.substring(1)) 97 | matches.forEach(p => { 98 | if (p && p.imageURL) 99 | p.image = p.imageURL 100 | else 101 | p.image = helpers.getMissingProfileImage() 102 | }) 103 | const sortFunc = new Intl.Collator().compare 104 | const sortedPeople = matches.sort((a, b) => { return sortFunc(a.name, b.name) }) 105 | const suggestions = sortedPeople.slice(0, 5).map((x) => { return { icon: x.image, text: x.name, value: x } }) 106 | cb(null, suggestions) 107 | }, 108 | 109 | clickPeople: function(person) { 110 | // Add a person at the cursor. 111 | return "[@" + person.name + "](" + person.id + ")" 112 | }, 113 | 114 | popupSuggestions: function(suggest, optionList, replaceStart, replaceEnd) { 115 | var self = this 116 | const editorSelInfo = this.$refs.tuiEditor.editor.mdEditor.view.coordsAtPos(this.$refs.tuiEditor.editor.mdEditor.view.state.selection.head) 117 | const cursorXY = { x: editorSelInfo.left, y: editorSelInfo.bottom } 118 | const positionParent = document.body 119 | var popupEl = positionParent.getElementsByClassName("suggestion-box")[0] 120 | if (!popupEl) { 121 | popupEl = positionParent.appendChild(document.createElement("div")) 122 | popupEl.className = "suggestion-box toastui-editor-popup toastui-editor-popup-add-heading" 123 | popupEl.appendChild(document.createElement("div")).className = "toastui-editor-popup-body" 124 | 125 | // Fix the position in place at the initial opening. 126 | popupEl.style.position = "absolute" 127 | popupEl.style.left = cursorXY.x + "px" 128 | popupEl.style.top = cursorXY.y + "px" 129 | } 130 | 131 | // Keep it hidden until there are options to show, but we can at least initialize so the position's fixed. 132 | popupEl.style.display = (optionList.length > 0 ? "block" : "none") 133 | if (!popupEl.firstChild.firstChild) 134 | popupEl.firstChild.appendChild(document.createElement("ul")) 135 | var listEl = popupEl.firstChild.firstChild 136 | while (listEl.firstChild) 137 | listEl.removeChild(listEl.firstChild) 138 | for (o in optionList) { 139 | var liEl = listEl.appendChild(document.createElement("li")) 140 | if (optionList[o].icon) { 141 | var imgEl = document.createElement("img") 142 | imgEl.src = optionList[o].icon 143 | imgEl.style.width = (suggest.iconSize || "16px") 144 | imgEl.style.maxHeight = (suggest.iconSize || "16px") 145 | imgEl.style.marginRight = "2px" 146 | imgEl.style.marginLeft = "-8px" 147 | imgEl.style.verticalAlign = "middle" 148 | liEl.appendChild(imgEl) 149 | } 150 | liEl.appendChild(document.createTextNode(optionList[o].text)) 151 | liEl.addEventListener("click", function (value) { return function(e) { 152 | self.useSuggestion(replaceStart, replaceEnd, suggest.click(value)) 153 | e.stopPropagation() 154 | return false 155 | } }(optionList[o].value)) 156 | // Something's hooking mousedown on this and cancelling it so click never fires. This prevents the event from bubbling and getting cancelled. 157 | liEl.addEventListener("mousedown", function(e) { e.stopPropagation(); e.preventDefault(); return false; } ) 158 | } 159 | }, 160 | 161 | useSuggestion: function(replaceStart, replaceEnd, suggestionMarkdown) { 162 | // Replace the current token with our new content. 163 | const startingMarkdown = this.$refs.tuiEditor.editor.getMarkdown() 164 | console.log("Replacing " + startingMarkdown.substring(replaceStart, replaceEnd) + " with " + suggestionMarkdown) 165 | this.$refs.tuiEditor.editor.setMarkdown(startingMarkdown.substring(0, replaceStart) + suggestionMarkdown + startingMarkdown.substring(replaceEnd, startingMarkdown.length)) 166 | this.hideSuggestions() 167 | }, 168 | 169 | hideSuggestions: function() { 170 | var popupEl = document.body.getElementsByClassName("suggestion-box")[0] 171 | if (popupEl) { 172 | popupEl.parentNode.removeChild(popupEl) 173 | } 174 | }, 175 | 176 | hideSuggestionsLater: function() { 177 | // This is here because ToastUI now fires the blur event before the onclick event of the suggestion box can be fired, hiding the box before it can fire. 178 | setTimeout(this.hideSuggestions, 100) 179 | }, 180 | 181 | onChange: function() { 182 | // Figure out where the cursor is at and if we're in a position to pop up a suggestion. 183 | // Search backwards to the beginning of this token. 184 | var self = this 185 | const tokenSeparatorChars = " ,()[]{};:.'\"!" 186 | // Ensure editor is initialized, because onChange can happen before initialization. 187 | if (!this.$refs.tuiEditor.editor) return 188 | const selInfo = this.$refs.tuiEditor.editor.getSelection() 189 | const markdownLines = this.$refs.tuiEditor.editor.getMarkdown().split("\n") 190 | const editorContainerEl = this.$refs.tuiEditor.editor.mdEditor.editorContainerEl 191 | // Ensure we only have a cursor and not a selection. 192 | if (!selInfo[0] || selInfo[0][0] != selInfo[1][0] || selInfo[0][1] != selInfo[1][1]) return 193 | const cursorPos = { line: selInfo[0][0] - 1, ch: selInfo[0][1] - 1 } 194 | const cursorLine = markdownLines[cursorPos.line] 195 | var tokenStart = cursorPos.ch - 1 196 | for (; tokenStart >= 0; --tokenStart) { 197 | if (tokenSeparatorChars.indexOf(cursorLine.charAt(tokenStart)) >= 0) break 198 | } 199 | var tokenEnd = cursorPos.ch 200 | for (; tokenEnd < cursorLine.length; ++tokenEnd) { 201 | if (tokenSeparatorChars.indexOf(cursorLine.charAt(tokenEnd)) >= 0) break 202 | } 203 | const token = cursorLine.substring(tokenStart + 1, tokenEnd) 204 | const suggestionChars = { 205 | '#': { list: this.suggestChannels, click: this.clickChannel }, 206 | '@': { list: this.suggestPeople, click: this.clickPeople } 207 | } 208 | if (token && (suggest = suggestionChars[token.charAt(0)])) { 209 | // This is a type of token we support. 210 | // Figure out where in the raw Markdown the token actually is. 211 | var replaceStart = 0 212 | for (l = 0; l < cursorPos.line; ++l) 213 | replaceStart += markdownLines[l].length + 1 214 | replaceStart += tokenStart + 1 215 | 216 | suggest.list(token, (err, optionList) => { 217 | self.popupSuggestions(suggest, optionList, replaceStart, replaceStart + token.length) 218 | }) 219 | } else { 220 | self.hideSuggestions() 221 | } 222 | }, 223 | 224 | insertMarkdown: function(markdown) { 225 | this.$refs.tuiEditor.editor.insertText(markdown) 226 | }, 227 | 228 | getMarkdown: function() { 229 | return this.$refs.tuiEditor.invoke('getMarkdown') 230 | }, 231 | 232 | setMarkdown: function(newMarkdown) { 233 | this.postText = newMarkdown 234 | if (this.$refs.tuiEditor) 235 | this.$refs.tuiEditor.invoke('setMarkdown', newMarkdown) 236 | } 237 | }, 238 | 239 | created: function () { 240 | } 241 | }) 242 | -------------------------------------------------------------------------------- /ui/markdown.js: -------------------------------------------------------------------------------- 1 | const nodeEmoji = require('node-emoji') 2 | const md = require('ssb-markdown') 3 | const ref = require('ssb-ref') 4 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 5 | 6 | const mdOpts = { 7 | toUrl: (id) => { 8 | function doReplacement(imageURL, link) { 9 | // markdown doesn't support async, so we have to modify the DOM afterwards, see: 10 | // https://github.com/markdown-it/markdown-it/blob/master/docs/development.md#i-need-async-rule-how-to-do-it 11 | function replaceLink(err, newLink) { 12 | [ ssbErr, SSB ] = ssbSingleton.getSSB() 13 | if (!SSB || !SSB.isConnectedWithData) { 14 | // Couldn't lock the database or not fully initialized. Try again later. 15 | setTimeout(function() { 16 | replaceLink(err, newLink) 17 | }, 3000) 18 | return 19 | } 20 | 21 | if (err) { 22 | // We probably can't display this because we're not connected to a peer we can download it from. 23 | if (!SSB.isConnectedWithData()) { 24 | // Try again once we're connected. 25 | SSB.connectedWithData(() => { 26 | doReplacement(imageURL, link) 27 | }) 28 | } 29 | return 30 | } 31 | 32 | if (imageURL != newLink) 33 | { 34 | var els = document.querySelectorAll(`img[src='${imageURL}']`) 35 | for (var i = 0, l = els.length; i < l; ++i) { 36 | els[i].src = newLink 37 | } 38 | } 39 | } 40 | 41 | if (link.query && link.query.unbox) { // private 42 | SSB.net.blobs.privateGet(link.link, link.query.unbox, replaceLink) 43 | } 44 | else { 45 | SSB.net.blobs.localGet(link.link, replaceLink) 46 | } 47 | } 48 | 49 | var self = this 50 | var link = ref.parseLink(id) 51 | if (link && ref.isBlob(link.link)) { 52 | // This has to be done synchronously, so this poses a bit of a challenge for concurrency support. 53 | [ err, SSB ] = ssbSingleton.getSSB() 54 | if (!SSB || !(imageURL = SSB.net.blobs.remoteURL(link.link)) || imageURL == '') { 55 | // We're not connected to a peer - generate a unique ID so at least we have something to replace. 56 | imageURL = '/blobs/get/' + link.link 57 | } 58 | 59 | doReplacement(imageURL, link) 60 | 61 | return imageURL 62 | } else if (ref.isFeed(id)) { 63 | return `#/profile/${encodeURIComponent(id)}` 64 | } else if (ref.isMsg(id)) { 65 | return `#/thread/${encodeURIComponent(id.substring(1))}` 66 | } else if (typeof(id) === 'string' && id[0] === '#') { 67 | return `#/channel/${encodeURIComponent(id.substring(1))}` 68 | } else if (typeof(id) === 'string' && id[0] === '@') { // workaround bug in ssb-markdown 69 | return id 70 | } 71 | }, 72 | imageLink: (ref) => ref, 73 | emoji: (emoji) => { 74 | // https://github.com/omnidan/node-emoji/issues/76 75 | const emojiCharacter = nodeEmoji.get(emoji).replace(/:/g, '') 76 | return `${emojiCharacter}` 77 | } 78 | } 79 | 80 | exports.markdown = function(text) { return md.block(text, mdOpts) } 81 | -------------------------------------------------------------------------------- /ui/new-private-messages.js: -------------------------------------------------------------------------------- 1 | module.exports = function (state) { 2 | const pull = require('pull-stream') 3 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 4 | 5 | Vue.component('new-private-messages', { 6 | template: ` 7 | 8 | 🔒 9 | `, 10 | 11 | data: function() { 12 | return state 13 | }, 14 | 15 | methods: { 16 | reset() { 17 | if (this.$route.path == "/private") 18 | this.$route.matched[0].instances.default.renderPrivate() 19 | else 20 | this.$router.push({ path: '/private'}) 21 | }, 22 | 23 | tryLoading: function () { 24 | var self = this; 25 | 26 | [ err, SSB ] = ssbSingleton.getSSB() 27 | if (err || !SSB.db) { 28 | setTimeout(self.tryLoading, 3000) 29 | return 30 | } 31 | 32 | // This should only be done once, and only after we've already gotten an SSB running, which is why it's done here. 33 | if (!self.registeredSSBChange) { 34 | ssbSingleton.onChangeSSB(self.tryLoading) 35 | self.registeredSSBChange = true 36 | } 37 | 38 | const { where, and, isPrivate, not, author, type, live, toPullStream } = SSB.dbOperators 39 | 40 | pull( 41 | SSB.db.query( 42 | where( 43 | and( 44 | isPrivate(), 45 | not(author(SSB.net.id)) 46 | ) 47 | ), 48 | live(), 49 | toPullStream(), 50 | pull.drain(() => { 51 | self.newPrivateMessages = true 52 | }) 53 | ) 54 | ) 55 | } 56 | }, 57 | 58 | created: function() { 59 | this.tryLoading() 60 | } 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /ui/new-public-messages.js: -------------------------------------------------------------------------------- 1 | module.exports = function (state) { 2 | const pull = require('pull-stream') 3 | const localPrefs = require('../localprefs') 4 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 5 | 6 | Vue.component('new-public-messages', { 7 | template: ` 8 | 9 | 🎉 10 | `, 11 | 12 | data: function() { 13 | return state 14 | }, 15 | 16 | methods: { 17 | refreshIfConfigured() { 18 | const scrollTop = (typeof document.body.scrollTop != 'undefined' ? document.body.scrollTop : window.scrollY) 19 | if (this.newPublicMessages && scrollTop == 0 && this.$route.path == "/public" && localPrefs.getAutorefresh()) { 20 | this.$route.matched[0].instances.default.refresh() 21 | } 22 | if (this.newPublicMessages && scrollTop == 0 && this.$route.path == "/threads" && localPrefs.getAutorefresh()) { 23 | this.$route.matched[0].instances.default.refresh() 24 | } 25 | }, 26 | 27 | reset() { 28 | // render public resets the newPublicMessages state 29 | if (this.$route.path == "/public") 30 | this.$route.matched[0].instances.default.refresh() 31 | else 32 | this.$router.push({ path: '/public'}) 33 | }, 34 | 35 | tryLoading: function () { 36 | var self = this; 37 | 38 | [ err, SSB ] = ssbSingleton.getSSB() 39 | if (err || !SSB.db) { 40 | setTimeout(self.tryLoading, 3000) 41 | return 42 | } 43 | 44 | // This should only be done once, and only after we've already gotten an SSB running, which is why it's done here. 45 | if (!self.registeredSSBChange) { 46 | ssbSingleton.onChangeSSB(self.tryLoading) 47 | self.registeredSSBChange = true 48 | } 49 | 50 | const { where, and, type, isPublic, author, not, live, toPullStream } = SSB.dbOperators 51 | 52 | pull( 53 | SSB.db.query( 54 | where( 55 | and( 56 | type('post'), 57 | isPublic(), 58 | not(author(SSB.net.id)) 59 | ) 60 | ), 61 | live(), 62 | toPullStream(), 63 | pull.drain((msg) => { 64 | if (!msg.value.meta) { 65 | self.newPublicMessages = true 66 | 67 | // If we're scrolled to the top of the page and autorefresh is on, refresh. 68 | if (self.publicRefreshTimer == 0 && localPrefs.getAutorefresh()) { 69 | // Only allow refreshing every 30 seconds, but at the end of that, check once again if we have queued messages. 70 | self.publicRefreshTimer = setTimeout(function() { 71 | console.log("Checking for new data via timer...") 72 | self.refreshIfConfigured() 73 | 74 | // After another few seconds, clear the blocking timer. 75 | self.publicRefreshTimer = 0 76 | console.log("Autorefresh blocking timer cleared. Autorefreshing is allowed to proceed.") 77 | }, 30000) 78 | 79 | // Refresh now. 80 | console.log("Autorefreshing blocked for 30 seconds. Refreshing via event...") 81 | self.refreshIfConfigured() 82 | } 83 | } 84 | }) 85 | ) 86 | ) 87 | } 88 | }, 89 | 90 | created: function() { 91 | this.tryLoading() 92 | } 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /ui/notifications.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | const pull = require('pull-stream') 3 | const asyncFilter = require('pull-async-filter') 4 | const cat = require('pull-cat') 5 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 6 | 7 | return { 8 | template: ` 9 |
10 |

{{ $t('notifications.title') }}

11 | 12 |
`, 13 | 14 | props: ['channel'], 15 | 16 | data: function() { 17 | return { 18 | componentStillLoaded: false, 19 | messages: [] 20 | } 21 | }, 22 | 23 | methods: { 24 | createGetSameRoot: function(SSB) { 25 | const { where, and, author, type, toCallback, hasRoot } = SSB.dbOperators 26 | 27 | return function(read) { 28 | return function readable (end, cb) { 29 | read(end, function(end, data) { 30 | if (data) { 31 | const root = data.value.content.root ? data.value.content.root : data.key 32 | // Get all messages with the same root, but only the ones after the user's most recent post. 33 | SSB.db.query( 34 | where( 35 | and( 36 | hasRoot(root), 37 | type('post') 38 | ) 39 | ), 40 | toCallback((err, results) => { 41 | // Look through the results from the end backwards and look for a post from the user. 42 | // If we find one, stop passing along results, so we only have the posts since the user last replied. 43 | for (var r = results.length - 1; r >= 0; --r) { 44 | if (results[r].value.author == SSB.net.id) break 45 | 46 | cb(false, results[r]) 47 | } 48 | }) 49 | ) 50 | } 51 | 52 | // Place the original message back in the queue. 53 | cb(end, data) 54 | }) 55 | } 56 | } 57 | }, 58 | 59 | render: function () { 60 | ssbSingleton.getSimpleSSBEventually(() => this.componentStillLoaded, this.renderCB) 61 | }, 62 | 63 | renderCB: function (err, SSB) { 64 | const { where, and, mentions, contact, author, type, toCallback, 65 | toPullStream, hasRoot, paginate, descending } = SSB.dbOperators 66 | 67 | var self = this 68 | 69 | console.time("notifications") 70 | 71 | pull( 72 | cat([ 73 | // Messages directly mentioning the user. 74 | pull( 75 | SSB.db.query( 76 | where(mentions(SSB.net.id)), 77 | descending(), 78 | paginate(25), 79 | toPullStream() 80 | ), 81 | pull.take(1), 82 | pull.flatten() 83 | ), 84 | // Messages the user has posted. 85 | pull( 86 | SSB.db.query( 87 | where( 88 | and( 89 | author(SSB.net.id), 90 | type('post') 91 | ) 92 | ), 93 | descending(), 94 | paginate(25), 95 | toPullStream() 96 | ), 97 | pull.take(1), 98 | pull.flatten() 99 | ), 100 | pull( 101 | SSB.db.query( 102 | where(contact(SSB.net.id)), 103 | descending(), 104 | paginate(25), 105 | toPullStream() 106 | ), 107 | pull.take(1), 108 | pull.flatten() 109 | ) 110 | ]), 111 | self.createGetSameRoot(SSB), 112 | pull.unique('key'), 113 | asyncFilter((msg, cb) => { 114 | if (msg.value.author === SSB.net.id) return cb(null, true) 115 | else SSB.net.friends.isBlocking({source: SSB.net.id, dest: msg.value.author }, cb) 116 | }), 117 | pull.collect((err, msgs) => { 118 | console.timeEnd("notifications") 119 | this.messages = msgs.sort((a, b) => b.value.timestamp - a.value.timestamp).slice(0, 50) 120 | }) 121 | ) 122 | } 123 | }, 124 | 125 | created: function () { 126 | this.componentStillLoaded = true 127 | 128 | document.title = this.$root.appTitle + " - " + this.$root.$t('notifications.title') 129 | 130 | this.render() 131 | }, 132 | 133 | destroyed: function () { 134 | this.componentStillLoaded = false 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /ui/onboarding-dialog.js: -------------------------------------------------------------------------------- 1 | const defaultPrefs = require("../defaultprefs.json") 2 | const helpers = require('./helpers') 3 | 4 | Vue.component('onboarding-dialog', { 5 | template: ` 6 | 7 | 65 | `, 66 | 67 | props: ['onClose', 'show'], 68 | 69 | data: function() { 70 | var self = this 71 | return { 72 | name: '', 73 | descriptionText: '', 74 | mnemonic: '', 75 | displayRecoveryWarnings: false, 76 | currentTab: 0, 77 | suggestedPeers: (defaultPrefs.suggestPeers || []), 78 | suggestedFollows: (defaultPrefs.suggestFollows || []), 79 | usePeers: (defaultPrefs.suggestPeers || []).filter((x) => typeof x.default == "undefined" || x.default), 80 | useFollows: (defaultPrefs.suggestFollows || []).filter((x) => typeof x.default == "undefined" || x.default) 81 | } 82 | }, 83 | 84 | methods: { 85 | changeTab: function(e, index) { 86 | this.currentTab = index 87 | this.displayRecoveryWarnings = (index == 1) 88 | }, 89 | 90 | saveProfile: function() { 91 | this.descriptionText = this.$refs.markdownEditor.getMarkdown() 92 | 93 | var msg = { type: 'about', about: SSB.net.id } 94 | if (this.name) 95 | msg.name = this.name 96 | if (this.descriptionText) 97 | msg.description = this.descriptionText 98 | 99 | // Make sure the full post (including headers) is not larger than the 8KiB limit. 100 | if (JSON.stringify(msg).length > 8192) { 101 | throw this.$root.$t('common.postTooLarge') 102 | } 103 | 104 | SSB.db.publish(msg, (err) => { 105 | if (err) throw err 106 | }) 107 | }, 108 | 109 | getStarted: function() { 110 | if (this.currentTab == 0) { 111 | // Save the person's name and description. 112 | try { 113 | this.saveProfile() 114 | } catch(err) { 115 | alert(err) 116 | return 117 | } 118 | 119 | // Connect to peers. 120 | for (p in this.usePeers) { 121 | (function(x) { 122 | const suggestedPeer = x 123 | var s = suggestedPeer.address.split(":") 124 | SSB.net.connectAndRemember(suggestedPeer.address, { 125 | key: '@' + s[s.length-1] + '.ed25519', 126 | type: suggestedPeer.type 127 | }) 128 | })(this.usePeers[p]) 129 | } 130 | 131 | // Follow people. 132 | for (f in this.useFollows) { 133 | (function(x) { 134 | const followKey = x 135 | SSB.db.publish({ 136 | type: 'contact', 137 | contact: followKey, 138 | following: true 139 | }, () => { }) 140 | })(this.useFollows[f].key) 141 | } 142 | } else { 143 | // Recover account from existing mnemonic code. 144 | const mnemonic = require('ssb-keys-mnemonic') 145 | const key = mnemonic.wordsToKeys(this.mnemonic) 146 | localStorage["/.ssb-lite/secret"] = JSON.stringify(key) 147 | localStorage["/.ssb-lite/restoreFeed"] = "true" 148 | 149 | // Remember connections for after the reload. 150 | // This is different than under the New User case because we don't want to wait for a successful connection to remember the connection - otherwise reloading means it never gets saved. 151 | for (p in this.usePeers) { 152 | (function(x) { 153 | const suggestedPeer = x 154 | var s = suggestedPeer.address.split(":") 155 | SSB.net.conn.remember(suggestedPeer.address, { 156 | key: '@' + s[s.length-1] + '.ed25519', 157 | type: suggestedPeer.type, 158 | autoconnect: true 159 | }) 160 | })(this.usePeers[p]) 161 | } 162 | 163 | // Since we recovered a mnemonic code, warn the user we'll need to refresh and then do it so the new key takes effect. 164 | alert("We will now need to reload for this change to take effect.") 165 | window.location.reload() 166 | } 167 | 168 | this.onClose() 169 | } 170 | }, 171 | 172 | computed: { 173 | } 174 | }) 175 | -------------------------------------------------------------------------------- /ui/private.js: -------------------------------------------------------------------------------- 1 | module.exports = function (componentsState) { 2 | const helpers = require('./helpers') 3 | const pull = require('pull-stream') 4 | const ssbMentions = require('ssb-mentions') 5 | const ref = require('ssb-ref') 6 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 7 | 8 | return { 9 | template: `
10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |

{{ $t('private.privateMessages') }}

23 | 24 | 25 |
`, 26 | 27 | props: ['feedId'], 28 | 29 | data: function() { 30 | var self = this 31 | return { 32 | postMessageVisible: false, 33 | postText: "", 34 | subject: "", 35 | people: [], 36 | recipients: [], 37 | messages: [], 38 | componentStillLoaded: false, 39 | 40 | showPreview: false 41 | } 42 | }, 43 | 44 | methods: { 45 | suggest: function(searchString, loading) { 46 | [ err, SSB ] = ssbSingleton.getSSB() 47 | if (!SSB || !SSB.getProfile) { 48 | // This isn't important enough to try again or wait. 49 | return 50 | } 51 | 52 | var self = this 53 | loading(true) 54 | 55 | const matches = SSB.searchProfiles(searchString) 56 | 57 | var unsortedPeople = [] 58 | matches.forEach(match => { 59 | const p = SSB.getProfile(match.id) 60 | if (p && p.imageURL) 61 | unsortedPeople.push({ id: match.id, name: match.name, image: p.imageURL }) 62 | else 63 | unsortedPeople.push({ id: match.id, name: match.name, image: helpers.getMissingProfileImage() }) 64 | }) 65 | const sortFunc = new Intl.Collator().compare 66 | self.people = unsortedPeople.sort((a, b) => { return sortFunc(a.name, b.name) }) 67 | loading(false) 68 | }, 69 | 70 | recipientsOpen: function() { 71 | ssbSingleton.getSimpleSSBEventually(() => this.componentStillLoaded, this.recipientsOpenCB) 72 | }, 73 | 74 | recipientsOpenCB: function (err, SSB) { 75 | const matches = SSB.searchProfiles("") 76 | var unsortedPeople = [] 77 | matches.forEach(match => { 78 | const p = SSB.getProfile(match.id) 79 | if (p && p.imageURL) 80 | unsortedPeople.push({ id: match.id, name: match.name, image: p.imageURL }) 81 | else 82 | unsortedPeople.push({ id: match.id, name: match.name, image: helpers.getMissingProfileImage() }) 83 | }) 84 | const sortFunc = new Intl.Collator().compare 85 | this.people = unsortedPeople.sort((a, b) => { return sortFunc(a.name, b.name) }) 86 | }, 87 | 88 | renderPrivate: function() { 89 | ssbSingleton.getSimpleSSBEventually(() => this.componentStillLoaded, this.renderPrivateCB) 90 | }, 91 | 92 | renderPrivateCB: function(err, SSB) { 93 | const { where, and, descending, isPrivate, isRoot, type, toCallback } = SSB.dbOperators 94 | document.body.classList.add('refreshing') 95 | 96 | var self = this 97 | if (this.feedId && this.feedId != '') { 98 | this.postMessageVisible = true 99 | var name = SSB.getProfileName(this.feedId) 100 | if (self.people.length == 0) 101 | self.people = [{ id: self.feedId, name: (name || self.feedId) }] 102 | self.recipients = [{ id: self.feedId, name: (name || self.feedId) }] 103 | 104 | // Done connecting and loading the box, so now we can take down the refreshing indicator 105 | document.body.classList.remove('refreshing') 106 | } 107 | 108 | componentsState.newPrivateMessages = false 109 | 110 | console.time("private messages") 111 | 112 | SSB.db.query( 113 | where( 114 | and( 115 | isPrivate(), 116 | isRoot(), 117 | type('post') 118 | ) 119 | ), 120 | descending(), 121 | toCallback((err, results) => { 122 | this.messages = results 123 | console.timeEnd("private messages") 124 | 125 | if (!self.feedId || self.feedId == '') 126 | document.body.classList.remove('refreshing') 127 | }) 128 | ) 129 | }, 130 | 131 | onFileSelect: function(ev) { 132 | var self = this 133 | helpers.handleFileSelect(ev, true, (err, text) => { 134 | self.$refs.markdownEditor.insertMarkdown(text) 135 | }) 136 | }, 137 | 138 | closePreview: function() { 139 | this.showPreview = false 140 | }, 141 | 142 | onPost: function() { 143 | if (!this.postMessageVisible) { 144 | this.postMessageVisible = true 145 | return 146 | } 147 | 148 | this.postText = this.$refs.markdownEditor.getMarkdown() 149 | 150 | if (this.postText == '' || this.subject == '') { 151 | alert(this.$root.$t('private.blankFieldError')) 152 | return 153 | } 154 | 155 | // Make sure the full post (including headers) is not larger than the 8KiB limit. 156 | var postData = this.buildPostData() 157 | if (JSON.stringify(postData).length > 8192) { 158 | alert(this.$root.$t('common.postTooLarge')) 159 | return 160 | } 161 | 162 | this.showPreview = true 163 | }, 164 | 165 | buildPostData: function() { 166 | [ err, SSB ] = ssbSingleton.getSSB() 167 | if (!SSB || !SSB.net || !SSB.box) { 168 | alert("Can't post right now. Couldn't lock database. Please make sure there's only one instance of ssb-browser running.") 169 | return 170 | } 171 | 172 | if (this.recipients.length == 0) { 173 | alert(this.$root.$t('private.noRecipientError')) 174 | return 175 | } 176 | 177 | let recps = this.recipients.map(x => x.id) 178 | 179 | if (!recps.every(x => x.startsWith("@"))) { 180 | alert(this.$root.$t('private.badRecipientError')) 181 | return 182 | } 183 | 184 | if (!recps.includes(SSB.net.id)) 185 | recps.push(SSB.net.id) 186 | 187 | var mentions = ssbMentions(this.postText) 188 | 189 | var content = { type: 'post', text: this.postText, subject: this.subject, mentions } 190 | if (recps) { 191 | content.recps = recps 192 | content = SSB.box(content, recps.map(x => x.substr(1))) 193 | } 194 | 195 | return content 196 | }, 197 | 198 | confirmPost: function() { 199 | [ err, SSB ] = ssbSingleton.getSSB() 200 | if (!SSB || !SSB.net || !SSB.box) { 201 | alert("Can't post right now. Couldn't lock database. Please make sure there's only one instance of ssb-browser running.") 202 | return 203 | } 204 | 205 | var self = this 206 | 207 | var content = this.buildPostData() 208 | 209 | SSB.db.publish(content, (err) => { 210 | if (err) console.log(err) 211 | 212 | this.postMessageVisible = false 213 | this.postText = "" 214 | this.subject = "" 215 | this.recipients = [] 216 | this.showPreview = false 217 | if (self.$refs.markdownEditor) 218 | self.$refs.markdownEditor.setMarkdown(this.descriptionText) 219 | 220 | this.renderPrivate() 221 | }) 222 | } 223 | }, 224 | 225 | created: function () { 226 | this.componentStillLoaded = true 227 | 228 | document.title = this.$root.appTitle + " - " + this.$root.$t('private.title') 229 | 230 | this.renderPrivate() 231 | }, 232 | 233 | destroyed: function () { 234 | this.componentStillLoaded = false 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /ui/search.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | const pull = require('pull-stream') 3 | const ssbMentions = require('ssb-mentions') 4 | const ssbSingleton = require('ssb-browser-core/ssb-singleton') 5 | 6 | return { 7 | template: ` 8 |