Bläddra i källkod

Chat back online

master
Roderic Day 5 år sedan
förälder
incheckning
8cd5c1b1b9
8 ändrade filer med 134 tillägg och 305 borttagningar
  1. +36
    -0
      apps/chat.css
  2. +39
    -46
      apps/chat.js
  3. +2
    -0
      apps/hanabi.js
  4. +0
    -129
      apps/pico.css
  5. +0
    -6
      apps/stuff
  6. +0
    -70
      apps/webrtc.js
  7. +4
    -0
      pico.html
  8. +53
    -54
      pico.js

+ 36
- 0
apps/chat.css Visa fil

@@ -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;
}

+ 39
- 46
apps/chat.js Visa fil

@@ -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
},
}

+ 2
- 0
apps/hanabi.js Visa fil

@@ -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']) {

+ 0
- 129
apps/pico.css Visa fil

@@ -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));
}
}

+ 0
- 6
apps/stuff Visa fil

@@ -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>

+ 0
- 70
apps/webrtc.js Visa fil

@@ -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)
}
}



+ 4
- 0
pico.html Visa fil

@@ -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;">

+ 53
- 54
pico.js Visa fil

@@ -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)
}

Laddar…
Avbryt
Spara