@@ -0,0 +1,36 @@ | |||
.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; | |||
} |
@@ -1,9 +1,3 @@ | |||
marked.setOptions({ | |||
breaks: true, | |||
}) | |||
const scrollIntoView = (vnode) => { | |||
vnode.dom.scrollIntoView() | |||
} | |||
const TextBox = { | |||
autoSize: () => { | |||
textbox.style.height = `0px` | |||
@@ -44,8 +38,8 @@ const TextBox = { | |||
}, | |||
hotKey: (e) => { | |||
// 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() | |||
TextBox.sendPost() | |||
} | |||
@@ -76,19 +70,13 @@ const TextBox = { | |||
onkeydown: TextBox.hotKey, | |||
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) => { | |||
const dt = new Date(ts) | |||
@@ -97,39 +85,44 @@ const Chat = { | |||
const S = `0${dt.getSeconds()}`.slice(-2) | |||
return `${H}:${M}:${S}` | |||
}, | |||
outboundLinks: (vnode) => { | |||
vnode.dom.querySelectorAll('a').forEach(anchor => { | |||
fixPost: ({dom}) => { | |||
dom.querySelectorAll('a').forEach(anchor => { | |||
anchor.target = '_blank' | |||
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() { | |||
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('.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 | |||
}, | |||
} |
@@ -1,3 +1,5 @@ | |||
<script src="/libs/mobile-drag-drop.min.js"></script> | |||
const initCards = () => { | |||
const cards = new Map() | |||
for(const color of ['red', 'green', 'blue', 'yellow', 'white']) { |
@@ -1,129 +0,0 @@ | |||
.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)); | |||
} | |||
} |
@@ -1,6 +0,0 @@ | |||
<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> |
@@ -1,70 +0,0 @@ | |||
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) | |||
} | |||
} | |||
@@ -3,8 +3,12 @@ | |||
<head> | |||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | |||
<link rel="icon" href="/pico.svg" /> | |||
<link rel="stylesheet" href="/apps/chat.css"/> | |||
<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/chat.js"></script> | |||
<script src="/pico.js" defer></script> | |||
</head> | |||
<body style="margin: 0; padding: 0;"> |
@@ -31,10 +31,6 @@ listen('state', ({detail}) => { | |||
delete detail.kind | |||
Object.assign(State, detail) | |||
}) | |||
listen('post', ({detail}) => { | |||
State.messages.push(detail) | |||
m.redraw() | |||
}) | |||
const doNotLog = new Set(['login', 'state', 'post', 'peerInfo', 'join', 'leave']) | |||
/* | |||
@@ -43,7 +39,7 @@ const doNotLog = new Set(['login', 'state', 'post', 'peerInfo', 'join', 'leave'] | |||
* | |||
*/ | |||
State.unseen = 0 | |||
listen('post', () => {State.unseen += !document.hasFocus(); updateTitle()}) | |||
listen('beep', () => {State.unseen += !document.hasFocus(); updateTitle()}) | |||
listen('focus', () => {State.unseen = 0; updateTitle()}) | |||
const updateTitle = () => { | |||
document.title = location.href.split('//')[1] + (State.unseen ? ` (${State.unseen})` : ``) | |||
@@ -58,10 +54,60 @@ const autoFocus = (vnode) => { | |||
} | |||
/* | |||
* | |||
* 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 | |||
* | |||
*/ | |||
const Base = { | |||
oncreate: () => { | |||
if(localStorage.username) { | |||
connect(localStorage.username) | |||
} | |||
}, | |||
sendLogin: (e) => { | |||
e.preventDefault() | |||
const username = e.target.username.value | |||
@@ -105,59 +151,12 @@ const Base = { | |||
m('input[readonly]', {value: location}), | |||
) : null, | |||
State.isConnected ? m(VideoConfig) : null, | |||
State.isConnected ? m(ChatConfig) : null, | |||
m('span.error', State.info), | |||
), | |||
State.isConnected ? m(StreamContainer) : null, | |||
State.isConnected ? m(Chat) : null, | |||
) | |||
}, | |||
} | |||
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) | |||
} |