├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── app ├── command.js ├── desktop.js ├── elements │ ├── base-element.js │ ├── channels.js │ ├── composer.js │ ├── input-prompt.js │ ├── messages.js │ ├── status.js │ └── users.js ├── index.js ├── signature.js ├── style │ ├── button.styl │ └── index.styl ├── util.js ├── web.js └── windows │ ├── git-help.html │ └── git-help.js ├── config.js ├── index.html ├── index.js ├── package.json ├── pkg.js └── static ├── Icon.icns ├── Icon.png ├── Icon@2x.png ├── friends-logo.psd ├── logo.png ├── screenshot.png ├── startup.ogg └── verified.svg /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | app/style/*.css 3 | friendsdb/ 4 | node_modules/ 5 | pkg/ 6 | public-keys/ 7 | Friends.app 8 | *.log 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '4' 4 | - '5' 5 | install: npm install standard 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Friends change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | ## Unreleased 7 | * engage 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Contributions welcome! 4 | 5 | **Before spending lots of time on something, ask for feedback on your idea first!** 6 | 7 | Please search issues and pull requests before adding something new to avoid duplicating efforts and conversations. 8 | 9 | This project welcomes non-code contributions, too! The following types of contributions are welcome: 10 | 11 | - **Ideas**: participate in an issue thread or start your own to have your voice heard. 12 | - **Writing**: contribute your expertise in an area by helping expand the included content. 13 | - **Copy editing**: fix typos, clarify language, and generally improve the quality of the content. 14 | - **Formatting**: help keep content easy to read with consistent formatting. 15 | 16 | ## Code Style 17 | 18 | [![standard][standard-image]][standard-url] 19 | 20 | This repository uses [`standard`][standard-url] to maintain code style and consistency, and to avoid style arguments. `npm test` runs `standard` automatically, so you don't have to! 21 | 22 | [standard-image]: https://cdn.rawgit.com/feross/standard/master/badge.svg 23 | [standard-url]: https://github.com/feross/standard 24 | 25 | # Project Governance 26 | 27 | **This is an [OPEN Open Source Project](http://openopensource.org/).** 28 | 29 | Individuals making significant and valuable contributions are given commit-access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project. 30 | 31 | ## Rules 32 | 33 | There are a few basic ground-rules for contributors: 34 | 35 | 1. **No `--force` pushes** or modifying the Git history in any way. 36 | 1. **Non-master branches** ought to be used for ongoing work. 37 | 1. **External API changes and significant modifications** ought to be subject to an **internal pull-request** to solicit feedback from other contributors. 38 | 1. Internal pull-requests to solicit feedback are *encouraged* for any other non-trivial contribution but left to the discretion of the contributor. 39 | 1. Contributors should attempt to adhere to the prevailing code style. 40 | 41 | ## Releases 42 | 43 | Declaring formal releases remains the prerogative of the project maintainer. 44 | 45 | ## Changes to this arrangement 46 | 47 | This is an experiment and feedback is welcome! This document may also be subject to pull-requests or changes by contributors where you believe you have something valuable to add or change. 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # [MIT License](https://spdx.org/licenses/MIT) 2 | 3 | Copyright (c) 2015-2016 [MOOSE Team](http://moose-team.github.io) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Friends 2 | 3 | > P2P chat powered by the Web. 4 | 5 | [![travis][travis-image]][travis-url] 6 | [![david][david-image]][david-url] 7 | [![javascript style guide][standard-image]][standard-url] 8 | 9 | [travis-image]: https://img.shields.io/travis/moose-team/friends/master.svg 10 | [travis-url]: https://travis-ci.org/moose-team/friends 11 | [david-image]: https://img.shields.io/david/moose-team/friends.svg 12 | [david-url]: https://david-dm.org/moose-team/friends 13 | [standard-image]: https://img.shields.io/badge/code_style-standard-brightgreen.svg 14 | [standard-url]: https://standardjs.com 15 | 16 | ![screenshot](static/screenshot.png) 17 | 18 | **This project is alpha quality.** You probably only want to use this if you like to send pull requests fixing things :) 19 | 20 | ## How it works 21 | 22 | See [our site](http://moose-team.github.io/friends/) or the `gh-pages` branch. 23 | 24 | ## Install 25 | 26 | ### Prerequisites 27 | 28 | You'll need [Node.js](https://nodejs.org) (`>= 4`) and [npm](https://www.npmjs.com/package/npm) (`>= 2.8.3`). 29 | 30 | ### Build 31 | 32 | Clone the source locally: 33 | 34 | ```sh 35 | $ git clone https://github.com/moose-team/friends 36 | $ cd friends 37 | ``` 38 | 39 | Install project dependencies: 40 | 41 | ```sh 42 | $ npm install 43 | ``` 44 | 45 | Compile leveldown for [electron](http://electron.atom.io/): 46 | 47 | ```sh 48 | $ npm run rebuild-leveldb 49 | ``` 50 | 51 | If you are not on 64-bit architecture, you will have to modify the command in package.json: 52 | 53 | ``` 54 | "rebuild-leveldb": "cd node_modules/leveldown && set HOME=~/.electron-gyp && node-gyp rebuild --target=$(../../version.js) --arch=x64 --dist-url=https://atom.io/download/atom-shell" 55 | ``` 56 | 57 | to use `--arch=ia32`. 58 | 59 | ## Usage 60 | 61 | ### GitHub Login 62 | 63 | Friends currently uses your git and github configuration for authentication. 64 | 65 | If you don't already have a public key on GitHub and corresponding private key on your machine, you'll need to [set that up first](https://help.github.com/articles/generating-ssh-keys/). Make sure your github username is also set, using `git config --global github.user yourusername`. 66 | 67 | If you're having trouble getting this part to work, do this to get debug information: 68 | 69 | ``` 70 | $ npm i github-current-user -g 71 | $ DEBUG=* github-current-user 72 | ``` 73 | 74 | and then report an [issue](https://github.com/moose-team/friends/issues). 75 | 76 | **Note**: DSA keys are not supported. You should switch to RSA anyway for security reasons. 77 | 78 | If it can't verify you, try doing `ssh-add ~/.ssh/id_rsa`. Your key should show up when you run `ssh-add -l`. 79 | 80 | ### Run 81 | 82 | To run from the command line, execute `npm start`. 83 | 84 | To create a distributable app, run `npm run package`. 85 | 86 | ## Contributing 87 | 88 | Contributions welcome! Please read the [contributing guidelines](CONTRIBUTING.md) before getting started. 89 | 90 | ## License 91 | 92 | [MIT](LICENSE.md) 93 | -------------------------------------------------------------------------------- /app/command.js: -------------------------------------------------------------------------------- 1 | function command (self, db) { 2 | return function (commandStr) { 3 | var words = commandStr.split(' ') 4 | var command = words[0].substring(1, words[0].length).toLowerCase() 5 | 6 | switch (command) { 7 | case 'join': 8 | words.shift() 9 | var channel = words.join(' ') 10 | self.emit('addChannel', channel) 11 | break 12 | case 'wc': 13 | case 'part': 14 | case 'leave': 15 | self.emit('leaveChannel', self.data.activeChannel.name) 16 | break 17 | case 'wcall': 18 | case 'partall': 19 | case 'leaveall': 20 | self.data.channels.forEach(function (channel) { 21 | self.emit('leaveChannel', channel.name) 22 | }) 23 | break 24 | case 'alias': 25 | var aliasName = words[1] 26 | var aliasCommand = words.splice(2, words.length - 1).join(' ') 27 | db.aliases.put(aliasName, aliasCommand) 28 | break 29 | case 'rmalias': 30 | var aliasN = words[1] 31 | db.aliases.del(aliasN) 32 | break 33 | default: 34 | db.aliases.get(command, function (err, alias) { 35 | if (err === null) { 36 | self.emit('executeCommand', alias) 37 | } else { 38 | console.log('Unrecognized command: ' + command + ' (in "' + commandStr + '")') 39 | self.emit('sendMessage', commandStr) 40 | } 41 | }) 42 | break 43 | } 44 | } 45 | } 46 | 47 | module.exports = command 48 | -------------------------------------------------------------------------------- /app/desktop.js: -------------------------------------------------------------------------------- 1 | module.exports = Desktop 2 | 3 | var inherits = require('util').inherits 4 | var remote = require('remote') 5 | var app = remote.require('app') 6 | var shell = require('shell') 7 | var BrowserWindow = remote.require('browser-window') 8 | 9 | var App = require('./') 10 | var config = require('../config') 11 | 12 | inherits(Desktop, App) 13 | 14 | function Desktop () { 15 | if (!(this instanceof Desktop)) return new Desktop() 16 | 17 | // get current window when app is instantiated 18 | var currentWindow = remote.getCurrentWindow() 19 | var self = this 20 | 21 | App.call(this, document.body, currentWindow) 22 | 23 | // defensively remove all listeners as errors will occur on reload 24 | // see https://github.com/atom/electron/issues/3778 25 | currentWindow.removeAllListeners() 26 | 27 | // clear notifications on focus. 28 | // TODO: only clear notifications in current channel when we have that 29 | currentWindow.on('focus', function () { 30 | self.setBadge(false) 31 | }) 32 | 33 | this.on('showGitHelp', this.showGitHelp.bind(this)) 34 | this.on('setBadge', this.setBadge.bind(this)) 35 | this.on('openUrl', function (url) { 36 | shell.openExternal(url) 37 | }) 38 | } 39 | 40 | Desktop.prototype.showGitHelp = function () { 41 | var gitHelp = new BrowserWindow({ 42 | width: 600, 43 | height: 525, 44 | show: false, 45 | center: true, 46 | resizable: false 47 | }) 48 | 49 | gitHelp.on('closed', function () { 50 | gitHelp = null 51 | }) 52 | 53 | gitHelp.loadURL(config.GIT_HELP) 54 | gitHelp.show() 55 | } 56 | 57 | Desktop.prototype.setBadge = function (num) { 58 | if (!app.dock) return 59 | if (num === false) return app.dock.setBadge('') 60 | if (num == null) this._notifications++ 61 | else this._notifications = num 62 | app.dock.setBadge(this._notifications.toString()) 63 | } 64 | -------------------------------------------------------------------------------- /app/elements/base-element.js: -------------------------------------------------------------------------------- 1 | module.exports = BaseElement 2 | 3 | var inherits = require('util').inherits 4 | var EE = require('events').EventEmitter 5 | 6 | function BaseElement (target) { 7 | EE.call(this) 8 | this.target = target || null 9 | } 10 | inherits(BaseElement, EE) 11 | 12 | BaseElement.prototype.send = function () { 13 | if (this.target && typeof this.target.emit === 'function') { 14 | this.target.emit.apply(this.target, arguments) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/elements/channels.js: -------------------------------------------------------------------------------- 1 | module.exports = Channels 2 | 3 | var InputPrompt = require('./input-prompt') 4 | var inherits = require('util').inherits 5 | var BaseElement = require('./base-element') 6 | var yo = require('yo-yo') 7 | 8 | function Channels (target) { 9 | var self = this 10 | BaseElement.call(this, target) 11 | 12 | self.addChannelPrompt = new InputPrompt({ 13 | className: 'add-channel', 14 | prompt: '+ Join Channel', 15 | placeholder: 'Channel name', 16 | onsubmit: function (channelName) { 17 | self.send('addChannel', channelName) 18 | }, 19 | onupdate: function () { 20 | self.send('render') 21 | } 22 | }) 23 | } 24 | inherits(Channels, BaseElement) 25 | 26 | Channels.prototype.render = function (channels) { 27 | var self = this 28 | 29 | channels = channels.map(function (channel) { 30 | var className = channel.active ? 'active' : '' 31 | 32 | function onclick () { 33 | self.send('selectChannel', channel.name) 34 | } 35 | 36 | return yo` 37 |
  • 38 | 39 |
  • 40 | ` 41 | }) 42 | 43 | return yo` 44 |
    45 |
    Channels
    46 | 47 | ${self.addChannelPrompt.render()} 48 |
    49 | ` 50 | } 51 | -------------------------------------------------------------------------------- /app/elements/composer.js: -------------------------------------------------------------------------------- 1 | module.exports = Composer 2 | 3 | var inherits = require('util').inherits 4 | var uniq = require('lodash.uniq') 5 | var BaseElement = require('./base-element') 6 | var yo = require('yo-yo') 7 | 8 | function Composer (target) { 9 | BaseElement.call(this, target) 10 | // List of words available to autocomplete 11 | this.autocompletes = [] 12 | // Whether we should show the autocomplete box 13 | this.autocompleting = [] 14 | // Which item in the autocomplete list we are selecting 15 | this.autocompleteIndex = 0 16 | } 17 | inherits(Composer, BaseElement) 18 | 19 | var TAB_KEY = 9 20 | var ENTER_KEY = 13 21 | 22 | // the height taken up by padding, margin, border combined 23 | Composer.prototype.minimumHeight = 48 // the default height of the composer element in pixels is one row + mimimum 24 | Composer.prototype.defaultHeight = 17 + this.minimumHeight 25 | 26 | Composer.prototype.render = function (data) { 27 | var self = this 28 | data = data || {} 29 | var ownUsername = data.username 30 | 31 | function onkeydown (e) { 32 | if (e.keyCode === TAB_KEY) { 33 | e.preventDefault() 34 | 35 | // if there are no matches try matching again 36 | if (!self.autocompleting.length) { 37 | self.resetAutocomplete() 38 | self.autocomplete(self.node.value, ownUsername) 39 | } 40 | 41 | self.insertAutocomplete(e.target) 42 | } else if (e.keyCode === ENTER_KEY && !e.shiftKey) { 43 | e.preventDefault() 44 | self.submit(e.target) 45 | } 46 | 47 | // reset the completions if the user submits or changes the text to be 48 | // completed 49 | if (e.keyCode !== TAB_KEY) { 50 | self.resetAutocomplete() 51 | } 52 | } 53 | 54 | function oninput (e) { 55 | self.resize(e.target) 56 | } 57 | 58 | self.node = yo` 59 | 67 | ` 68 | 69 | this.initAutoExpander() 70 | 71 | return self.node 72 | } 73 | 74 | Composer.prototype.autocomplete = function (text, ownUsername) { 75 | if (!text || text.length < 1) { 76 | this.resetAutocomplete() 77 | return 78 | } 79 | 80 | this.autocompleting = uniq(this.autocompletes.filter(function (candidate) { 81 | return candidate.toLowerCase().indexOf(text.toLowerCase()) === 0 && 82 | candidate.toLowerCase() !== ownUsername.toLowerCase() 83 | })) 84 | } 85 | 86 | Composer.prototype.submit = function (node) { 87 | var self = this 88 | if (node.value.charAt(0) === '/') { 89 | self.send('executeCommand', node.value) 90 | } else { 91 | self.send('sendMessage', node.value) 92 | } 93 | node.innerHTML = '' 94 | self.resize(node) 95 | } 96 | 97 | Composer.prototype.insertAutocomplete = function (node) { 98 | if (this.autocompleting.length < 1) return 99 | if (this.autocompleting[this.autocompleteIndex]) { 100 | node.value = this.autocompleting[this.autocompleteIndex] + ': ' 101 | } 102 | this.autocompleteIndex = (this.autocompleteIndex + 1) % this.autocompleting.length 103 | } 104 | 105 | Composer.prototype.resetAutocomplete = function () { 106 | this.autocompleting = [] 107 | this.autocompleteIndex = 0 108 | } 109 | 110 | Composer.prototype.focus = function () { 111 | this.node.focus() 112 | } 113 | 114 | Composer.prototype.initAutoExpander = function () { 115 | var self = this 116 | 117 | setTimeout(function () { 118 | var savedValue = self.node.value 119 | self.node.value = '' 120 | self.node.baseScrollHeight = self.node.scrollHeight 121 | self.node.value = savedValue 122 | }) 123 | } 124 | 125 | Composer.prototype.resize = function () { 126 | var oldrows = this.node.rows 127 | this.node.rows = 1 128 | var rows = Math.ceil((this.node.scrollHeight - this.node.baseScrollHeight) / 17) 129 | this.node.rows = 1 + rows 130 | 131 | // only dispatch an event if the rows count actually changed 132 | if (oldrows !== this.node.rows) { 133 | this.send('resizeComposer', (rows * 17) + this.minimumHeight) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /app/elements/input-prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = InputPrompt 2 | 3 | var inherits = require('util').inherits 4 | var BaseElement = require('./base-element') 5 | var yo = require('yo-yo') 6 | 7 | function InputPrompt (params) { 8 | BaseElement.call(this) 9 | if (!params.onsubmit) throw new Error('param `onsubmit` required') 10 | if (!params.prompt) throw new Error('param `prompt` required') 11 | 12 | this.params = params 13 | this.showInput = false 14 | } 15 | inherits(InputPrompt, BaseElement) 16 | 17 | InputPrompt.prototype.render = function () { 18 | var self = this 19 | var view 20 | 21 | function onsubmit (e) { 22 | e.preventDefault() 23 | var input = this.querySelector('input') 24 | self.params.onsubmit(input.value) 25 | self.showInput = false 26 | self.params.onupdate() 27 | } 28 | 29 | function onblur (e) { 30 | self.showInput = false 31 | self.params.onupdate() 32 | } 33 | 34 | function onclick (e) { 35 | e.preventDefault() 36 | self.showInput = true 37 | self.params.onupdate() 38 | } 39 | 40 | if (self.showInput) { 41 | view = yo` 42 |
    43 | 47 |
    48 | ` 49 | } else { 50 | view = yo`` 51 | } 52 | 53 | self.node = yo`
    ${view}
    ` 54 | 55 | return self.node 56 | } 57 | -------------------------------------------------------------------------------- /app/elements/messages.js: -------------------------------------------------------------------------------- 1 | module.exports = Messages 2 | 3 | var inherits = require('util').inherits 4 | var BaseElement = require('./base-element') 5 | var yo = require('yo-yo') 6 | 7 | function Messages (target) { 8 | BaseElement.call(this, target) 9 | this.shouldAutoScroll = true 10 | // keep track of the scrollTop property here to avoid odd behavior when 11 | // changing scrollHeight while rerendering 12 | this.scrollTop = 0 13 | this.composerHeight = 48 14 | } 15 | inherits(Messages, BaseElement) 16 | 17 | Messages.prototype.render = function (channel, users) { 18 | var self = this 19 | var childViews = renderChildViews.call(self, channel, users) 20 | 21 | function leaveChannel () { 22 | if (channel.name === 'friends') return null 23 | 24 | function onclick () { 25 | if (!channel || channel.name === 'friends') return 26 | self.send('leaveChannel', channel.name) 27 | } 28 | 29 | return yo`` 30 | } 31 | 32 | var channelName = `#${channel ? channel.name : 'friends'}` 33 | var numPeers = yo`${channel.peers} peer${channel.peers === 1 ? '' : 's'}` 34 | 35 | return yo` 36 |
    37 |
    38 |
    ${channelName} ${numPeers}
    39 | ${leaveChannel()} 40 |
    41 | ${childViews} 42 |
    43 | ` 44 | } 45 | 46 | function renderChildViews (channel, users) { 47 | if (channel && channel.messages.length === 0) { 48 | var starterMessage = 'This is a new channel. Send a message to start things off!' 49 | return yo`
    ${starterMessage}
    ` 50 | } 51 | 52 | var self = this 53 | var messages = (channel ? channel.messages : []).map(function (msg) { 54 | var user = users[msg.username] 55 | if (user && user.blocked) return null 56 | 57 | var isVerified = !/Anonymous/i.test(msg.username) && msg.valid 58 | var userUrl = isVerified ? `http://github.com/${msg.username}` : '#' 59 | 60 | function avatar () { 61 | function onclick (e) { 62 | if (userUrl !== '#') { self.send('openUrl', userUrl) } 63 | } 64 | 65 | return yo`` 66 | } 67 | 68 | function username () { 69 | return yo`${msg.username}` 70 | } 71 | 72 | function verified () { 73 | return isVerified ? yo`` : null 74 | } 75 | 76 | function timestamp () { 77 | return yo`${msg.timeago}` 78 | } 79 | 80 | function message () { 81 | var el = yo`
    ` 82 | el.innerHTML = msg.html || msg.text 83 | return el 84 | } 85 | 86 | return yo` 87 |
  • 88 | ${avatar()} 89 |
    90 | ${username()} 91 | ${verified()} 92 | ${timestamp()} 93 |
    94 | ${message()} 95 |
  • 96 | ` 97 | }) 98 | 99 | function onscroll () { 100 | if (this.scrollHeight <= this.clientHeight + this.scrollTop) self.shouldAutoScroll = true 101 | else self.shouldAutoScroll = false 102 | } 103 | 104 | return yo` 105 |
    109 | ${messages} 110 |
    111 | ` 112 | } 113 | 114 | Messages.prototype.scrollToBottom = function (force) { 115 | if (!force && !this.shouldAutoScroll) return 116 | var messagesDiv = document.querySelector('.messages') 117 | if (messagesDiv) messagesDiv.scrollTop = messagesDiv.scrollHeight 118 | } 119 | 120 | Messages.prototype.notifyComposerHeight = function (height) { 121 | var messagesDiv = document.querySelector('.messages') 122 | if (messagesDiv) { 123 | var heightChange = height - this.composerHeight 124 | this.scrollTop = messagesDiv.scrollTop + heightChange 125 | } 126 | 127 | this.composerHeight = height 128 | } 129 | -------------------------------------------------------------------------------- /app/elements/status.js: -------------------------------------------------------------------------------- 1 | module.exports = Status 2 | 3 | var inherits = require('util').inherits 4 | var BaseElement = require('./base-element') 5 | var yo = require('yo-yo') 6 | 7 | function Status (target) { 8 | BaseElement.call(this, target) 9 | } 10 | inherits(Status, BaseElement) 11 | 12 | Status.prototype.render = function (username, peers) { 13 | return yo` 14 |
    15 |
    ${username}
    16 |
    Connected to ${peers} peer${peers === 1 ? '' : 's'}
    17 |
    18 | ` 19 | } 20 | -------------------------------------------------------------------------------- /app/elements/users.js: -------------------------------------------------------------------------------- 1 | module.exports = Users 2 | 3 | var inherits = require('util').inherits 4 | var BaseElement = require('./base-element') 5 | var ModalElement = require('modal-element') 6 | var yo = require('yo-yo') 7 | 8 | function Users (target) { 9 | BaseElement.call(this, target) 10 | var self = this 11 | this.showUserMenuFor = false 12 | this.lastClickPosition = [0, 0] 13 | this.userMenu = new ModalElement(document.body) 14 | this.userMenu.centerOnLoad = false 15 | this.userMenu.on('load', function (node) { 16 | node.childNodes[0].style.top = self.lastClickPosition[1] + 'px' 17 | node.childNodes[0].style.left = self.lastClickPosition[0] + 'px' 18 | }) 19 | } 20 | inherits(Users, BaseElement) 21 | 22 | Users.prototype.render = function (users) { 23 | var self = this 24 | var now = new Date().getTime() 25 | 26 | var activeUsers = [] 27 | var idleUsers = [] 28 | var sortedUsers = Object.keys(users).sort(function (a, b) { 29 | return a.toLowerCase().localeCompare(b.toLowerCase()) 30 | }) 31 | 32 | idleUsers = sortedUsers.filter(function (username) { 33 | var user = users[username] 34 | // User is "active" if they sent a message in the last 60 mins (3600000ms) 35 | if (now - user.lastActive < 3600000) { 36 | activeUsers.push(username) 37 | return false 38 | } 39 | return true 40 | }) 41 | 42 | idleUsers = idleUsers.map(enrichUsers) 43 | activeUsers = activeUsers.map(enrichUsers) 44 | 45 | function enrichUsers (username) { 46 | var user = users[username] 47 | var className = user.blocked ? 'blocked' : '' 48 | 49 | function oncontextmenu (e) { 50 | e.preventDefault() 51 | self.showUserMenuFor = username 52 | self.lastClickPosition = [e.clientX, e.clientY] 53 | self.send('render') 54 | } 55 | 56 | return yo` 57 |
  • 58 | 62 |
  • 63 | ` 64 | } 65 | 66 | // Build user menu 67 | this.userMenu.shown = !!this.showUserMenuFor 68 | var ignoreLabel = 'mute' 69 | if (users[this.showUserMenuFor] && users[this.showUserMenuFor].blocked) ignoreLabel = 'unmute' 70 | 71 | function onclick (e) { 72 | e.preventDefault() 73 | self.send('toggleBlockUser', self.showUserMenuFor) 74 | } 75 | 76 | this.userMenu.render( 77 | yo` 78 | 90 | ` 91 | ) 92 | 93 | return yo` 94 |
    95 |
    Active Users (${activeUsers.length})
    96 | 97 |
    Idle Users (${idleUsers.length})
    98 | 99 |
    100 | ` 101 | } 102 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | /* global Notification */ 2 | 3 | module.exports = window.App = App 4 | 5 | var EventEmitter = require('events').EventEmitter 6 | 7 | var catNames = require('cat-names') 8 | var delegate = require('dom-delegate') 9 | var eos = require('end-of-stream') 10 | var githubCurrentUser = require('github-current-user') 11 | var inherits = require('inherits') 12 | var leveldown = require('leveldown') // browser: level-js 13 | var levelup = require('levelup') 14 | var subleveldown = require('subleveldown') 15 | var richMessage = require('rich-message') 16 | var Swarm = require('friends-swarm') 17 | var yo = require('yo-yo') 18 | 19 | var config = require('../config') 20 | var util = require('./util') 21 | var command = require('./command') 22 | var Signature = require('./signature') 23 | var Channels = require('./elements/channels') 24 | var Composer = require('./elements/composer') 25 | var Messages = require('./elements/messages') 26 | var Status = require('./elements/status') 27 | var Users = require('./elements/users') 28 | 29 | inherits(App, EventEmitter) 30 | 31 | function App (el, currentWindow) { 32 | var self = this 33 | if (!(self instanceof App)) return new App(el) 34 | self._notifications = 0 35 | self.currentWindow = currentWindow 36 | 37 | var db = levelup(config.DB_PATH, { db: leveldown }) 38 | 39 | db.channels = subleveldown(db, 'channels', { valueEncoding: 'json' }) 40 | db.aliases = subleveldown(db, 'aliases', { valueEncoding: 'json' }) 41 | 42 | // Open links in user's default browser 43 | delegate(el).on('click', 'a', function (e) { 44 | var href = e.target.getAttribute('href') 45 | if (/^https?:/.test(href)) { 46 | e.preventDefault() 47 | self.emit('openUrl', href) 48 | } else if (/^#/.test(href)) { 49 | self.emit('addChannel', href) 50 | } 51 | }) 52 | 53 | // The mock data model 54 | self.data = { 55 | peers: 0, 56 | username: 'Anonymous (' + catNames.random() + ')', 57 | channels: [], 58 | messages: [], 59 | users: [], 60 | activeChannel: null 61 | } 62 | 63 | var swarm = window.swarm = Swarm(subleveldown(db, 'swarm'), { maxPeers: 20 }) 64 | var channelsFound = {} 65 | var usersFound = {} 66 | var changesOffsets = {} 67 | 68 | // join default channel 69 | swarm.addChannel('friends') 70 | 71 | if (githubCurrentUser.verify) githubCurrentUser.verify(onCurrentUser) 72 | else onCurrentUser(null, false) 73 | 74 | function onCurrentUser (err, verified, username) { 75 | if (err || !verified) self.emit('showGitHelp') 76 | if (err) return console.error(err.message || err) 77 | if (verified) { 78 | self.data.username = username 79 | swarm.sign(Signature.signer(username)) 80 | 81 | // Re-create rich messages after we know our username, since we can now do 82 | // highlights correctly. 83 | self.data.messages = self.data.messages.map(function (message) { 84 | return richMessage(message, self.data.username) 85 | }) 86 | 87 | render() 88 | } 89 | } 90 | 91 | swarm.verify(Signature.verify) 92 | 93 | swarm.process(function (basicMessage, cb) { 94 | var message = richMessage(basicMessage, self.data.username) 95 | var channelName = message.channel || 'friends' 96 | var channel = channelsFound[channelName] 97 | 98 | if (!channel) { 99 | channel = channelsFound[channelName] = { 100 | id: self.data.channels.length, 101 | name: channelName, 102 | active: false, 103 | peers: 0, 104 | messages: [] 105 | } 106 | self.data.channels.push(channel) 107 | self.data.activeChannel = channel 108 | } 109 | 110 | if (!changesOffsets[channel.name]) changesOffsets[channel.name] = swarm.changes(channel.name) 111 | 112 | if (self.data.username && !self.isFocused()) { 113 | if (message.text.indexOf(self.data.username) > -1) { 114 | new Notification('Mentioned in #' + channel.name, { // eslint-disable-line 115 | body: message.username + ': ' + message.text.slice(0, 20) 116 | }) 117 | self.emit('setBadge') 118 | } 119 | } 120 | 121 | var lastMessage = channel.messages[channel.messages.length - 1] 122 | if (lastMessage && lastMessage.username === message.username) { 123 | // Last message came from same user, so merge into the last message 124 | message = richMessage.mergeMessages(lastMessage, message) 125 | } else { 126 | channel.messages.push(message) 127 | } 128 | 129 | if (!message.anon && message.valid) { 130 | if (!usersFound[message.username]) { 131 | usersFound[message.username] = true 132 | self.data.users[message.username] = { 133 | avatar: message.avatar, 134 | blocked: false 135 | } 136 | 137 | // Add user names to available autocompletes 138 | self.views.composer.autocompletes.push(message.username) 139 | } 140 | 141 | // update last active time for user 142 | self.data.users[message.username].lastActive = message.timestamp 143 | } 144 | if (!message.anon && !message.valid) { 145 | message.username = 'Allegedly ' + message.username 146 | } 147 | 148 | if (changesOffsets[channel.name] <= basicMessage.change) { 149 | render() 150 | self.views.messages.scrollToBottom() 151 | } 152 | 153 | cb() 154 | }) 155 | 156 | swarm.on('peer', function (p, channel) { 157 | var ch = channelsFound[channel] 158 | if (ch) ch.peers++ 159 | self.data.peers++ 160 | render() 161 | eos(p, function () { 162 | if (ch) ch.peers-- 163 | self.data.peers-- 164 | render() 165 | }) 166 | }) 167 | 168 | channelsFound.friends = { 169 | id: 0, 170 | name: 'friends', 171 | active: true, 172 | peers: 0, 173 | messages: [] 174 | } 175 | 176 | self.data.channels.push(channelsFound.friends) 177 | self.data.messages = channelsFound.friends.messages 178 | self.data.activeChannel = channelsFound.friends 179 | 180 | // View instances used in our App 181 | self.views = { 182 | channels: new Channels(self), 183 | composer: new Composer(self), 184 | messages: new Messages(self), 185 | status: new Status(self), 186 | users: new Users(self) 187 | } 188 | 189 | // Initial DOM tree render 190 | var tree = self.render() 191 | el.appendChild(tree) 192 | 193 | function render () { 194 | var newTree = self.render() 195 | yo.update(tree, newTree) 196 | } 197 | 198 | self.on('render', render) 199 | 200 | self.on('selectChannel', function (channelName) { 201 | self.data.channels.forEach(function (channel) { 202 | channel.active = (channelName === channel.name) 203 | if (channel.active) { 204 | self.data.messages = channel.messages 205 | self.data.activeChannel = channel 206 | if (channel.name !== 'friends') db.channels.put(channel.name, { name: channel.name, id: channel.id }) 207 | } 208 | }) 209 | render() 210 | self.views.composer.focus() 211 | self.views.messages.scrollToBottom() 212 | }) 213 | 214 | self.on('sendMessage', function (text) { 215 | text = text.trim() 216 | if (text.length === 0) return 217 | 218 | swarm.send({ 219 | username: self.data.username, 220 | channel: self.data.activeChannel && self.data.activeChannel.name, 221 | text: text, 222 | timestamp: Date.now() 223 | }) 224 | }) 225 | 226 | self.on('executeCommand', command(self, db)) 227 | 228 | self.on('addChannel', function (channelName) { 229 | if (channelName.charAt(0) === '#') channelName = channelName.substring(1) 230 | if (channelName.length === 0) return 231 | 232 | if (!channelsFound[channelName]) { 233 | var channel = channelsFound[channelName] = { 234 | name: channelName, 235 | id: self.data.channels.length, 236 | peers: 0, 237 | active: false, 238 | messages: [] 239 | } 240 | self.data.channels.push(channel) 241 | swarm.addChannel(channelName) 242 | db.channels.put(channelName, { 243 | name: channelName, 244 | id: self.data.channels.length 245 | }) 246 | } 247 | self.emit('selectChannel', channelName) 248 | }) 249 | 250 | self.on('leaveChannel', function (channelName) { 251 | if (channelName === 'friends') return // can't leave friends for now 252 | db.channels.del(channelName, function () { 253 | var channel = channelsFound[channelName] 254 | if (!channel) return 255 | var i = self.data.channels.indexOf(channel) 256 | if (i > -1) self.data.channels.splice(i, 1) 257 | delete channelsFound[channelName] 258 | swarm.removeChannel(channelName) 259 | self.emit('selectChannel', 'friends') 260 | render() 261 | }) 262 | }) 263 | 264 | self.on('toggleBlockUser', function (username) { 265 | var user = self.data.users[username] 266 | if (user) user.blocked = !user.blocked 267 | render() 268 | self.views.messages.scrollToBottom(true) 269 | }) 270 | 271 | self.on('resizeComposer', function (height) { 272 | self.views.messages.notifyComposerHeight(height) 273 | render() 274 | }) 275 | 276 | // Update friendly "timeago" time string (once per minute) 277 | setInterval(function () { 278 | self.data.activeChannel.messages.forEach(function (message) { 279 | message.timeago = util.timeago(message.timestamp) 280 | }) 281 | }, 60 * 1000) 282 | 283 | db.channels.createValueStream() 284 | .on('data', function (data) { 285 | data.messages = [] 286 | data.peers = 0 287 | self.data.channels.push(data) 288 | channelsFound[data.name] = data 289 | swarm.addChannel(data.name) 290 | }) 291 | .on('end', function () { 292 | render() 293 | }) 294 | } 295 | 296 | App.prototype.render = function () { 297 | var self = this 298 | var views = self.views 299 | var data = self.data 300 | 301 | return yo` 302 |
    303 | 310 |
    311 | ${views.messages.render(data.activeChannel, data.users)} 312 | ${views.composer.render(data)} 313 |
    314 |
    315 | ` 316 | } 317 | 318 | App.prototype.isFocused = function () { 319 | if (this.currentWindow) return this.currentWindow.isFocused() 320 | return true 321 | } 322 | -------------------------------------------------------------------------------- /app/signature.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var ghsign = require('ghsign') 3 | var get = require('simple-get') 4 | var mkdirp = require('mkdirp') 5 | var path = require('path') 6 | var config = require('../config') 7 | var verifiers = {} 8 | 9 | module.exports.signer = signer 10 | module.exports.verify = verify 11 | 12 | if (!process.browser) { 13 | ghsign = ghsign(function (username, cb) { 14 | var userKeysPath = path.join(config.KEYS_PATH, username + '.keys') 15 | fs.readFile(userKeysPath, 'utf-8', function (_, keys) { 16 | if (keys) return cb(null, keys) 17 | get.concat('https://github.com/' + username + '.keys', function (_, res, body) { 18 | var keys = res.statusCode === 200 && body 19 | if (!keys) return cb(new Error('Could not find public keys for ' + username)) 20 | mkdirp(config.KEYS_PATH, function () { 21 | fs.writeFile(userKeysPath, keys, function () { 22 | cb(null, keys.toString()) 23 | }) 24 | }) 25 | }) 26 | }) 27 | }) 28 | } 29 | 30 | function signer (username) { 31 | return ghsign.signer && ghsign.signer(username) 32 | } 33 | 34 | function verify (username, message, signature, cb) { 35 | var vfy = verifiers[username] 36 | if (!vfy && ghsign.verifier) verifiers[username] = vfy = ghsign.verifier(username) 37 | if (!vfy || !username || !signature) return cb(null, false) 38 | 39 | vfy(message, signature, cb) 40 | } 41 | -------------------------------------------------------------------------------- /app/style/button.styl: -------------------------------------------------------------------------------- 1 | /* BSD License (http://purecss.io/buttons/) */ 2 | 3 | .button 4 | display: inline-block 5 | zoom: 1 6 | line-height: normal 7 | white-space: nowrap 8 | vertical-align: middle 9 | text-align: center 10 | cursor: pointer 11 | -webkit-user-drag: none 12 | -webkit-user-select: none 13 | -moz-user-select: none 14 | -ms-user-select: none 15 | user-select: none 16 | box-sizing: border-box 17 | 18 | /* Firefox: Get rid of the inner focus border */ 19 | .button::-moz-focus-inner 20 | padding: 0 21 | border: 0 22 | 23 | .button 24 | font-family: inherit 25 | font-size: 100% 26 | padding: 0.5em 1em 27 | color: #444 /* rgba not supported (IE 8) */ 28 | color: rgba(0, 0, 0, 0.80) /* rgba supported */ 29 | border: 1px solid #999 /*IE 6/7/8*/ 30 | border: none rgba(0, 0, 0, 0) /*IE9 + everything else*/ 31 | background-color: #E6E6E6 32 | text-decoration: none 33 | border-radius: 2px 34 | 35 | .button-hover, 36 | .button:hover, 37 | .button:focus 38 | background-image: linear-gradient(transparent, rgba(0,0,0, 0.05) 40%, rgba(0,0,0, 0.10)) 39 | 40 | .button:focus 41 | outline: 0 42 | 43 | .button-active, 44 | .button:active 45 | box-shadow: 0 0 0 1px rgba(0,0,0, 0.15) inset, 0 0 6px rgba(0,0,0, 0.20) inset 46 | border-color: #000 47 | 48 | .button[disabled], 49 | .button-disabled, 50 | .button-disabled:hover, 51 | .button-disabled:focus, 52 | .button-disabled:active 53 | border: none 54 | background-image: none 55 | opacity: 0.40 56 | cursor: not-allowed 57 | box-shadow: none 58 | 59 | .button-hidden 60 | display: none 61 | 62 | /* Firefox: Get rid of the inner focus border */ 63 | .button::-moz-focus-inner 64 | padding: 0 65 | border: 0 66 | 67 | .button-primary, 68 | .button-selected, 69 | a.button-primary, 70 | a.button-selected 71 | background-color: rgb(0, 120, 231) 72 | color: #fff 73 | 74 | 75 | -------------------------------------------------------------------------------- /app/style/index.styl: -------------------------------------------------------------------------------- 1 | @import 'nib' 2 | @import './button' 3 | 4 | WHITE = rgb(255, 255, 255) 5 | 6 | GRAY_HIGHLIGHT = rgb(240, 240, 240) 7 | GRAY_LIGHTEST = rgb(220, 220, 220) 8 | GRAY_LIGHT = rgb(200, 200, 200) 9 | GRAY = rgb(100, 100, 100) 10 | BLACK = rgb(30, 30, 30) 11 | 12 | BLUE_DARKEST = rgb(17, 74, 86) 13 | BLUE_DARK = rgb(1, 82, 99) 14 | BLUE = rgb(0, 158, 179) 15 | BLUE_LIGHT = rgb(208, 255, 255) 16 | 17 | RED_LIGHT = rgb(232, 74, 62) 18 | RED = rgb(180, 35, 8) 19 | 20 | HIGHLIGHT = rgb(250, 250, 220) 21 | 22 | SCROLLBAR_TRACK = transparent 23 | SCROLLBAR_THUMB_DARKEN = rgba(0,0,0,0.175) 24 | SCROLLBAR_THUMB_LIGHTEN = rgba(255,255,255,0.175) 25 | 26 | SIDEBAR_WIDTH = 190px 27 | FRIENDS_LOGO_HEIGHT = 70px 28 | SIDEBAR_BOTTOM_HEIGHT = 61px 29 | 30 | AVATAR_SIZE = 35px 31 | AVATAR_SIZE_SMALL = 18px 32 | 33 | BORDER_RADIUS = 5px 34 | 35 | FONT_SIZE_BIG = 20px 36 | FONT_SIZE = 15px 37 | FONT_SIZE_SMALL = 12px 38 | 39 | TOP_BAR_HEIGHT = 45px 40 | 41 | SPACING_SIZE = 10px 42 | 43 | ellipsis() 44 | overflow: hidden 45 | text-overflow: ellipsis 46 | white-space: nowrap 47 | 48 | .clearfix:before, .clearfix:after 49 | content: '' 50 | display: table 51 | 52 | .clearfix:after 53 | clear: both 54 | 55 | *, *:after, *:before 56 | box-sizing: border-box 57 | 58 | html, body 59 | margin: 0 60 | padding: 0 61 | height: 100% 62 | overflow: hidden 63 | 64 | h1, h2, h3, h4, h5, h6 65 | margin: 0.1em 0 66 | h1 67 | font-size: 1.5em 68 | h2 69 | font-size: 1.3em 70 | h3 71 | font-size: 1.1em 72 | h4 73 | font-size: 0.9em 74 | h5 75 | font-size: 0.7em 76 | h6 77 | font-size: 0.5em 78 | ul 79 | -webkit-padding-start: 20px 80 | 81 | body 82 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif 83 | font-size: FONT_SIZE 84 | 85 | textarea, input, button 86 | outline: none 87 | 88 | button 89 | background: transparent 90 | border: none 91 | font-size: 1em 92 | 93 | .sidebar 94 | position: absolute 95 | left: 0 96 | top: 0 97 | bottom: 0 98 | width: SIDEBAR_WIDTH 99 | height: 100% 100 | color: GRAY_LIGHTEST 101 | background: BLUE_DARK top left url('../../static/logo.png') no-repeat 102 | background-size: SIDEBAR_WIDTH 103 | padding-top: FRIENDS_LOGO_HEIGHT 104 | padding-bottom: SIDEBAR_BOTTOM_HEIGHT 105 | user-select: none 106 | cursor: default 107 | text-shadow: 0 1px 2px rgba(BLACK, 0.6) 108 | 109 | .sidebar-scroll 110 | overflow: auto 111 | height: 100% 112 | padding: 0 SPACING_SIZE 4em SPACING_SIZE 113 | 114 | .heading 115 | font-size: FONT_SIZE_SMALL 116 | margin-top: (SPACING_SIZE * 2) 117 | margin-bottom: SPACING_SIZE 118 | text-transform: uppercase 119 | font-weight: bold 120 | letter-spacing: 0.5px 121 | 122 | button 123 | ellipsis() 124 | color: GRAY_LIGHTEST 125 | text-decoration: none 126 | padding: (SPACING_SIZE / 3) (SPACING_SIZE * 2) 127 | margin: 0 128 | display: block 129 | width: 100% 130 | text-align: left 131 | 132 | &:hover 133 | color: GRAY_HIGHLIGHT 134 | 135 | .add-channel 136 | margin: 0 (-1 * SPACING_SIZE) 137 | button 138 | padding: (SPACING_SIZE / 2) (SPACING_SIZE * 2) 139 | .inputprompt 140 | margin: 0 (SPACING_SIZE * 2) 141 | input 142 | padding: (SPACING_SIZE / 3) SPACING_SIZE 143 | font-size: FONT_SIZE 144 | width: 100% 145 | z-index: 10000 146 | 147 | ul 148 | list-style: none 149 | margin: 0 150 | padding: 0 151 | 152 | li 153 | ellipsis() 154 | margin: 0 155 | padding: 0 156 | margin: 0 (-1 * SPACING_SIZE) 157 | 158 | &.active 159 | background-color: RED_LIGHT 160 | button 161 | color: WHITE 162 | 163 | .avatar 164 | float: left 165 | border-radius: (BORDER_RADIUS / 2) 166 | width: AVATAR_SIZE_SMALL 167 | height: AVATAR_SIZE_SMALL 168 | margin-right: (SPACING_SIZE * (2 / 3)) 169 | opacity: 0.8 170 | 171 | .status 172 | background-color: BLUE_DARKEST 173 | text-align: center 174 | position: absolute 175 | padding: SPACING_SIZE 0 176 | bottom: 0 177 | left: 0 178 | right: 0 179 | 180 | .username, .peers 181 | ellipsis() 182 | .peers 183 | margin-top: SPACING_SIZE 184 | font-style: italic 185 | font-size: FONT_SIZE_SMALL 186 | 187 | .users .blocked 188 | opacity: 0.5 189 | 190 | .content 191 | position: absolute 192 | right: 0 193 | top: 0 194 | bottom: 0 195 | left: SIDEBAR_WIDTH 196 | 197 | .messages-container 198 | height: 100% 199 | 200 | .top-bar 201 | background-color: WHITE 202 | box-shadow: 0 0 7px 0 GRAY_LIGHT 203 | padding: SPACING_SIZE 204 | height: TOP_BAR_HEIGHT 205 | 206 | .channel-name 207 | display: inline-block 208 | color: BLACK 209 | font-size: FONT_SIZE_BIG 210 | font-weight: bold 211 | 212 | .num-peers 213 | font-size: FONT_SIZE_SMALL 214 | font-weight: normal 215 | color: GRAY_LIGHT 216 | 217 | .button 218 | float: right 219 | margin-top: -3px 220 | 221 | .messages 222 | overflow-y: auto 223 | margin: 0 224 | height: calc(100% - 45px) 225 | padding: 0 226 | margin-bottom: TOP_BAR_HEIGHT 227 | 228 | li 229 | list-style: none 230 | 231 | .message 232 | margin: SPACING_SIZE 233 | a 234 | color: #0084B4 235 | .avatar 236 | float: left 237 | border-radius: BORDER_RADIUS 238 | width: AVATAR_SIZE 239 | height: AVATAR_SIZE 240 | cursor: pointer 241 | .username, .verified, .timestamp 242 | line-height: 1 243 | .username 244 | font-weight: bold 245 | margin-left: SPACING_SIZE 246 | text-decoration: none 247 | color: inherit 248 | .verified 249 | display: inline-block 250 | background-image: url('../../static/verified.svg') 251 | background-size: 10px 252 | width: 10px 253 | height: 10px 254 | opacity: 0.8 255 | .timestamp 256 | color: GRAY_LIGHT 257 | font-size: FONT_SIZE_SMALL 258 | .text 259 | margin-left: AVATAR_SIZE + SPACING_SIZE 260 | overflow-wrap: break-word 261 | .highlight 262 | background-color: HIGHLIGHT 263 | padding-left: 4px 264 | margin-left: -4px 265 | margin-right: (-1 * SPACING_SIZE) 266 | p 267 | margin: 4px 0 268 | img 269 | max-width: 100% 270 | pre 271 | margin: 0 272 | 273 | .starterMessage 274 | color: GRAY 275 | font-style: italic 276 | font-size: FONT_SIZE_BIG 277 | text-align: center 278 | margin: (SPACING_SIZE * 3) SPACING_SIZE SPACING_SIZE SPACING_SIZE 279 | 280 | input, textarea 281 | outline: 0 282 | 283 | textarea.composer 284 | border-radius: BORDER_RADIUS 285 | border: solid 3px GRAY_LIGHT 286 | bottom: 0 287 | box-shadow: 0 0 0 2px WHITE 288 | box-sizing: padding-box 289 | display: block 290 | font-size: FONT_SIZE 291 | margin: SPACING_SIZE 292 | overflow: hidden 293 | padding: SPACING_SIZE 294 | position: absolute 295 | width: calc(100% - 25px) 296 | resize: none 297 | 298 | img.emoji 299 | height: 1.2em 300 | width: 1.2em 301 | margin: 0 .05em 0 .1em 302 | vertical-align: -0.1em 303 | 304 | .modal-overlay 305 | background-color: rgba(0,0,0,.001) 306 | width: 100% 307 | height: 100% 308 | position: fixed 309 | top: 0 310 | left: 0 311 | z-index: 9999 312 | .modal 313 | position: absolute 314 | top: 50% 315 | left: 50% 316 | z-index: 9998 317 | background-color: WHITE 318 | border-radius: 3px 319 | padding: .5em 320 | color: BLACK 321 | box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12) 322 | margin-top: 10px 323 | ul 324 | margin: 0 325 | padding: 0 326 | li 327 | list-style: none 328 | a,button 329 | width: 100% 330 | display: block 331 | text-decoration: none 332 | color: BLACK 333 | background: none 334 | border: none 335 | text-align: left 336 | padding: .3em 337 | cursor: pointer 338 | &:hover 339 | background-color: GRAY_LIGHTEST 340 | 341 | ::-webkit-scrollbar 342 | width: 10px 343 | 344 | ::-webkit-scrollbar-track 345 | background-color: SCROLLBAR_TRACK 346 | border-radius: 8px 347 | 348 | ::-webkit-scrollbar-thumb 349 | background-color: SCROLLBAR_THUMB_DARKEN 350 | border-radius: 8px 351 | 352 | .sidebar 353 | ::-webkit-scrollbar-thumb 354 | background-color: SCROLLBAR_THUMB_LIGHTEN 355 | border-radius: 8px 356 | -------------------------------------------------------------------------------- /app/util.js: -------------------------------------------------------------------------------- 1 | var moment = require('moment') 2 | 3 | exports.timeago = function (milliseconds) { 4 | var timeago = moment(milliseconds).calendar() 5 | if (timeago.indexOf('Today at ') === 0) return timeago.substring(9) 6 | return timeago 7 | } 8 | -------------------------------------------------------------------------------- /app/web.js: -------------------------------------------------------------------------------- 1 | module.exports = Web 2 | 3 | var inherits = require('util').inherits 4 | var App = require('./') 5 | 6 | inherits(Web, App) 7 | 8 | function Web () { 9 | if (!(this instanceof Web)) return new Web() 10 | App.call(this, document.body) 11 | 12 | this.on('openUrl', function (url) { 13 | window.open(url) 14 | }) 15 | } 16 | 17 | // Start onload 18 | window.friends = Web() 19 | -------------------------------------------------------------------------------- /app/windows/git-help.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Help with GitHub Authentication 4 | 5 | 6 | 7 | 28 | 29 | 43 | 44 |

    We couldn't automatically authenticate you with GitHub credentials!

    45 | 46 |

    Other authentication methods will be available but right now GitHub is 47 | your only option for signed messages and verifying your username.

    48 | 49 |

    Here's what we were able to figure out about who you are:

    50 | 51 | 55 | 56 |
    57 |

    We also saw this error:

    58 | 59 |

    60 |
    61 | 62 |

    Reasons authentication can fail

    63 | 64 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /app/windows/git-help.js: -------------------------------------------------------------------------------- 1 | var delegate = require('dom-delegate') 2 | var githubCurrentUser = require('github-current-user') 3 | 4 | function AuthenticationHelp (el) { 5 | delegate(el).on('click', 'button', window.close) 6 | 7 | githubCurrentUser.verify(function (err, verified, username) { 8 | if (err) { 9 | var errorWrapperEl = el.querySelector('#error-wrapper') 10 | var errorEl = el.querySelector('#error') 11 | 12 | errorWrapperEl.style.display = 'block' 13 | errorEl.innerHTML = err 14 | } 15 | 16 | var usernameEl = el.querySelector('#username') 17 | var validKeyEl = el.querySelector('#valid-key') 18 | 19 | usernameEl.innerHTML = username || 'unknown' 20 | 21 | if (typeof verified !== 'undefined') { 22 | validKeyEl.innerHTML = verified 23 | } else { 24 | validKeyEl.innerHTML = '' 25 | } 26 | }) 27 | } 28 | 29 | module.exports = window.AuthenticationHelp = AuthenticationHelp 30 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | var applicationConfigPath = require('application-config-path') 2 | var path = require('path') 3 | 4 | var CONFIG_PATH = applicationConfigPath('Friends') 5 | var ROOT_PATH = __dirname 6 | 7 | module.exports = { 8 | APP_NAME: 'Friends', 9 | 10 | CONFIG_PATH: CONFIG_PATH, 11 | DB_PATH: path.join(CONFIG_PATH, 'friendsdb'), 12 | KEYS_PATH: path.join(CONFIG_PATH, 'public-keys'), 13 | 14 | INDEX: 'file://' + path.join(ROOT_PATH, 'index.html'), 15 | GIT_HELP: 'file://' + path.join(ROOT_PATH, 'app', 'windows', 'git-help.html') 16 | } 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Friends 4 | 5 | 6 | 7 | 8 | 9 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var BrowserWindow = require('browser-window') 2 | var config = require('./config') 3 | var app = require('app') 4 | 5 | app.on('ready', appReady) 6 | 7 | var mainWindow 8 | 9 | function appReady () { 10 | mainWindow = new BrowserWindow({ 11 | width: 800, 12 | height: 600, 13 | title: config.APP_NAME 14 | }) 15 | 16 | mainWindow.loadURL(config.INDEX) 17 | 18 | mainWindow.on('closed', function () { 19 | mainWindow = null 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friends", 3 | "description": "P2P chat powered by the Web.", 4 | "version": "1.0.0", 5 | "author": [ 6 | "feross", 7 | "maxogden", 8 | "mafintosh", 9 | "ngoldman", 10 | "shama", 11 | "jlord", 12 | "chrisdickinson" 13 | ], 14 | "browser": { 15 | "ghsign": false, 16 | "github-current-user": false, 17 | "leveldown": "level-js" 18 | }, 19 | "browserify": { 20 | "transforms": [ 21 | "brfs" 22 | ] 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/moose-team/friends/issues" 26 | }, 27 | "dependencies": { 28 | "application-config-path": "^0.1.0", 29 | "cat-names": "^1.0.2", 30 | "dom-delegate": "^2.0.3", 31 | "end-of-stream": "^1.1.0", 32 | "friends-swarm": "^2.0.0", 33 | "ghsign": "^3.0.1", 34 | "github-current-user": "^2.5.0", 35 | "highlight.js": "^9.2.0", 36 | "inherits": "^2.0.1", 37 | "level-js": "^2.2.1", 38 | "leveldown": "^1.4.1", 39 | "levelup": "^1.2.1", 40 | "lodash.uniq": "^4.2.0", 41 | "mkdirp": "^0.5.1", 42 | "modal-element": "^1.0.0", 43 | "moment": "^2.12.0", 44 | "rich-message": "^1.0.2", 45 | "silence-chromium": "^2.0.0", 46 | "simple-get": "^2.0.0", 47 | "subleveldown": "^2.0.0", 48 | "yo-yo": "^1.1.1" 49 | }, 50 | "devDependencies": { 51 | "beefy": "^2.1.5", 52 | "brfs": "^1.4.1", 53 | "electron-packager": "^5.0.2", 54 | "electron-prebuilt": "0.36.10", 55 | "nib": "^1.1.0", 56 | "node-gyp": "^2.0.2", 57 | "rimraf": "^2.3.3", 58 | "shelljs": "^0.4.0", 59 | "standard": "*", 60 | "stylus": "^0.52.0", 61 | "watchify": "^3.2.1" 62 | }, 63 | "engines": { 64 | "node": ">=4", 65 | "npm": ">=2.8.3" 66 | }, 67 | "homepage": "https://github.com/moose-team/friends", 68 | "keywords": [ 69 | "chat", 70 | "communication", 71 | "crypto", 72 | "discussion", 73 | "friends", 74 | "irc", 75 | "mad science", 76 | "p2p", 77 | "peer-to-peer", 78 | "replication", 79 | "slack", 80 | "team chat", 81 | "webrtc" 82 | ], 83 | "license": "MIT", 84 | "main": "index.js", 85 | "repository": { 86 | "type": "git", 87 | "url": "https://github.com/moose-team/friends.git" 88 | }, 89 | "scripts": { 90 | "build-css": "stylus -u nib app/style/index.styl -o app/style/bundle.css -c", 91 | "prepackage": "npm run build-css", 92 | "package": "node pkg.js", 93 | "package-all": "npm run package -- --all", 94 | "rebuild-leveldb": "cd node_modules/leveldown && set HOME=~/.electron-gyp && node-gyp rebuild --target=$npm_package_devDependencies_electron_prebuilt --arch=x64 --dist-url=https://atom.io/download/atom-shell", 95 | "start": "npm run build-css && electron index.js 2>&1 | silence-chromium", 96 | "test": "standard", 97 | "watch": "npm run build-css && (npm run watch-css & electron index.js 2>&1 | silence-chromium)", 98 | "watch-css": "stylus -u nib app/style/index.styl -o app/style/bundle.css -w", 99 | "web": "beefy app/web.js:bundle.js" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var os = require('os') 4 | var pkgjson = require('./package.json') 5 | var path = require('path') 6 | var sh = require('shelljs') 7 | 8 | var appVersion = pkgjson.version 9 | var appName = pkgjson.name 10 | var electronPackager = path.join('node_modules', '.bin', 'electron-packager') 11 | var electronVersion = pkgjson.devDependencies['electron-prebuilt'] 12 | var icon = path.join('static', 'Icon.icns') 13 | 14 | if (process.argv[2] === '--all') { 15 | // build for all platforms 16 | var archs = ['ia32', 'x64'] 17 | var platforms = ['linux', 'win32', 'darwin'] 18 | 19 | platforms.forEach(function (plat) { 20 | archs.forEach(function (arch) { 21 | pack(plat, arch) 22 | }) 23 | }) 24 | } else { 25 | // build for current platform only 26 | pack(os.platform(), os.arch()) 27 | } 28 | 29 | function pack (plat, arch) { 30 | var prefix = os.platform() === 'win32' ? '.\\' : './' 31 | var rimraf = path.join('node_modules', '.bin', 'rimraf') 32 | var outputPath = path.join('pkg', appVersion, plat, arch) 33 | 34 | // there is no darwin ia32 electron 35 | if (plat === 'darwin' && arch === 'ia32') return 36 | 37 | var cmd1 = `${prefix}${rimraf} ${outputPath}` 38 | var cmd2 = `${prefix}${electronPackager} . ${appName} ` + 39 | `--platform=${plat} ` + 40 | `--arch=${arch} ` + 41 | `--version=${electronVersion} ` + 42 | `--app-version=${appVersion} ` + 43 | `--icon=${icon} ` + 44 | `--out=${outputPath} ` + 45 | '--prune ' + 46 | '--ignore=pkg' // ignore the pkg directory or hilarity will ensue 47 | 48 | console.log(`${cmd1}\n${cmd2}`) 49 | 50 | if (process.argv.slice(2)[0] === '--dry') process.exit(0) 51 | 52 | sh.exec(cmd1) 53 | sh.exec(cmd2) 54 | } 55 | -------------------------------------------------------------------------------- /static/Icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moose-team/friends/c38652f083185aaf433e40b63849417d7c4466ee/static/Icon.icns -------------------------------------------------------------------------------- /static/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moose-team/friends/c38652f083185aaf433e40b63849417d7c4466ee/static/Icon.png -------------------------------------------------------------------------------- /static/Icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moose-team/friends/c38652f083185aaf433e40b63849417d7c4466ee/static/Icon@2x.png -------------------------------------------------------------------------------- /static/friends-logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moose-team/friends/c38652f083185aaf433e40b63849417d7c4466ee/static/friends-logo.psd -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moose-team/friends/c38652f083185aaf433e40b63849417d7c4466ee/static/logo.png -------------------------------------------------------------------------------- /static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moose-team/friends/c38652f083185aaf433e40b63849417d7c4466ee/static/screenshot.png -------------------------------------------------------------------------------- /static/startup.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moose-team/friends/c38652f083185aaf433e40b63849417d7c4466ee/static/startup.ogg -------------------------------------------------------------------------------- /static/verified.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | --------------------------------------------------------------------------------