|
- 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 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 toggleFullscreen = (el) => (event) => {
- const requestFullscreen = (el.requestFullscreen || el.webkitRequestFullscreen).bind(el)
- document.fullscreenElement ? document.exitFullscreen() : requestFullscreen()
- }
- const prettyTime = (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}`
- }
- const blockIndent = (text, iStart, iEnd, nLevels) => {
- // prop
- 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)]
- }
- const hotKey = (e) => {
- // if isDesktop, Enter posts, unless Shift+Enter
- // use isLandscape as proxy for isDesktop
- if(e.key === 'Enter' && isLandscape && !e.shiftKey) {
- e.preventDefault()
- Chat.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] = blockIndent(text, A, B, nLevels)
- textbox.value = newText
- textbox.setSelectionRange(newA, newB)
- }
- }
- const VideoOptions = {
- available: ['mirror', 'square'],
- getFor: (username) => {
- if(!State.options[username]) {
- State.options[username] = new Set(VideoOptions.available)
- }
- 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
- dom.ondblclick = toggleFullscreen(dom)
- },
- 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.width = `${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,
- },
- 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
- },
- turnOn: async () => {
- const media = await Media.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 = {
- sendPost: () => {
- if(textbox.value) {
- wire({kind: 'post', value: textbox.value})
- textbox.value = ''
- }
- },
- view() {
- return m('.chat',
- m('.posts',
- State.posts.map(post => m('.post', {oncreate: scrollIntoView},
- m('.ts', prettyTime(post.ts)),
- m('.source', post.source || '~'),
- m('.text', m.trust(DOMPurify.sanitize(marked(post.value)))),
- )),
- ),
- m('.actions',
- m('textarea#textbox', {oncreate: autoFocus, onkeydown: hotKey}),
- m('button', {onclick: Chat.sendPost}, 'Send'),
- ),
- 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)
|