| 
                        123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 | 
                        - const isLandscape = screen.width > screen.height
 - const State = {
 -     username: null,
 -     websocket: null,
 -     online: [],
 -     posts: [],
 -     rpcs: {},
 -     media: {},
 -     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
 -     }
 -     const myStream = State.media[State.username]
 -     if(!State.rpcs[username] && myStream) {
 -         const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]})
 -         myStream.getTracks().forEach(track => rpc.addTrack(track, myStream))
 -         rpc.onicecandidate = ({candidate}) => {
 -             if(candidate) {
 -                 wire({kind: 'peerInfo', value: {type: 'candidate', candidate}})
 -             }
 -         }
 -         rpc.ontrack = (e) => {
 -             State.media[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 getSelectedMedia = async () => {
 -     const stream = new MediaStream()
 -     const addTrack = stream.addTrack.bind(stream)
 - 
 -     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))
 -     }
 - 
 -     return stream
 - }
 - const onPeerInfo = async ({detail: message}) => {
 -     const rpc = getOrCreateRpc(message.source)
 -     if(rpc && message.value.type === 'request') {
 -         const localOffer = await rpc.createOffer()
 -         await rpc.setLocalDescription(localOffer)
 -         wire({kind: 'peerInfo', value: localOffer, target: message.source})
 -     }
 -     else if(rpc && message.value.type === 'offer') {
 -         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.media[message.source]) {
 -             State.media[message.source].getTracks().map(track => track.stop())
 -             delete State.media[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'],
 -     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, stream}) => ({dom}) => {
 -         dom.autoplay = true
 -         dom.muted = (username === State.username)
 -         dom.srcObject = stream
 -     },
 -     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 () => {
 -         const media = await getSelectedMedia()
 -         State.media[State.username] = media
 -         wire({kind: 'peerInfo', value: {type: 'request'}})
 -         m.redraw()
 -     },
 -     turnOff: () => {
 -         wire({kind: 'peerInfo', value: {type: 'stop'}})
 -         State.online.forEach(signalPeerStop)
 -     },
 -     view() {
 -         if(!State.media[State.username]) {
 -             return m('.media',
 -                 m('.media-settings',
 -                     m('button', {onclick: Media.turnOn}, 'turn on'),
 -                     m('select#media-source', Media.videoSources.map(option => m('option', option))),
 -                     m('label', m('input#mute-check', {type: 'checkbox'}), 'mute'),
 -                 ),
 -             )
 -         }
 -         else {
 -             return m('.media',
 -                 m('.media-settings',
 -                     m('button', {onclick: Media.turnOff}, 'turn off'),
 -                 ),
 -                 m('.videos',
 -                     Object.entries(State.media).map(([username, stream]) =>
 -                         m(Video, {username, stream})
 -                     ),
 -                 ),
 -             )
 -         }
 -     }
 - }
 - 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)
 
 
  |