|  | 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 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
    }
    localStream.getTracks().forEach(track => {
        track.stop()
        localStream.removeTrack(track)
    })
    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))
    }
    document.querySelectorAll('video').forEach(video => video.srcObject = video.srcObject)
    wire({kind: 'peerInfo', value: {type: 'request'}})
}
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.rows = textbox.value.split('\n').length
    },
    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,
                onkeyup: 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}
        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)}),
        )
    },
}
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'}),
                    '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}`
    },
    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', 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', 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)
 |