├── src ├── assets │ └── icon.png ├── views │ ├── 404.js │ └── main.js ├── components │ ├── icons │ │ ├── moon.js │ │ ├── clipboard.js │ │ ├── key.js │ │ ├── users.js │ │ └── sun.js │ ├── users.js │ ├── user.js │ ├── header.js │ ├── view-messages.js │ ├── input-msg.js │ ├── key-modal.js │ ├── init-modal.js │ └── message.js ├── index.css ├── index.js ├── config.js ├── lib │ ├── theme.js │ ├── db-names.js │ └── saga.js └── stores │ ├── ui.js │ └── chat.js ├── .env.example ├── assets └── favicon │ ├── favicon.ico │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── apple-icon-precomposed.png │ ├── browserconfig.xml │ └── manifest.json ├── .gitignore ├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── STEPS.md ├── README.md ├── manifest.webmanifest ├── index.html └── package.json /src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/src/assets/icon.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | #ICE_URLS=stun:localhost:3478 2 | #SIGNAL_URLS=http://localhost:4000 3 | -------------------------------------------------------------------------------- /assets/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/favicon.ico -------------------------------------------------------------------------------- /assets/favicon/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon.png -------------------------------------------------------------------------------- /assets/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /assets/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /assets/favicon/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/ms-icon-144x144.png -------------------------------------------------------------------------------- /assets/favicon/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/ms-icon-150x150.png -------------------------------------------------------------------------------- /assets/favicon/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/ms-icon-310x310.png -------------------------------------------------------------------------------- /assets/favicon/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/ms-icon-70x70.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-57x57.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-60x60.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-72x72.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-76x76.png -------------------------------------------------------------------------------- /assets/favicon/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/android-icon-144x144.png -------------------------------------------------------------------------------- /assets/favicon/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/android-icon-192x192.png -------------------------------------------------------------------------------- /assets/favicon/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/android-icon-36x36.png -------------------------------------------------------------------------------- /assets/favicon/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/android-icon-48x48.png -------------------------------------------------------------------------------- /assets/favicon/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/android-icon-72x72.png -------------------------------------------------------------------------------- /assets/favicon/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/android-icon-96x96.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-114x114.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-120x120.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-144x144.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-152x152.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-180x180.png -------------------------------------------------------------------------------- /assets/favicon/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geut/olaf/HEAD/assets/favicon/apple-icon-precomposed.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output/ 3 | coverage/ 4 | dist/ 5 | tmp/ 6 | npm-debug.log* 7 | .DS_Store 8 | .cache 9 | .env 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-proposal-class-properties" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "node": true 5 | }, 6 | "parser": "babel-eslint", 7 | "extends": "standard", 8 | "plugins": [ 9 | "babel" 10 | ], 11 | "rules": { 12 | "strict": 0 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /assets/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /src/views/404.js: -------------------------------------------------------------------------------- 1 | var html = require('choo/html') 2 | 3 | var TITLE = 'olaf - route not found' 4 | 5 | module.exports = view 6 | 7 | function view (state, emit) { 8 | if (state.title !== TITLE) emit(state.events.DOMTITLECHANGE, TITLE) 9 | return html` 10 | 11 |

Route not found.

