.chat { | |||||
background-color: rgba(255, 255, 255, 0.95); | |||||
position: fixed; | |||||
height: 140px; | |||||
width: 320px; | |||||
right: 0; | |||||
top: 0; | |||||
} | |||||
.chat .actions { | |||||
display: grid; | |||||
grid-template-columns: 1fr auto; | |||||
} | |||||
#textbox { | |||||
resize: none; | |||||
} | |||||
.posts { | |||||
overflow-y: scroll; | |||||
height: 100%; | |||||
} | |||||
.post > * { | |||||
display: inline; | |||||
padding-right: 3px; | |||||
} | |||||
.post .ts { | |||||
font-family: monospace; | |||||
color: gray; | |||||
} | |||||
.post .source { | |||||
font-weight: bold; | |||||
} | |||||
.post .text p { | |||||
margin: 0; | |||||
} | |||||
.post .text p:first-child { | |||||
display: inline; | |||||
} |
marked.setOptions({ | |||||
breaks: true, | |||||
}) | |||||
const scrollIntoView = (vnode) => { | |||||
vnode.dom.scrollIntoView() | |||||
} | |||||
const TextBox = { | const TextBox = { | ||||
autoSize: () => { | autoSize: () => { | ||||
textbox.style.height = `0px` | textbox.style.height = `0px` | ||||
}, | }, | ||||
hotKey: (e) => { | hotKey: (e) => { | ||||
// if isDesktop, Enter posts, unless Shift+Enter | // if isDesktop, Enter posts, unless Shift+Enter | ||||
// use isLandscape as proxy for isDesktop | |||||
if(e.key === 'Enter' && isLandscape && !e.shiftKey) { | |||||
isDesktop = true | |||||
if(e.key === 'Enter' && isDesktop && !e.shiftKey) { | |||||
e.preventDefault() | e.preventDefault() | ||||
TextBox.sendPost() | TextBox.sendPost() | ||||
} | } | ||||
onkeydown: TextBox.hotKey, | onkeydown: TextBox.hotKey, | ||||
oninput: TextBox.autoSize, | oninput: TextBox.autoSize, | ||||
}), | }), | ||||
m('button', { | |||||
onclick: ({target}) => { | |||||
TextBox.sendPost() | |||||
TextBox.autoSize() | |||||
}, | |||||
}, | |||||
'Send'), | |||||
m('button', {onclick: TextBox.sendPost}, 'Send'), | |||||
) | ) | ||||
}, | }, | ||||
} | } | ||||
const Chat = { | |||||
log: (message) => { | |||||
signal({kind: 'post', ts: +new Date(), value: '' + message}) | |||||
const Post = { | |||||
oncreate: ({dom}) => { | |||||
dom.scrollIntoView() | |||||
}, | }, | ||||
prettifyTime: (ts) => { | prettifyTime: (ts) => { | ||||
const dt = new Date(ts) | const dt = new Date(ts) | ||||
const S = `0${dt.getSeconds()}`.slice(-2) | const S = `0${dt.getSeconds()}`.slice(-2) | ||||
return `${H}:${M}:${S}` | return `${H}:${M}:${S}` | ||||
}, | }, | ||||
outboundLinks: (vnode) => { | |||||
vnode.dom.querySelectorAll('a').forEach(anchor => { | |||||
fixPost: ({dom}) => { | |||||
dom.querySelectorAll('a').forEach(anchor => { | |||||
anchor.target = '_blank' | anchor.target = '_blank' | ||||
anchor.rel = 'noopener' | anchor.rel = 'noopener' | ||||
}) | }) | ||||
}, | }, | ||||
view({attrs: {post}}) { | |||||
return m('.post', | |||||
m('.ts', this.prettifyTime(post.ts)), | |||||
m('.source', post.source || '~'), | |||||
m('.text', {oncreate: this.fixPost}, m.trust(DOMPurify.sanitize(marked(post.value)))), | |||||
) | |||||
} | |||||
} | |||||
const ChatConfig = { | |||||
isOn: false, | |||||
view() { | |||||
return m('button', {onclick: () => {ChatConfig.isOn = !ChatConfig.isOn}}, 'chat') | |||||
} | |||||
} | |||||
const Chat = { | |||||
posts: [], | |||||
oncreate() { | |||||
listen('post', ({detail}) => { | |||||
this.posts.push(detail) | |||||
m.redraw() | |||||
}) | |||||
listen('logout', () => { | |||||
this.posts = [] | |||||
}) | |||||
marked.setOptions({ | |||||
breaks: true, | |||||
}) | |||||
}, | |||||
view() { | 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))) | |||||
), | |||||
)), | |||||
), | |||||
return ChatConfig.isOn ? m('.chat', | |||||
m('.posts', this.posts.map(post => m(Post, {post}))), | |||||
m(TextBox), | 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), | |||||
), | |||||
) | |||||
) : null | |||||
}, | }, | ||||
} | } |
<script src="/libs/mobile-drag-drop.min.js"></script> | |||||
const initCards = () => { | const initCards = () => { | ||||
const cards = new Map() | const cards = new Map() | ||||
for(const color of ['red', 'green', 'blue', 'yellow', 'white']) { | for(const color of ['red', 'green', 'blue', 'yellow', 'white']) { |
.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)); | |||||
} | |||||
} |
<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) | |||||
} | |||||
} | |||||
<head> | <head> | ||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||
<link rel="icon" href="/pico.svg" /> | <link rel="icon" href="/pico.svg" /> | ||||
<link rel="stylesheet" href="/apps/chat.css"/> | |||||
<script src="/libs/mithril.min.js"></script> | <script src="/libs/mithril.min.js"></script> | ||||
<script src="/libs/marked.min.js"></script> | |||||
<script src="/libs/purify.min.js"></script> | |||||
<script src="/apps/streams.js"></script> | <script src="/apps/streams.js"></script> | ||||
<script src="/apps/chat.js"></script> | |||||
<script src="/pico.js" defer></script> | <script src="/pico.js" defer></script> | ||||
</head> | </head> | ||||
<body style="margin: 0; padding: 0;"> | <body style="margin: 0; padding: 0;"> |
delete detail.kind | delete detail.kind | ||||
Object.assign(State, detail) | Object.assign(State, detail) | ||||
}) | }) | ||||
listen('post', ({detail}) => { | |||||
State.messages.push(detail) | |||||
m.redraw() | |||||
}) | |||||
const doNotLog = new Set(['login', 'state', 'post', 'peerInfo', 'join', 'leave']) | const doNotLog = new Set(['login', 'state', 'post', 'peerInfo', 'join', 'leave']) | ||||
/* | /* | ||||
* | * | ||||
*/ | */ | ||||
State.unseen = 0 | State.unseen = 0 | ||||
listen('post', () => {State.unseen += !document.hasFocus(); updateTitle()}) | |||||
listen('beep', () => {State.unseen += !document.hasFocus(); updateTitle()}) | |||||
listen('focus', () => {State.unseen = 0; updateTitle()}) | listen('focus', () => {State.unseen = 0; updateTitle()}) | ||||
const updateTitle = () => { | const updateTitle = () => { | ||||
document.title = location.href.split('//')[1] + (State.unseen ? ` (${State.unseen})` : ``) | document.title = location.href.split('//')[1] + (State.unseen ? ` (${State.unseen})` : ``) | ||||
} | } | ||||
/* | /* | ||||
* | * | ||||
* WEBSOCKET | |||||
* | |||||
*/ | |||||
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 => { | |||||
signal({kind: 'post', ts: message.ts, value: `${username} joined`}) | |||||
signal({kind: 'join', username: username}) | |||||
}) | |||||
difference(State.online, message.online).forEach(username => { | |||||
signal({kind: 'post', ts: message.ts, value: `${username} left`}) | |||||
signal({kind: 'leave', username: username}) | |||||
}) | |||||
} | |||||
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() | |||||
} | |||||
} | |||||
/* | |||||
* | |||||
* BASE | * BASE | ||||
* | * | ||||
*/ | */ | ||||
const Base = { | const Base = { | ||||
oncreate: () => { | |||||
if(localStorage.username) { | |||||
connect(localStorage.username) | |||||
} | |||||
}, | |||||
sendLogin: (e) => { | sendLogin: (e) => { | ||||
e.preventDefault() | e.preventDefault() | ||||
const username = e.target.username.value | const username = e.target.username.value | ||||
m('input[readonly]', {value: location}), | m('input[readonly]', {value: location}), | ||||
) : null, | ) : null, | ||||
State.isConnected ? m(VideoConfig) : null, | State.isConnected ? m(VideoConfig) : null, | ||||
State.isConnected ? m(ChatConfig) : null, | |||||
m('span.error', State.info), | m('span.error', State.info), | ||||
), | ), | ||||
State.isConnected ? m(StreamContainer) : null, | State.isConnected ? m(StreamContainer) : null, | ||||
State.isConnected ? m(Chat) : null, | |||||
) | ) | ||||
}, | }, | ||||
} | } | ||||
m.mount(document.body, Base) | m.mount(document.body, Base) | ||||
/* | |||||
* | |||||
* WEBSOCKET | |||||
* | |||||
*/ | |||||
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 => { | |||||
signal({kind: 'post', ts: message.ts, value: `${username} joined`}) | |||||
signal({kind: 'join', username: username}) | |||||
}) | |||||
difference(State.online, message.online).forEach(username => { | |||||
signal({kind: 'post', ts: message.ts, value: `${username} left`}) | |||||
signal({kind: 'leave', username: username}) | |||||
}) | |||||
} | |||||
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) | |||||
} |