const State = Object.seal({ username: null, websocket: null, online: [], get isConnected() { return State.websocket && State.websocket.readyState === 1 }, get others() { return State.online.filter(username => username != State.username) } }) const rpcConfig = {iceServers: [{ urls: ['stun:stun.pico.chat:5349', 'turn:turn.pico.chat:5349'], username: 'roderic', credential: 'tomodachi', }]} const params = (new URL(document.location)).searchParams /* * * SIGNALING * */ addEventListener('resize', () => m.redraw()) // message: {kind, value, target?} const wire = (message) => State.websocket.send(JSON.stringify(message)) const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message})) const listen = (kind, handler) => { addEventListener(kind, handler) } listen('login', ({detail}) => { State.username = detail.value }) listen('logout', ({detail}) => { State.online = [] }) listen('state', ({detail}) => { delete detail.ts delete detail.kind Object.assign(State, detail) }) const doNotLog = new Set(['state']) /* * * UTILS * */ const autoFocus = (vnode) => { vnode.dom.focus() } /* * * WEBSOCKET * */ const connect = (username) => { const wsUrl = location.href.replace('http', 'ws') State.websocket = new WebSocket(wsUrl) State.websocket.onopen = (e) => { wire({kind: 'login', value: username}) } State.websocket.onmessage = (e) => { const message = JSON.parse(e.data) if(message.online) { const isNew = (username) => !State.online.includes(username) const isGone = (username) => !message.online.includes(username) const isAfter = (_, idx, ll) => ll.indexOf(State.username) < idx message.online.filter(isNew).filter(isAfter).forEach(username => { signal({kind: 'post', ts: message.ts, value: `${username} joined`}) signal({kind: 'join', value: username}) }) State.online.filter(isGone).forEach(username => { signal({kind: 'post', ts: message.ts, value: `${username} left`}) signal({kind: 'leave', value: username}) }) } if(!doNotLog.has(message.kind)) { console.log(message) } signal(message) m.redraw() } State.websocket.onclose = (e) => { if(!e.wasClean) { setTimeout(connect, 1000, username) } m.redraw() } } /* * * BASE * */ const Shadow = { oncreate({dom, attrs}) { dom.listener = () => attrs.app.isOn = !attrs.app.isOn addEventListener(attrs.key, dom.listener) }, onremove({dom, attrs}) { removeEventListener(attrs.key, dom.listener) }, view({attrs}) { const style = { zIndex: 999, backgroundColor: 'rgba(0, 0, 0, 0.2)', visibility: attrs.app.isOn ? 'unset' : 'hidden', position: 'fixed', right: 0, top: 0, height: '100vh', width: '100vw', display: 'flex', alignItems: 'center', justifyContent: 'center', } const onclick = ({target: {classList}}) => { classList.contains('shadow') && signal({kind: attrs.key}) } return m('.shadow', {style, onclick}, m(attrs.app)) }, } const Settings = { get(key) { try { return JSON.parse(localStorage.getItem(key)) } catch(error) { return null } }, set(key, value) { localStorage.setItem(key, JSON.stringify(value)) }, multiField: ([key, options]) => { let current = Settings.get(key) if(!options.includes(current)) { Settings.set(key, options[0]) } return m('.field', m('label', key), options.map(value => { const style = { fontWeight: Settings.get(key) == value ? 'bold' : 'unset' } const onclick = () => Settings.set(key, value) return m('button', {style, onclick}, `${value}`) }) ) }, view() { return m('.settings', Object.entries({ blackBars: [true, false], }).map(Settings.multiField) ) }, } const Base = { oncreate: () => { const randomName = ('' + Math.random()).substring(2) connect(localStorage.username || randomName) }, sendLogin: (e) => { e.preventDefault() const username = e.target.username.value localStorage.username = username connect(username) }, sendLogout: (e) => { e.preventDefault() wire({kind: 'logout'}) signal({kind: 'logout'}) }, view() { const attrs = { oncreate: autoFocus, name: 'username', autocomplete: 'off', value: localStorage.username, } const style = { flexDirection: innerWidth > innerHeight ? 'row' : 'column', } return [ m('header', State.isConnected ? Headers.map(h => m(...h)) : [ m('form.login', {onsubmit: Base.sendLogin}, m('input', attrs), m('button', 're-join'), ), ], ), m('main', {style}, State.isConnected ? Apps.map(a => m(...a)) : [ m('span.error', State.info), m(Settings), ], ), ] }, } const Headers = [ ['button', {onclick: Base.sendLogout}, 'log-out'], ] const Apps = [ ] addEventListener('load', () => m.mount(document.body, Base))