12 | Back to main. 13 | 14 | ` 15 | } 16 | -------------------------------------------------------------------------------- /src/components/icons/moon.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | module.exports = function moonIcon () { 4 | return html` 5 | 6 | ` 7 | } 8 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tachyons'; 2 | @import 'balloon-css/balloon.min.css'; 3 | 4 | html, body { 5 | height: 100%; 6 | } 7 | 8 | .break-word { 9 | overflow-wrap: break-word; 10 | } 11 | 12 | .modal-overlay { 13 | position: fixed; 14 | top: 0; 15 | right: 0; 16 | bottom: 0; 17 | left: 0; 18 | background: rgba(17, 17, 17, 0.51); 19 | } 20 | 21 | .icon-button { 22 | width: 40px; 23 | padding: 10px; 24 | height: 40px; 25 | } 26 | -------------------------------------------------------------------------------- /STEPS.md: -------------------------------------------------------------------------------- 1 | # olaf steps 2 | 3 | > cool quote 4 | 5 | ## STEP 1 6 | 7 | Implement `saga` :cat: core: (use hyperdb) 8 | - `getHistory` 9 | - `watchForMessages` 10 | 11 | ## STEP 2 12 | 13 | Implement our swarm :bees: 14 | - add `discovery-swarm` support 15 | 16 | ## STEP 3 17 | 18 | Implement `saga` methods: 19 | - `join` 20 | - `leave` 21 | 22 | ## STEP 4 23 | 24 | Prepare everything for the browser :alien: 25 | 26 | - Replace `discovery-swarm` with `webrtc-swarm` [1](https://github.com/mafintosh/webrtc-swarm) 27 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | var choo = require('choo') 2 | 3 | var app = choo() 4 | if (process.env.NODE_ENV !== 'production') { 5 | app.use(require('choo-devtools')()) 6 | } else { 7 | app.use(require('choo-service-worker')('/service-worker.js')) 8 | } 9 | 10 | if (module.hot) { 11 | module.hot.accept(function () { 12 | window.location.reload() 13 | }) 14 | } 15 | 16 | app.use(require('./stores/chat')) 17 | app.use(require('./stores/ui')) 18 | 19 | app.route('/', require('./views/main')) 20 | app.route('/*', require('./views/404')) 21 | 22 | module.exports = app.mount('body') 23 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | SIGNAL_URLS: 'https://geut-webrtc-signal.herokuapp.com', 3 | ICE_URLS: 'stun:stun.l.google.com:19302;stun:stun1.l.google.com:19302;stun:stun2.l.google.com:19302;stun:stun3.l.google.com:19302;stun:stun4.l.google.com:19302;stun:stun.ekiga.net;turn:numb.viagenie.ca,muazkh,webrtc@live.com;turn:192.158.29.39:3478?transport=udp,JZEOEt2V3Qb0y27GRntt2u2PAYA=,28224511:1379330808;turn:192.158.29.39:3478?transport=tcp,JZEOEt2V3Qb0y27GRntt2u2PAYA=,28224511:1379330808;turn:turn.bistri.com:80,homeo,homeo;turn:turn.anyfirewall.com:443?transport=tcp,webrtc,webrtc' 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/theme.js: -------------------------------------------------------------------------------- 1 | const COLORS = exports.COLORS = { 2 | light: { 3 | name: { 4 | bg: 'bg-washed-red', 5 | color: 'dark-gray' 6 | }, 7 | hex: { 8 | bg: '#FFDFDF', 9 | color: '#333333' 10 | } 11 | }, 12 | dark: { 13 | name: { 14 | bg: 'bg-navy', 15 | color: 'moon-gray' 16 | }, 17 | hex: { 18 | bg: '#001B44', 19 | color: '#CCCCCC' 20 | } 21 | } 22 | } 23 | 24 | exports.THEME = { 25 | light: `${COLORS.light.name.bg} ${COLORS.light.name.color}`, 26 | dark: `${COLORS.dark.name.bg} ${COLORS.dark.name.color}` 27 | } 28 | -------------------------------------------------------------------------------- /src/components/icons/clipboard.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | module.exports = function clipboardIcon () { 4 | return html` 16 | 17 | 18 | ` 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/db-names.js: -------------------------------------------------------------------------------- 1 | const dbs = JSON.parse(localStorage.getItem('olaf/dbs')) || [] 2 | 3 | export const addDB = (dbName, pubKey) => { 4 | dbs.push({ dbName, pubKey }) 5 | localStorage.setItem('olaf/dbs', JSON.stringify(dbs)) 6 | return dbName 7 | } 8 | 9 | export const getDB = pubKey => { 10 | const db = dbs.find(db => db.pubKey === pubKey) 11 | if (db) { 12 | return db.dbName 13 | } 14 | 15 | return addDB(`olaf-${Date.now()}`, pubKey) 16 | } 17 | 18 | export const updateDB = (dbName, pubKey) => { 19 | const db = dbs.find(db => db.dbName === dbName) 20 | db.pubKey = pubKey 21 | localStorage.setItem('olaf/dbs', JSON.stringify(dbs)) 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # olaf 2 | A P2P Dat-powered chat 3 | 4 | :smirk_cat: [Live](https://olafchat.netlify.com/) 5 | 6 | ## Commands 7 | Command | Description | 8 | -----------------------|--------------------------------------------------| 9 | `$ npm start` | Start the development server 10 | `$ npm test` | Lint, validate deps & run tests 11 | `$ npm run build` | Compile all files into `dist/` 12 | 13 | ## Description 14 | 15 | This chat was built in companion with GEUT's [Dat Worskshop](https://github.com/geut/dat-workshop). 16 | The stack includes: 17 | - choo 18 | - tachyons 19 | - parcel 20 | - babel7 21 | - and some webrtc 22 | -------------------------------------------------------------------------------- /src/components/icons/key.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | module.exports = function keyIcon () { 4 | return html` 5 | 17 | 18 | 19 | 20 | 21 | 22 | ` 23 | } 24 | -------------------------------------------------------------------------------- /src/components/icons/users.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | module.exports = function usersIcon () { 4 | return html` 5 | 17 | 18 | 19 | 20 | 21 | 22 | ` 23 | } 24 | -------------------------------------------------------------------------------- /src/components/icons/sun.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | module.exports = function sunIcon () { 4 | return html` 5 | 6 | ` 7 | } 8 | -------------------------------------------------------------------------------- /src/components/users.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | const user = require('./user') 4 | 5 | module.exports = function users (state, emit) { 6 | const { chat: { friends, username, userTimestamp }, ui: { showFriendsPanel } } = state 7 | const users = friends.slice() 8 | users.sort((a, b) => a.timestamp - b.timestamp) 9 | 10 | const displayOnMobile = showFriendsPanel ? 'db flex-grow-1 flex-shrink-0' : 'dn' 11 | 12 | return html` 13 | 22 | ` 23 | } 24 | -------------------------------------------------------------------------------- /assets/favicon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /src/components/user.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | module.exports = ({ owner = false, username, timestamp, color = 'green' }) => { 4 | if (!username || !timestamp) { 5 | return '' 6 | } 7 | 8 | let connectionTime = 'right now' 9 | const difference = Math.abs(new Date() - new Date(timestamp)) 10 | let time = Math.floor(difference / 36e5) // hours 11 | if (time > 0) { 12 | connectionTime = `${time} hours ago` 13 | } else { 14 | time = Math.floor(difference / 6e4) // minutes 15 | if (time > 0) { 16 | connectionTime = `${time} minutes ago` 17 | } 18 | } 19 | 20 | const colorStyle = color ? `color: ${color}` : '' 21 | 22 | return html` 23 |
  • 25 |
    26 | ${username}${owner ? ' (you)' : ''} 27 |
    28 |
    29 | ${connectionTime} 30 |
    31 |
  • 32 | ` 33 | } 34 | -------------------------------------------------------------------------------- /manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "olaf", 3 | "short_name": "olaf", 4 | "description": "A P2P chat", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#ffdfdf", 8 | "theme_color": "#ffdfdf", 9 | "icons": [ 10 | { 11 | "src": "/assets/favicon/android-icon-36x36.png", 12 | "sizes": "36x36", 13 | "type": "image/png", 14 | "density": "0.75" 15 | }, 16 | { 17 | "src": "/assets/favicon/android-icon-48x48.png", 18 | "sizes": "48x48", 19 | "type": "image/png", 20 | "density": "1.0" 21 | }, 22 | { 23 | "src": "/assets/favicon/android-icon-72x72.png", 24 | "sizes": "72x72", 25 | "type": "image/png", 26 | "density": "1.5" 27 | }, 28 | { 29 | "src": "/assets/favicon/android-icon-96x96.png", 30 | "sizes": "96x96", 31 | "type": "image/png", 32 | "density": "2.0" 33 | }, 34 | { 35 | "src": "/assets/favicon/android-icon-144x144.png", 36 | "sizes": "144x144", 37 | "type": "image/png", 38 | "density": "3.0" 39 | }, 40 | { 41 | "src": "/assets/favicon/android-icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "density": "4.0" 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /src/stores/ui.js: -------------------------------------------------------------------------------- 1 | function Store (state, emitter) { 2 | state.storeName = 'ui' 3 | 4 | // declare app events 5 | const { events } = state 6 | events.SHOW_MODAL_KEY = 'ui:show_modal_key' 7 | events.HIDE_MODAL_KEY = 'ui:hide_modal_key' 8 | events.TOGGLE_FRIENDS = 'ui:toggle_friends' 9 | events.TOGGLE_THEME = 'ui:toggle_theme' 10 | 11 | state.ui = { 12 | showModalKey: false, 13 | showFriendsPanel: false, 14 | toggleTheme: true 15 | } 16 | 17 | emitter.on('DOMContentLoaded', function () { 18 | emitter.on(events.SHOW_MODAL_KEY, showModalKey) 19 | emitter.on(events.HIDE_MODAL_KEY, hideModalKey) 20 | emitter.on(events.TOGGLE_FRIENDS, toggleFriends) 21 | emitter.on(events.TOGGLE_THEME, toggleTheme) 22 | }) 23 | 24 | function showModalKey () { 25 | state.ui.showModalKey = true 26 | emitter.emit('render') 27 | } 28 | 29 | function hideModalKey () { 30 | state.ui.showModalKey = false 31 | emitter.emit('render') 32 | } 33 | 34 | function toggleFriends () { 35 | state.ui.showFriendsPanel = !state.ui.showFriendsPanel 36 | emitter.emit('render') 37 | } 38 | 39 | function toggleTheme () { 40 | state.ui.toggleTheme = !state.ui.toggleTheme 41 | emitter.emit('render') 42 | } 43 | } 44 | 45 | module.exports = Store 46 | -------------------------------------------------------------------------------- /src/views/main.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | const { THEME } = require('../lib/theme') 4 | const initModal = require('../components/init-modal') 5 | const header = require('../components/header') 6 | const users = require('../components/users') 7 | 8 | const InputMsg = require('../components/input-msg') 9 | const ViewMessages = require('../components/view-messages') 10 | const KeyModal = require('../components/key-modal') 11 | 12 | module.exports = view 13 | 14 | function view (state, emit) { 15 | const { username, key, init } = state.chat 16 | const { showModalKey, toggleTheme } = state.ui 17 | 18 | const theme = toggleTheme ? THEME.light : THEME.dark 19 | 20 | return html` 21 | 22 |
    23 | ${header(state, emit)} 24 |
    25 | ${state.cache(ViewMessages, 'viewMessages').render()} 26 | ${users(state, emit)} 27 |
    28 |
    29 | ${state.cache(InputMsg, 'inputMsg').render()} 30 |
    31 |
    32 | ${(!init) ? initModal({ username, key }, this.emit, this.state.events) : ''} 33 | ${showModalKey ? state.cache(KeyModal, 'keyModal').render({ key }) : ''} 34 | 35 | ` 36 | } 37 | -------------------------------------------------------------------------------- /src/components/header.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | const usersIcon = require('./icons/users') 4 | const keyIcon = require('./icons/key') 5 | const moonIcon = require('./icons/moon') 6 | const sunIcon = require('./icons/sun') 7 | 8 | const iconButton = (icon, onclick, classes = '') => { 9 | return html` 10 | 12 | ${icon} 13 | 14 | ` 15 | } 16 | 17 | module.exports = function header (state, emit) { 18 | function toggleFriends () { 19 | const { TOGGLE_FRIENDS } = state.events 20 | emit(TOGGLE_FRIENDS) 21 | } 22 | 23 | function showModalKey () { 24 | const { SHOW_MODAL_KEY } = state.events 25 | emit(SHOW_MODAL_KEY) 26 | } 27 | 28 | function toggleTheme () { 29 | const { TOGGLE_THEME } = state.events 30 | emit(TOGGLE_THEME) 31 | } 32 | 33 | return html`` 43 | } 44 | -------------------------------------------------------------------------------- /src/components/view-messages.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const html = require('choo/html') 3 | 4 | const Message = require('./message') 5 | 6 | const customStyle = 'outline: none;overflow-x: hidden;overflow-y: auto;transform: translateZ(0);' 7 | 8 | module.exports = class ViewMessages extends Component { 9 | constructor (name, state, emit) { 10 | super(name) 11 | this.state = state 12 | this.emit = emit 13 | this.local = this.state.components[name] = {} 14 | this.setState() 15 | } 16 | 17 | setState () { 18 | this.local.messages = this.state.chat.messages.slice() 19 | this.local.messages.sort((a, b) => a.timestamp - b.timestamp) 20 | } 21 | 22 | update () { 23 | const { chat: { messages } } = this.state 24 | if (this.local.messages.length !== messages.length) { 25 | this.setState() 26 | return true 27 | } 28 | } 29 | 30 | createElement () { 31 | return html` 32 |
    37 | ${this.local.messages.map(m => this.state.cache(Message, `message_${m.key}`, { updateHeight: this.updateHeight }).render(m, this.state.chat.colors[m.username]))} 38 |
    39 | ` 40 | } 41 | 42 | updateHeight = h => { 43 | this.element.scrollTo(0, this.element.scrollHeight + h + 10) 44 | } 45 | 46 | afterupdate (el) { 47 | el.scrollTo(0, el.scrollHeight) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/input-msg.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const html = require('choo/html') 3 | 4 | module.exports = class InputMsg extends Component { 5 | constructor (name, state, emit) { 6 | super(name) 7 | this.state = state 8 | this.emit = emit 9 | } 10 | 11 | update () { 12 | return false 13 | } 14 | 15 | sendMessage = (e) => { 16 | e.preventDefault() 17 | const { events } = this.state 18 | const input = this.element.querySelector('#input-msg') 19 | 20 | if (input.value.length === 0) { 21 | return 22 | } 23 | 24 | this.emit(events.WRITE_MESSAGE, input.value) 25 | input.value = '' 26 | }; 27 | 28 | createElement () { 29 | return html` 30 |
    31 |
    32 |
    33 | 34 | 42 | 47 |
    48 |
    49 |
    50 | ` 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Olaf chat 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "olaf", 3 | "version": "1.0.0", 4 | "private": true, 5 | "browserslist": [ 6 | "> 5%" 7 | ], 8 | "scripts": { 9 | "test": "echo \"Error: no test specified\"", 10 | "posttest": "npm run lint", 11 | "start": "parcel index.html -p 3000", 12 | "build": "npm run clean && parcel build index.html --public-url ./", 13 | "clean": "del-cli dist", 14 | "lint": "eslint src", 15 | "signal": "signalhubws listen -p 4000" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "devDependencies": { 21 | "@babel/core": "^7.0.0", 22 | "@babel/plugin-proposal-class-properties": "^7.0.0", 23 | "@babel/preset-env": "^7.0.0", 24 | "babel-eslint": "^10.0.1", 25 | "del-cli": "^1.1.0", 26 | "eslint": "^5.6.0", 27 | "eslint-config-standard": "^12.0.0", 28 | "eslint-plugin-babel": "^5.2.0", 29 | "eslint-plugin-import": "^2.14.0", 30 | "eslint-plugin-node": "^7.0.1", 31 | "eslint-plugin-promise": "^4.0.1", 32 | "eslint-plugin-standard": "^4.0.0", 33 | "parcel-bundler": "^1.9.7", 34 | "parcel-plugin-sw-precache": "^1.0.1" 35 | }, 36 | "dependencies": { 37 | "@geut/discovery-swarm-webrtc": "^2.2.4", 38 | "anchorme": "^1.1.2", 39 | "balloon-css": "^0.5.0", 40 | "choo": "^6.13.0", 41 | "choo-devtools": "^2.5.1", 42 | "choo-service-worker": "^2.4.0", 43 | "color-contrast": "0.0.1", 44 | "copy-to-clipboard": "^3.0.8", 45 | "file-type": "^10.1.0", 46 | "flush-write-stream": "^1.0.3", 47 | "hyperdb": "^3.5.0", 48 | "hyperid": "^1.4.1", 49 | "pump": "^3.0.0", 50 | "qrcode": "^1.3.0", 51 | "random-access-idb": "^1.2.0", 52 | "random-access-memory": "^3.0.0", 53 | "random-color": "^1.0.1", 54 | "signalhubws": "^1.0.4", 55 | "tachyons": "^4.11.1", 56 | "tinydate": "^1.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/key-modal.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const html = require('choo/html') 3 | const copy = require('copy-to-clipboard') 4 | const QRCode = require('qrcode') 5 | 6 | const clipboardIcon = require('./icons/clipboard') 7 | 8 | const url = window.location.protocol + '//' + window.location.host 9 | 10 | module.exports = class KeyModal extends Component { 11 | constructor (name, state, emit) { 12 | super(name) 13 | this.state = state 14 | this.emit = emit 15 | } 16 | 17 | update ({ key }) { 18 | if (this.key !== key) { 19 | return true 20 | } 21 | } 22 | 23 | load (el) { 24 | this.loadQRCode(el) 25 | } 26 | 27 | afterupdate (el) { 28 | this.loadQRCode(el) 29 | } 30 | 31 | loadQRCode (el) { 32 | QRCode.toCanvas(el.querySelector('#qrcode'), `${url}?key=${this.key}`) 33 | } 34 | 35 | hideModalKey = () => { 36 | const { events: { HIDE_MODAL_KEY } } = this.state 37 | this.emit(HIDE_MODAL_KEY) 38 | }; 39 | 40 | copyToClipboard = (e) => { 41 | e.preventDefault() 42 | copy(`${url}?key=${this.key}`) 43 | }; 44 | 45 | createElement ({ key }) { 46 | this.key = key 47 | 48 | return html` 49 | 61 | ` 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/init-modal.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | module.exports = function modal (props, emit, events) { 4 | const { username, key } = props 5 | 6 | return html` 7 | 52 | ` 53 | 54 | function join (e) { 55 | e.stopPropagation() 56 | e.preventDefault() 57 | if (username && username.length > 0 && key && key.length > 0) { 58 | emit(events.INIT_ROOM) 59 | } 60 | } 61 | 62 | function createRoom (e) { 63 | e.stopPropagation() 64 | e.preventDefault() 65 | if (username && username.length > 0) { 66 | emit(events.INIT_ROOM, true) 67 | } 68 | } 69 | 70 | function updateUsername (e) { 71 | emit(events.UPDATE_USERNAME, e.target.value) 72 | } 73 | 74 | function updateKey (e) { 75 | emit(events.UPDATE_KEY, e.target.value) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/lib/saga.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events') 2 | const { Writable } = require('stream') 3 | const hyperdb = require('hyperdb') 4 | const pump = require('pump') 5 | const hyperid = require('hyperid') 6 | const uuid = hyperid() 7 | 8 | class ForEachChunk extends Writable { 9 | constructor (opts, cb) { 10 | if (!cb) { 11 | cb = opts 12 | opts = {} 13 | } 14 | super(opts) 15 | 16 | this.cb = cb 17 | } 18 | 19 | _write (chunk, enc, next) { 20 | this.cb(chunk, enc, next) 21 | } 22 | } 23 | 24 | const forEachChunk = (...args) => new ForEachChunk(...args) 25 | 26 | class Saga extends EventEmitter { 27 | constructor (storage, key, username) { 28 | super() 29 | 30 | this.messages = new Map() 31 | this.users = new Map() 32 | this.username = username 33 | this.timestamp = Date.now() 34 | this.db = hyperdb(storage, key, { valueEncoding: 'json' }) 35 | } 36 | 37 | async initialize () { 38 | await this._ready() 39 | 40 | this._updateHistory(this._watchForMessages.bind(this)) 41 | } 42 | 43 | writeMessage (message) { 44 | const key = `messages/${uuid()}` 45 | const data = { 46 | key, 47 | message, 48 | username: this.username, 49 | timestamp: Date.now() 50 | } 51 | 52 | return new Promise((resolve, reject) => { 53 | this.db.put(key, data, (err) => { 54 | if (err) { 55 | reject(err) 56 | } else { 57 | resolve(key) 58 | } 59 | }) 60 | }) 61 | } 62 | 63 | replicate () { 64 | return this.db.replicate({ 65 | live: true, 66 | userData: JSON.stringify({ 67 | key: this.db.local.key, 68 | username: this.username, 69 | timestamp: this.timestamp 70 | }) 71 | }) 72 | } 73 | 74 | async connect (peer) { 75 | if (!peer.remoteUserData) { 76 | throw new Error('peer does not have userData') 77 | } 78 | 79 | const data = JSON.parse(peer.remoteUserData) 80 | 81 | const key = Buffer.from(data.key) 82 | const username = data.username 83 | 84 | await this._authorize(key) 85 | 86 | if (!this.users.has(username)) { 87 | this.users.set(username, new Date()) 88 | this.emit('join', data) 89 | peer.on('close', () => { 90 | if (!this.users.has(username)) return 91 | this.users.delete(username) 92 | this.emit('leave', data) 93 | }) 94 | } 95 | } 96 | 97 | _authorize (key) { 98 | return new Promise((resolve, reject) => { 99 | this.db.authorized(key, (err, auth) => { 100 | if (err) return reject(err) 101 | 102 | if (auth) { 103 | return resolve() 104 | } 105 | 106 | this.db.authorize(key, (err) => { 107 | if (err) return reject(err) 108 | resolve() 109 | }) 110 | }) 111 | }) 112 | } 113 | 114 | _updateHistory (onFinish) { 115 | const h = this.db.createHistoryStream({ reverse: true }) 116 | 117 | const ws = forEachChunk({ objectMode: true }, (data, enc, next) => { 118 | const { key, value } = data 119 | 120 | if (/messages/.test(key)) { 121 | if (this.messages.has(key)) { 122 | h.destroy() 123 | return 124 | } 125 | 126 | this.messages.set(key, value) 127 | this.emit('message', value, key) 128 | } 129 | 130 | next() 131 | }) 132 | 133 | pump(h, ws, onFinish) 134 | } 135 | 136 | _watchForMessages () { 137 | this.db.watch('messages', () => { 138 | this._updateHistory() 139 | }) 140 | } 141 | 142 | _ready () { 143 | return new Promise(resolve => this.db.ready(resolve)) 144 | } 145 | } 146 | 147 | module.exports = (...args) => new Saga(...args) 148 | -------------------------------------------------------------------------------- /src/components/message.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const html = require('choo/html') 3 | const raw = require('choo/html/raw') 4 | const tinydate = require('tinydate').default 5 | const anchorme = require('anchorme').default 6 | const fileType = require('file-type') 7 | 8 | const stamp = tinydate('{HH}:{mm}:{ss}') 9 | 10 | const parseMessage = message => { 11 | const anchor = anchorme(message, { list: true }) 12 | if (anchor.length) { 13 | // detect file type 14 | return Promise.all(anchor.map(async anchorData => { 15 | const controller = new window.AbortController() 16 | const signal = controller.signal 17 | 18 | const fetchPromise = window.fetch(anchorData.raw, { signal }) 19 | 20 | // 5 second timeout: 21 | setTimeout(() => controller.abort(), 5000) 22 | const response = await fetchPromise 23 | 24 | if (!response) return '' 25 | const ab = await response.arrayBuffer() 26 | const ft = fileType(ab) 27 | if (ft && ft.mime.includes('image')) { 28 | return html`
    ` 29 | } else return '' 30 | })).then(out => { 31 | // prepare output 32 | var f = anchorme(message) 33 | console.log(f) 34 | return html` 35 |
    36 | ${out.filter(img => img)} 37 |
    38 | ` 39 | }) 40 | } else { 41 | return Promise.resolve() 42 | } 43 | } 44 | 45 | class Message extends Component { 46 | constructor (id, choo, f, opts) { 47 | super() 48 | this.local = { 49 | extra: '' 50 | } 51 | this.parent = {} 52 | this.parent.updateHeight = opts.updateHeight 53 | } 54 | 55 | update ({ message }) { 56 | if (this.local.message !== message) return true 57 | } 58 | 59 | load (el) { 60 | parseMessage(this.local.message) 61 | .then(msg => { 62 | if (msg) { 63 | this.local.extra = msg 64 | this.rerender() 65 | } 66 | }) 67 | .catch(console.log) 68 | } 69 | 70 | createElement (props, color = 'green') { 71 | const { username, message, timestamp } = props 72 | const { extra } = this.local 73 | 74 | this.local.message = message 75 | 76 | const date = stamp(new Date(timestamp)) 77 | 78 | const colorStyle = color ? `color: ${color}` : '' 79 | 80 | return html` 81 |
    82 |
    83 |
    84 |
    85 |
    86 |
    87 | ${username} 88 |
    89 |
    90 |
    91 |

    92 | ${raw(anchorme(message))} 93 |

    94 |
    95 |
    96 |
    97 | ${extra} 98 |
    99 |
    100 | ⌚️ 101 |
    102 |
    103 |
    104 |
    105 | ` 106 | } 107 | 108 | afterupdate () { 109 | if (this.parent.updateHeight) { 110 | this.parent.updateHeight(this.element.scrollHeight) 111 | } 112 | } 113 | } 114 | 115 | module.exports = Message 116 | -------------------------------------------------------------------------------- /src/stores/chat.js: -------------------------------------------------------------------------------- 1 | const signalhub = require('signalhubws') 2 | const rai = require('random-access-idb') 3 | const saga = require('../lib/saga') 4 | const { getDB, updateDB } = require('../lib/db-names') 5 | const { SIGNAL_URLS, ICE_URLS } = require('../config') 6 | const swarm = require('@geut/discovery-swarm-webrtc') 7 | const { COLORS } = require('../lib/theme') 8 | const rcolor = require('random-color') 9 | const contrast = require('color-contrast') 10 | 11 | const webrtcOpts = { 12 | config: { 13 | iceServers: (process.env.ICE_URLS || ICE_URLS).split(';').map(data => { 14 | const [urls, credential, username] = data.split(',') 15 | 16 | if (credential && username) { 17 | return { 18 | urls, 19 | credential, 20 | username 21 | } 22 | } 23 | 24 | return { urls } 25 | }) 26 | } 27 | } 28 | console.log('ICE Servers: ', webrtcOpts.config.iceServers) 29 | 30 | async function initChat (username, key) { 31 | const publicKey = key && key.length > 0 ? key : null 32 | const dbName = getDB(publicKey) 33 | const chat = saga(rai(dbName), publicKey, username) 34 | 35 | await chat.initialize() 36 | 37 | if (publicKey === null) { 38 | updateDB(dbName, chat.db.key.toString('hex')) 39 | } 40 | 41 | const sw = swarm({ 42 | bootstrap: (process.env.SIGNAL_URLS || SIGNAL_URLS).split(';'), 43 | stream: () => chat.replicate(), 44 | simplePeer: webrtcOpts 45 | }) 46 | 47 | sw.join(chat.db.discoveryKey, webrtcOpts) 48 | 49 | sw.on('connection', async peer => { 50 | try { 51 | await chat.connect(peer) 52 | } catch (err) { 53 | console.log(err) 54 | } 55 | }) 56 | 57 | return chat 58 | } 59 | 60 | const TIMEOUT_DISCONNECTION = 30000 61 | 62 | function store (state, emitter) { 63 | state.storeName = 'chat' 64 | 65 | // declare app events 66 | const { events } = state 67 | events.INIT_ROOM = 'chat:init_room' 68 | events.UPDATE_USERNAME = 'chat:update_username' 69 | events.UPDATE_KEY = 'chat:update_key' 70 | events.JOIN_FRIEND = 'chat:join_friend' 71 | events.LEAVE_FRIEND = 'chat:leave_friend' 72 | events.WRITE_MESSAGE = 'chat:write_message' 73 | events.ADD_MESSAGE = 'chat:add_message' 74 | 75 | let chat 76 | const timers = new Map() 77 | 78 | state.chat = { 79 | initRoom: false, 80 | key: null, 81 | username: null, 82 | userTimestamp: null, 83 | messages: [], 84 | friends: [], 85 | colors: {} 86 | } 87 | 88 | emitter.on('DOMContentLoaded', function () { 89 | rehydrate() 90 | emitter.on(events.INIT_ROOM, initRoom) 91 | emitter.on(events.UPDATE_USERNAME, updateUsername) 92 | emitter.on(events.UPDATE_KEY, updateKey) 93 | emitter.on(events.ADD_MESSAGE, addMessage) 94 | emitter.on(events.WRITE_MESSAGE, writeMessage) 95 | emitter.on(events.JOIN_FRIEND, joinFriend) 96 | emitter.on(events.LEAVE_FRIEND, leaveFriend) 97 | }) 98 | 99 | function rehydrate () { 100 | const data = JSON.parse(localStorage.getItem('olaf/last-room')) 101 | 102 | state.chat.username = data ? data.username : null 103 | 104 | if (state.query.key) { 105 | state.chat.key = state.query.key 106 | } else { 107 | state.chat.key = data ? data.key : null 108 | } 109 | 110 | render() 111 | } 112 | 113 | async function initRoom (isNew = false) { 114 | chat = await initChat(state.chat.username, isNew ? null : state.chat.key) 115 | 116 | state.chat.key = chat.db.key.toString('hex') 117 | state.chat.userTimestamp = chat.timestamp 118 | state.chat.init = true 119 | 120 | localStorage.setItem('olaf/last-room', JSON.stringify({ username: state.chat.username, key: state.chat.key })) 121 | 122 | chat.on('message', data => { 123 | emitter.emit(events.ADD_MESSAGE, data) 124 | }) 125 | 126 | chat.on('join', user => { 127 | emitter.emit(events.JOIN_FRIEND, user) 128 | }) 129 | 130 | chat.on('leave', user => { 131 | emitter.emit(events.LEAVE_FRIEND, user) 132 | }) 133 | 134 | render() 135 | } 136 | 137 | function updateUsername (username) { 138 | state.chat.username = username 139 | render() 140 | } 141 | 142 | function updateKey (key) { 143 | state.chat.key = key 144 | render() 145 | } 146 | 147 | function writeMessage (msg) { 148 | chat.writeMessage(msg) 149 | render() 150 | } 151 | 152 | function joinFriend (user) { 153 | const index = state.chat.friends.findIndex(u => u.username === user.username) 154 | 155 | // check if the user already exists 156 | if (index !== -1) { 157 | // check if it has a timer to disconnect 158 | if (timers.has(user.username)) { 159 | clearTimeout(timers.get(user.username)) 160 | timers.delete(user.username) 161 | } 162 | return 163 | } 164 | 165 | let newColor = rcolor(0.99, 0.99).hexString() 166 | const currentTheme = state.ui.toggleTheme ? 'light' : 'dark' 167 | while (contrast(COLORS[currentTheme].hex.bg, newColor) < 4) { 168 | newColor = rcolor(0.99, 0.99).hexString() 169 | } 170 | user.color = newColor 171 | state.chat.colors[user.username] = user.color 172 | state.chat.friends.push(user) 173 | render() 174 | } 175 | 176 | function leaveFriend (user) { 177 | const index = state.chat.friends.findIndex(u => u.username === user.username) 178 | if (index !== -1) { 179 | // the webrtc connection could be losted for a moment so it's better wait a couple of seconds 180 | timers.set(user.username, setTimeout(() => { 181 | state.chat.friends.splice(index, 1) 182 | timers.delete(user.username) 183 | render() 184 | }, TIMEOUT_DISCONNECTION)) 185 | } 186 | } 187 | 188 | function addMessage (data) { 189 | state.chat.messages.push(data) 190 | render() 191 | } 192 | 193 | function render () { 194 | emitter.emit('render') 195 | } 196 | } 197 | 198 | module.exports = store 199 | --------------------------------------------------------------------------------