|  |  | @@ -120,66 +120,85 @@ const autoFocus = (vnode) => { | 
		
	
		
			
			|  |  |  | 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(/^    /, '') | 
		
	
		
			
			|  |  |  | } | 
		
	
		
			
			|  |  |  | 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)] | 
		
	
		
			
			|  |  |  | } | 
		
	
		
			
			|  |  |  | 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) | 
		
	
		
			
			|  |  |  | } | 
		
	
		
			
			|  |  |  | 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'], | 
		
	
	
		
			
			|  |  | @@ -215,7 +234,6 @@ const Video = { | 
		
	
		
			
			|  |  |  | dom.autoplay = true | 
		
	
		
			
			|  |  |  | dom.muted = (username === State.username) | 
		
	
		
			
			|  |  |  | dom.srcObject = stream | 
		
	
		
			
			|  |  |  | dom.ondblclick = toggleFullscreen(dom) | 
		
	
		
			
			|  |  |  | }, | 
		
	
		
			
			|  |  |  | view({attrs}) { | 
		
	
		
			
			|  |  |  | const classList = VideoOptions.getClassListFor(attrs.username) | 
		
	
	
		
			
			|  |  | @@ -330,25 +348,23 @@ const Login = { | 
		
	
		
			
			|  |  |  | }, | 
		
	
		
			
			|  |  |  | } | 
		
	
		
			
			|  |  |  | const Chat = { | 
		
	
		
			
			|  |  |  | sendPost: () => { | 
		
	
		
			
			|  |  |  | if(textbox.value) { | 
		
	
		
			
			|  |  |  | wire({kind: 'post', value: textbox.value}) | 
		
	
		
			
			|  |  |  | textbox.value = '' | 
		
	
		
			
			|  |  |  | } | 
		
	
		
			
			|  |  |  | 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', prettyTime(post.ts)), | 
		
	
		
			
			|  |  |  | m('.ts', Chat.prettifyTime(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(TextBox), | 
		
	
		
			
			|  |  |  | m('.online', | 
		
	
		
			
			|  |  |  | m('button', {onclick: Login.sendLogout}, 'Logout'), | 
		
	
		
			
			|  |  |  | m('.user-list', State.online.map(username => |