├── 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``
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 |
22 | `
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/icons/users.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | module.exports = function usersIcon () {
4 | return html`
5 |
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`
34 |
35 |
olaf 🐱
36 |
37 |
38 | ${iconButton(state.ui.toggleTheme ? moonIcon() : sunIcon(), toggleTheme, null)}
39 | ${iconButton(keyIcon(), showModalKey, null)}
40 | ${iconButton(usersIcon(), toggleFriends, 'db dn-ns')}
41 |
42 |
`
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 |
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 |
8 |
12 |
13 | Welcome! Please set your info.
14 |
15 |
40 |
50 |
51 |
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 |
--------------------------------------------------------------------------------