| marked.setOptions({ | |||||
| breaks: true, | |||||
| }) | |||||
| 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() | |||||
| TextBox.autoSize() | |||||
| } | |||||
| }, | |||||
| 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 Chat = { | |||||
| log: (message) => { | |||||
| signal({kind: 'post', ts: +new Date(), value: '' + message}) | |||||
| }, | |||||
| 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('.tabbed-area', | |||||
| m(Media), | |||||
| // m(Hanabi), | |||||
| ), | |||||
| ) | |||||
| }, | |||||
| } |
| .chat { | |||||
| display: grid; | |||||
| grid-template-areas: | |||||
| 'posts online' | |||||
| 'actions actions' | |||||
| 'tabbed-area tabbed-area' | |||||
| ; | |||||
| grid-template-columns: 1fr auto; | |||||
| grid-template-rows: 140px auto 1fr; | |||||
| position: fixed; | |||||
| top: 0; | |||||
| --pad: 3px; | |||||
| padding: var(--pad); | |||||
| width: calc(100vw - 2 * var(--pad)); | |||||
| height: 100%; | |||||
| } | |||||
| .online { | |||||
| grid-area: online; | |||||
| } | |||||
| .posts { | |||||
| grid-area: posts; | |||||
| overflow-y: scroll; | |||||
| } | |||||
| .post > div { | |||||
| display: inline; | |||||
| padding-left: var(--pad); | |||||
| } | |||||
| .post .ts { | |||||
| color: rgba(0, 0, 0, 0.4); | |||||
| font-family: monospace; | |||||
| } | |||||
| .post .source { | |||||
| font-weight: bold; | |||||
| } | |||||
| .post .text p { | |||||
| margin: 0; | |||||
| } | |||||
| .post .text p:first-child { | |||||
| display: inline; | |||||
| } | |||||
| .actions { | |||||
| grid-area: actions; | |||||
| display: grid; | |||||
| grid-template-columns: 1fr auto; | |||||
| } | |||||
| #textbox { | |||||
| resize: none; | |||||
| } | |||||
| .media { | |||||
| display: grid; | |||||
| grid-template-rows: auto 1fr; | |||||
| } | |||||
| .video-meta { | |||||
| position: absolute; | |||||
| z-index: 999; | |||||
| } | |||||
| .video-option { | |||||
| display: block; | |||||
| } | |||||
| .videos { | |||||
| display: grid; | |||||
| grid-auto-flow: column; | |||||
| justify-content: start; | |||||
| overflow-x: scroll; | |||||
| height: 50px; | |||||
| resize: vertical; | |||||
| position: relative; | |||||
| } | |||||
| .videos.full-screen { | |||||
| height: 100% !important; | |||||
| resize: none; | |||||
| } | |||||
| .video-container { | |||||
| display: inline-block; | |||||
| position: relative; | |||||
| height: 100%; | |||||
| overflow: scroll; | |||||
| } | |||||
| .video-container video { | |||||
| background-color: black; | |||||
| height: 100%; | |||||
| width: 100%; | |||||
| } | |||||
| /* mirror */ | |||||
| .video-container.mirror video { | |||||
| transform: scaleX(-1); | |||||
| } | |||||
| /* square */ | |||||
| .video-container.square { | |||||
| width: var(--height); | |||||
| overflow: hidden; | |||||
| } | |||||
| .video-container.square video { | |||||
| object-fit: cover; | |||||
| } | |||||
| /* full-screen */ | |||||
| .video-container.full-screen { | |||||
| position: absolute; | |||||
| top: 0; | |||||
| left: 0; | |||||
| right: 0; | |||||
| width: unset; | |||||
| z-index: 999; | |||||
| overflow: scroll; | |||||
| background-color: black; | |||||
| } | |||||
| .video-container.full-screen video { | |||||
| object-fit: contain; | |||||
| width: unset; | |||||
| height: unset; | |||||
| } | |||||
| .tabbed-area { | |||||
| grid-area: tabbed-area; | |||||
| grid-template-columns: 1fr 1fr; | |||||
| } | |||||
| @media only screen and (min-width: 800px) { | |||||
| .chat { | |||||
| grid-template-areas: | |||||
| 'online tabbed-area' | |||||
| 'posts tabbed-area' | |||||
| 'actions tabbed-area' | |||||
| ; | |||||
| grid-template-columns: 320px 1fr; | |||||
| grid-template-rows: auto 1fr auto; | |||||
| height: calc(100vh - 2 * var(--pad)); | |||||
| } | |||||
| } |
| toggle: (property) => () => { | toggle: (property) => () => { | ||||
| VideoConfig[property] = !VideoConfig[property] | VideoConfig[property] = !VideoConfig[property] | ||||
| updateSelfVideo() | updateSelfVideo() | ||||
| }, | |||||
| view() { | |||||
| return [ | |||||
| m('button', {onclick: VideoConfig.toggle('videoOn')}, 'video'), | |||||
| m('button', {onclick: VideoConfig.toggle('audioOn')}, 'audio'), | |||||
| ] | |||||
| } | } | ||||
| }) | }) | ||||
| const updateSelfVideo = async () => { | const updateSelfVideo = async () => { | ||||
| const dims = [StreamContainer.getRows(), StreamContainer.getColumns()] | const dims = [StreamContainer.getRows(), StreamContainer.getColumns()] | ||||
| if(screen.height > screen.width) dims.reverse() | if(screen.height > screen.width) dims.reverse() | ||||
| const style = { | const style = { | ||||
| overflow: 'hidden', | |||||
| display: 'grid', | display: 'grid', | ||||
| padding: '3px', | padding: '3px', | ||||
| gridGap: '3px', | gridGap: '3px', | ||||
| height: '90vh', | |||||
| gridTemplateRows: dims[0], | gridTemplateRows: dims[0], | ||||
| gridTemplateColumns: dims[1], | gridTemplateColumns: dims[1], | ||||
| } | } | ||||
| return [ | |||||
| m('span.video-controls', | |||||
| m('button', {onclick: VideoConfig.toggle('videoOn')}, 'video'), | |||||
| m('button', {onclick: VideoConfig.toggle('audioOn')}, 'audio'), | |||||
| ), | |||||
| m('.videos', {style}, | |||||
| m(Video, {username: State.username}), | |||||
| State.online.filter(username => username != State.username) | |||||
| .map(username => m(Video, {username})) | |||||
| ), | |||||
| ] | |||||
| return m('.videos', {style}, | |||||
| m(Video, {username: State.username}), | |||||
| State.online.filter(username => username != State.username) | |||||
| .map(username => m(Video, {username})) | |||||
| ) | |||||
| }, | }, | ||||
| } | } | ||||
| <script src="/libs/marked.min.js"></script> | |||||
| <script src="/libs/purify.min.js"></script> | |||||
| <script src="/libs/mobile-drag-drop.min.js"></script> | |||||
| <link rel="stylesheet" href="/apps/hanabi.css" /> | |||||
| <script src="/apps/hanabi.js" defer></script> |
| const getOrCreateRpc = (username) => { | |||||
| if(State.username === username) { | |||||
| return | |||||
| } | |||||
| if(!State.rpcs[username]) { | |||||
| 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 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) | |||||
| } | |||||
| } | |||||
| autocomplete: 'off', | autocomplete: 'off', | ||||
| value: localStorage.username, | value: localStorage.username, | ||||
| } | } | ||||
| const style = { | |||||
| display: 'inline', | |||||
| const mainStyle = { | |||||
| display: 'grid', | |||||
| gridTemplateRows: 'auto 1fr', | |||||
| height: '100vh', | |||||
| overflow: 'hidden', | |||||
| } | } | ||||
| return m('main', | |||||
| m('span.login-container', | |||||
| const headerStyle = { | |||||
| display: 'grid', | |||||
| gridAutoFlow: 'column', | |||||
| justifyItems: 'start', | |||||
| marginRight: 'auto', | |||||
| } | |||||
| return m('main', {style: mainStyle}, | |||||
| m('header', {style: headerStyle}, | |||||
| State.isConnected ? null : m('form.login', | State.isConnected ? null : m('form.login', | ||||
| {style, onsubmit: Base.sendLogin}, | |||||
| {onsubmit: Base.sendLogin}, | |||||
| m('input', attrs), | m('input', attrs), | ||||
| m('button', 'Login'), | m('button', 'Login'), | ||||
| ), | ), | ||||
| State.isConnected ? m('form.logout', | State.isConnected ? m('form.logout', | ||||
| {style, onsubmit: Base.sendLogout}, | |||||
| {onsubmit: Base.sendLogout}, | |||||
| m('button', 'Logout'), | m('button', 'Logout'), | ||||
| m('input[readonly]', {value: location}), | m('input[readonly]', {value: location}), | ||||
| ) : null, | ) : null, | ||||
| State.isConnected ? m(VideoConfig) : null, | |||||
| m('span.error', State.info), | m('span.error', State.info), | ||||
| ), | ), | ||||
| State.isConnected ? m(StreamContainer) : null, | State.isConnected ? m(StreamContainer) : null, | ||||
| } | } | ||||
| if(!doNotLog.has(message.kind)) { | if(!doNotLog.has(message.kind)) { | ||||
| console.log('@', message) | |||||
| console.log(message) | |||||
| } | } | ||||
| signal(message) | signal(message) | ||||