const isLandscape = screen.width > screen.height const State = { username: null, websocket: null, online: [], posts: [], rpcs: {}, streams: {}, options: {}, } const markedOptions = { breaks: true, } marked.setOptions(markedOptions) /* * * SIGNALING * */ const wire = (message) => State.websocket.send(JSON.stringify(message)) const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message})) const signalPeerRequest = () => wire({kind: 'peerInfo', value: {type: 'request'}}) const signalPeerStop = (username) => signal({kind: 'peerInfo', value: {type: 'stop'}, source: username}) const listen = (kind, handler) => addEventListener(kind, handler) listen('login', ({detail}) => State.username = detail.value) listen('state', ({detail}) => Object.assign(State, detail)) listen('post', ({detail}) => State.posts.push(detail)) listen('peerInfo', (e) => onPeerInfo(e)) const doNotLog = new Set(['login', 'state', 'post', 'peerInfo']) /* * * ALERTS * */ State.unseen = 0 listen('post', () => {State.unseen += !document.hasFocus(); updateTitle()}) listen('focus', () => {State.unseen = 0; updateTitle()}) const updateTitle = () => { document.title = `pico.chat` + (State.unseen ? ` (${State.unseen})` : ``) } /* * * WEBRTC * */ const getOrCreateRpc = (username) => { if(State.username === username) { return } if(!State.rpcs[username]) { const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]}) rpc.onicecandidate = ({candidate}) => { if(candidate) { wire({kind: 'peerInfo', value: {type: 'candidate', candidate}}) } } rpc.ontrack = (e) => { State.streams[username] = e.streams[0] m.redraw() } rpc.onclose = (e) => { console.log(username, e) } rpc.oniceconnectionstatechange = (e) => { m.redraw() } State.rpcs[username] = rpc } return State.rpcs[username] } const setSelectedMedia = async () => { const localStream = State.streams[State.username] if(!localStream) { return } const oldTracks = localStream.getTracks() const addTrack = localStream.addTrack.bind(localStream) const muted = document.querySelector('#mute-check').checked if(!muted) { const audio = Media.audioDefaults await navigator.mediaDevices.getUserMedia({audio}) .then(s => s.getAudioTracks().forEach(addTrack)) .catch(e => console.error(e)) } const source = document.querySelector('#media-source').value if(source === 'camera') { const video = {width: {ideal: 320}, facingMode: 'user', frameRate: 26} await navigator.mediaDevices.getUserMedia({video}) .then(s => s.getVideoTracks().forEach(addTrack)) .catch(e => console.error(e)) } if(source === 'screen' && navigator.mediaDevices.getDisplayMedia) { await navigator.mediaDevices.getDisplayMedia() .then(s => s.getVideoTracks().forEach(addTrack)) .catch(e => console.error(e)) } oldTracks.forEach(track => {track.stop(); localStream.removeTrack(track)}) document.querySelectorAll('video').forEach(video => video.srcObject = video.srcObject) signalPeerRequest() } const onPeerInfo = async ({detail: message}) => { const localStream = State.streams[State.username] const rpc = localStream && getOrCreateRpc(message.source) const resetStreams = () => { rpc.getSenders().forEach(sender => rpc.removeTrack(sender)) localStream.getTracks().forEach(track => rpc.addTrack(track, localStream)) } if(rpc && message.value.type === 'request') { resetStreams() const localOffer = await rpc.createOffer() await rpc.setLocalDescription(localOffer) wire({kind: 'peerInfo', value: localOffer, target: message.source}) } else if(rpc && message.value.type === 'offer') { resetStreams() const remoteOffer = new RTCSessionDescription(message.value) await rpc.setRemoteDescription(remoteOffer) const localAnswer = await rpc.createAnswer() await rpc.setLocalDescription(localAnswer) wire({kind: 'peerInfo', value: localAnswer, target: message.source}) } else if(rpc && message.value.type === 'answer') { const remoteAnswer = new RTCSessionDescription(message.value) await rpc.setRemoteDescription(remoteAnswer) } else if(rpc && message.value.type === 'candidate') { const candidate = new RTCIceCandidate(message.value.candidate) rpc.addIceCandidate(candidate) } else if(message.value.type === 'stop') { if(State.streams[message.source]) { State.streams[message.source].getTracks().map(track => track.stop()) delete State.streams[message.source] } if(State.rpcs[message.source]) { State.rpcs[message.source].close() delete State.rpcs[message.source] } } else if(rpc) { console.log('uncaught', message) } } /* * * GUI * */ const autoFocus = (vnode) => { vnode.dom.focus() } const scrollIntoView = (vnode) => { vnode.dom.scrollIntoView() } const TextBox = { autoSize: () => { textbox.style.height = `0px` textbox.style.height = `${textbox.scrollHeight}px` }, sendPost: () => { if(textbox.value) { wire({kind: 'post', value: textbox.value}) textbox.value = '' textbox.focus() } }, blockIndent: (text, iStart, iEnd, nLevels) => { const startLine = text.slice(0, iStart).split('\n').length - 1 const endLine = text.slice(0, iEnd).split('\n').length - 1 const newText = text .split('\n') .map((line, i) => { if(i < startLine || i > endLine || nLevels === 0) { newLine = line } else if(nLevels > 0) { newLine = line.replace(/^/, ' ') } else if(nLevels < 0) { newLine = line.replace(/^ /, '') } if(i === startLine) { iStart = iStart + newLine.length - line.length } iEnd = iEnd + newLine.length - line.length return newLine }) .join('\n') return [newText, Math.max(0, iStart), Math.max(0, iEnd)] }, hotKey: (e) => { // if isDesktop, Enter posts, unless Shift+Enter // use isLandscape as proxy for isDesktop if(e.key === 'Enter' && isLandscape && !e.shiftKey) { e.preventDefault() TextBox.sendPost() } // indent and dedent const modKey = e.ctrlKey || e.metaKey const {value: text, selectionStart: A, selectionEnd: B} = textbox if(e.key === 'Tab') { e.preventDefault() const regex = new RegExp(`([\\s\\S]{${A}})([\\s\\S]{${B - A}})`) textbox.value = text.replace(regex, (m, a, b) => a + ' '.repeat(4)) textbox.setSelectionRange(A + 4, A + 4) } if(']['.includes(e.key) && modKey) { e.preventDefault() const nLevels = {']': 1, '[': -1}[e.key] const [newText, newA, newB] = TextBox.blockIndent(text, A, B, nLevels) textbox.value = newText textbox.setSelectionRange(newA, newB) } }, view() { return m('.actions', m('textarea#textbox', { oncreate: (vnode) => { TextBox.autoSize() autoFocus(vnode) }, onkeydown: TextBox.hotKey, oninput: TextBox.autoSize, }), m('button', { onclick: ({target}) => { TextBox.sendPost() TextBox.autoSize() }, }, 'Send'), ) }, } const VideoOptions = { available: ['mirror', 'square', 'full-screen'], anyFullScreen: () => { for(const username of State.online) { if(State.options[username].has('full-screen')) { return 'full-screen' } } return '' }, getFor: (username) => { if(!State.options[username]) { State.options[username] = new Set(['mirror', 'square']) } return State.options[username] }, getClassListFor: (username) => { return [...VideoOptions.getFor(username)].join(' ') }, toggle: (options, string) => () => options.has(string) ? options.delete(string) : options.add(string), view({attrs: {username}}) { const options = VideoOptions.getFor(username) return VideoOptions.available.map((string) => m('label.video-option', m('input', { type: 'checkbox', checked: options.has(string), onchange: VideoOptions.toggle(options, string), }), string, ) ) } } const Video = { keepRatio: {observe: () => {}}, appendStream: ({username}) => ({dom}) => { dom.autoplay = true dom.muted = (username === State.username) dom.srcObject = State.streams[username] }, view({attrs}) { const classList = VideoOptions.getClassListFor(attrs.username) const rpc = State.rpcs[attrs.username] || {iceConnectionState: null} const options = VideoOptions.getFor(attrs.username) return m('.video-container', {class: classList, oncreate: ({dom}) => Video.keepRatio.observe(dom)}, m('.video-meta', m('span.video-source', attrs.username), m('.video-state', rpc.iceConnectionState), ), m('video', { playsinline: true, oncreate: Video.appendStream(attrs), ondblclick: VideoOptions.toggle(options, 'full-screen'), }), ) }, } if(window.ResizeObserver) { const doOne = ({target}) => target.style.setProperty('--height', `${target.clientHeight}px`) const doAll = (entries) => entries.forEach(doOne) Video.keepRatio = new ResizeObserver(doAll) } const Media = { videoSources: ['camera', 'screen', 'none'], audioDefaults: { noiseSuppresion: true, echoCancellation: true, }, turnOn: async () => { State.streams[State.username] = new MediaStream() await setSelectedMedia() m.redraw() }, turnOff: () => { wire({kind: 'peerInfo', value: {type: 'stop'}}) State.online.forEach(signalPeerStop) }, view() { return m('.media', m('.media-settings', State.streams[State.username] ? m('button', {onclick: Media.turnOff}, 'turn off') : m('button', {onclick: Media.turnOn}, 'turn on') , m('select#media-source', {onchange: setSelectedMedia}, Media.videoSources.map(option => m('option', option)) ), m('label', m('input#mute-check', {onchange: setSelectedMedia, type: 'checkbox'}), m('mute'), ), ), m('.videos', {className: VideoOptions.anyFullScreen()}, Object.keys(State.streams).map((username) => m(Video, {key: username, username}) ), ), ) }, } const Login = { sendLogin: (e) => { e.preventDefault() const username = e.target.username.value localStorage.username = username connect(username) }, sendLogout: (e) => { Media.turnOff() wire({kind: 'logout'}) State.posts = [] }, view() { const attrs = { oncreate: autoFocus, name: 'username', autocomplete: 'off', value: localStorage.username, } return m('.login', m('form', {onsubmit: Login.sendLogin}, m('input', attrs), m('button', 'Login'), ), m('.error', State.info), ) }, } const Chat = { prettifyTime: (ts) => { const dt = new Date(ts) const H = `0${dt.getHours()}`.slice(-2) const M = `0${dt.getMinutes()}`.slice(-2) const S = `0${dt.getSeconds()}`.slice(-2) return `${H}:${M}:${S}` }, outboundLinks: (vnode) => { vnode.dom.querySelectorAll('a').forEach(anchor => { anchor.target = '_blank' anchor.rel = 'noopener' }) }, view() { return m('.chat', m('.posts', State.posts.map(post => m('.post', {oncreate: scrollIntoView}, m('.ts', Chat.prettifyTime(post.ts)), m('.source', post.source || '~'), m('.text', {oncreate: Chat.outboundLinks}, m.trust(DOMPurify.sanitize(marked(post.value))) ), )), ), m(TextBox), m('.online', m('button', {onclick: Login.sendLogout}, 'Logout'), m('.user-list', State.online.map(username => m('details', m('summary', m('span', username), ), m(VideoOptions, {username}), ), )), ), m(Media), ) }, } const Main = { view() { const connected = State.websocket && State.websocket.readyState === 1 return connected ? m(Chat) : m(Login) }, } m.mount(document.body, Main) /* * * WEBSOCKETS * */ 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 difference = (l1, l2) => l1.filter(u => !l2.includes(u)) difference(message.online, State.online).forEach(username => State.posts.push({ts: message.ts, value: `${username} joined`})) difference(State.online, message.online).forEach(username => State.posts.push({ts: message.ts, value: `${username} left`})) } if(!doNotLog.has(message.kind)) { console.log(message) } signal(message) m.redraw() } State.websocket.onclose = (e) => { State.online.forEach(signalPeerStop) if(!e.wasClean) { setTimeout(connect, 1000, username) } m.redraw() } } if(localStorage.username) { connect(localStorage.username) } addEventListener('pagehide', Media.turnOff)