const connections = {} | |||||
const datachannels = {} | |||||
const streams = {} | |||||
const screen = {} | |||||
const allStreams = {} | |||||
function setStream({source, stream}) { | |||||
if(streams[source] && streams[source].id !== stream.id) { | |||||
streams[source].getTracks().forEach(track => track.stop()) | |||||
} | |||||
streams[source] = stream | |||||
m.redraw() | |||||
if(source === State.username) { | |||||
reloadAllStreams() | |||||
} | |||||
} | |||||
function reloadAllStreams() { | |||||
State.online.forEach(username => { | |||||
const rpc = connections[username] | |||||
if(rpc) { | |||||
rpc.getSenders().map(s => rpc.removeTrack(s)) | |||||
} | |||||
signal({kind: 'rpc', value: {type: 'request'}, source: username}) | |||||
}) | |||||
// https://stackoverflow.com/a/2117523 | |||||
function uuidv4() { | |||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => | |||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) | |||||
) | |||||
} | } | ||||
function createConnection(target) { | |||||
function newRPC(uid, target) { | |||||
const rpc = new RTCPeerConnection(rpcConfig) | const rpc = new RTCPeerConnection(rpcConfig) | ||||
rpc.onicecandidate = ({candidate}) => { | rpc.onicecandidate = ({candidate}) => { | ||||
if(candidate && candidate.candidate) { | if(candidate && candidate.candidate) { | ||||
const value = {type: 'candidate', candidate} | |||||
wire({kind: 'rpc', value, target}) | |||||
const value = JSON.parse(JSON.stringify(candidate)) | |||||
wire({kind: 'ice-candidate', value: {...value, uid}, target}) | |||||
} | } | ||||
} | } | ||||
rpc.ontrack = ({streams: [stream]}) => { | rpc.ontrack = ({streams: [stream]}) => { | ||||
setStream({source: target, stream}) | |||||
stream.getTracks().forEach(tr => ScreenShare.stream.addTrack(tr, stream)) | |||||
} | } | ||||
rpc.onconnectionstatechange = () => { | rpc.onconnectionstatechange = () => { | ||||
if(rpc.connectionState === 'failed') { | |||||
console.log(target, 'failed, retry!') | |||||
wire({kind: 'rpc', value: {type: 'request'}, target}) | |||||
} | |||||
console.log(rpc.connectionState) | |||||
} | } | ||||
rpc.ondatachannel = ({channel}) => { | |||||
datachannels[target] = channel | |||||
datachannels[target].onmessage = ({data}) => console.log(data) | |||||
return rpc | |||||
} | |||||
// for testing purposes | |||||
const msg = `rpc established from ${target} to ${State.username}` | |||||
datachannels[target].send(msg) | |||||
console.log(msg) | |||||
} | |||||
addEventListener('screen-start', async ({detail}) => { | |||||
const {target, stream} = detail.value | |||||
const uid = uuidv4() | |||||
const rpc = newRPC(uid, target) | |||||
allStreams[uid] = {target, rpc} | |||||
connections[target] = rpc | |||||
if(State.username > target) { | |||||
datachannels[target] = rpc.createDataChannel('test') | |||||
datachannels[target].onmessage = ({data}) => console.log(data) | |||||
signal({kind: 'rpc', value: {type: 'request'}, source: target}) | |||||
} | |||||
} | |||||
stream.getTracks().forEach(tr => rpc.addTrack(tr, stream)) | |||||
const localOffer = await rpc.createOffer() | |||||
await rpc.setLocalDescription(localOffer) | |||||
async function handlePeerInfo({source: target, value}) { | |||||
const rpc = connections[target] | |||||
wire({kind: 'screen-offer', value: {...localOffer, uid}, target}) | |||||
}) | |||||
if(!rpc) { | |||||
return | |||||
} | |||||
addEventListener('screen-offer', async ({detail}) => { | |||||
const target = detail.source | |||||
const uid = detail.value.uid | |||||
const rpc = newRPC(uid, target) | |||||
allStreams[uid] = {target, rpc} | |||||
const stream = streams[State.username] | |||||
if(stream) { | |||||
stream.getTracks().forEach(track => { | |||||
try { rpc.addTrack(track, stream) } | |||||
catch { } | |||||
}) | |||||
} | |||||
await rpc.setRemoteDescription(detail.value) | |||||
const localAnswer = await rpc.createAnswer() | |||||
await rpc.setLocalDescription(localAnswer) | |||||
if(value.type === 'request') { | |||||
const localOffer = await rpc.createOffer() | |||||
await rpc.setLocalDescription(localOffer) | |||||
wire({kind: 'rpc', value: localOffer, target}) | |||||
} | |||||
else if(value.type === 'offer') { | |||||
const remoteOffer = new RTCSessionDescription(value) | |||||
await rpc.setRemoteDescription(remoteOffer) | |||||
const localAnswer = await rpc.createAnswer() | |||||
await rpc.setLocalDescription(localAnswer) | |||||
wire({kind: 'rpc', value: localAnswer, target}) | |||||
} | |||||
else if(value.type === 'answer') { | |||||
const remoteAnswer = new RTCSessionDescription(value) | |||||
await rpc.setRemoteDescription(remoteAnswer).catch(e => e) | |||||
} | |||||
else if(value.type === 'candidate') { | |||||
const candidate = new RTCIceCandidate(value.candidate) | |||||
await rpc.addIceCandidate(candidate).catch(e => e) | |||||
} | |||||
} | |||||
wire({kind: 'screen-answer', value: {...localAnswer, uid}, target}) | |||||
}) | |||||
function destroyConnection(username) { | |||||
if(streams[username]) { | |||||
streams[username].getTracks().forEach(t => t.stop()) | |||||
delete streams[username] | |||||
} | |||||
if(datachannels[username]) { | |||||
datachannels[username].close() | |||||
delete datachannels[username] | |||||
} | |||||
if(connections[username]) { | |||||
connections[username].getReceivers().forEach(r => r.track.stop()) | |||||
connections[username].close() | |||||
delete connections[username] | |||||
} | |||||
} | |||||
addEventListener('screen-answer', async ({detail}) => { | |||||
await allStreams[detail.value.uid].rpc.setRemoteDescription(detail.value) | |||||
}) | |||||
function destroyAll() { | |||||
const people = new Set() | |||||
const collect = [connections, datachannels, streams, screen] | |||||
.map(collection => Object.keys(collection)) | |||||
.forEach(keys => keys.forEach(key => people.add(key))) | |||||
people.forEach(destroyConnection) | |||||
} | |||||
addEventListener('ice-candidate', async ({detail}) => { | |||||
await allStreams[detail.value.uid].rpc.addIceCandidate(detail.value) | |||||
}) | |||||
addEventListener('stream', (e) => setStream(e.detail.value)) | |||||
addEventListener('rpc', (e) => handlePeerInfo(e.detail)) | |||||
addEventListener('join', (e) => createConnection(e.detail.value)) | |||||
addEventListener('leave', (e) => destroyConnection(e.detail.value)) | |||||
addEventListener('logout', () => destroyAll()) | |||||
addEventListener('load', () => doNotLog.add('rpc')) | |||||
addEventListener('load', () => { | |||||
doNotLog.add('screen-start') | |||||
doNotLog.add('screen-offer') | |||||
doNotLog.add('screen-answer') | |||||
doNotLog.add('ice-candidate') | |||||
}) |
const connections = {} | |||||
const datachannels = {} | |||||
const videoElements = {} | |||||
function setStream({source, stream}) { | |||||
const element = videoElements[source] | |||||
const active = element && element.srcObject | |||||
if(active && active.id !== stream.id) { | |||||
videoElements[source].srcObject.getTracks().forEach(tr => tr.stop()) | |||||
} | |||||
element.srcObject = stream | |||||
if(source === State.username) { | |||||
signal({kind: 'stream-refresh'}) | |||||
} | |||||
} | |||||
function reloadAllStreams() { | |||||
State.online.forEach(username => { | |||||
const rpc = connections[username] | |||||
if(rpc) { | |||||
rpc.getSenders().map(s => console.log(s) || rpc.removeTrack(s)) | |||||
} | |||||
signal({kind: 'rpc', value: {type: 'request'}, source: username}) | |||||
}) | |||||
} | |||||
function createConnection(target) { | |||||
const rpc = new RTCPeerConnection(rpcConfig) | |||||
rpc.onicecandidate = ({candidate}) => { | |||||
if(candidate && candidate.candidate) { | |||||
const value = {type: 'candidate', candidate} | |||||
wire({kind: 'rpc', value, target}) | |||||
} | |||||
} | |||||
rpc.ontrack = ({streams: [stream]}) => { | |||||
setStream({source: target, stream}) | |||||
} | |||||
rpc.onconnectionstatechange = () => { | |||||
if(rpc.connectionState === 'failed') { | |||||
console.log(target, 'failed, retry!') | |||||
wire({kind: 'rpc', value: {type: 'request'}, target}) | |||||
} | |||||
} | |||||
rpc.ondatachannel = ({channel}) => { | |||||
datachannels[target] = channel | |||||
datachannels[target].onmessage = ({data}) => console.log(data) | |||||
// for testing purposes | |||||
const msg = `rpc established from ${target} to ${State.username}` | |||||
datachannels[target].send(msg) | |||||
console.log(msg) | |||||
} | |||||
rpc.onnegotiationneeded = () => { | |||||
console.log('lmao renegotiate bi') | |||||
} | |||||
connections[target] = rpc | |||||
if(State.username > target) { | |||||
datachannels[target] = rpc.createDataChannel('test') | |||||
datachannels[target].onmessage = ({data}) => console.log(data) | |||||
signal({kind: 'rpc', value: {type: 'request'}, source: target}) | |||||
} | |||||
} | |||||
function setSenders(rpc, username) { | |||||
const element = videoElements[username] | |||||
const stream = element && element.srcObject | |||||
rpc.getSenders().forEach(se => rpc.removeTrack(se)) | |||||
if(stream) { | |||||
stream.getTracks().forEach(tr => rpc.addTrack(tr, stream)) | |||||
} | |||||
} | |||||
async function handlePeerInfo({source: target, value, kind}) { | |||||
const rpc = connections[target] | |||||
if(!rpc) { | |||||
return | |||||
} | |||||
if(value.type === 'request') { | |||||
const localOffer = await rpc.createOffer() | |||||
await rpc.setLocalDescription(localOffer) | |||||
wire({kind, value: localOffer, target}) | |||||
} | |||||
else if(value.type === 'offer') { | |||||
const remoteOffer = new RTCSessionDescription(value) | |||||
await rpc.setRemoteDescription(remoteOffer) | |||||
const localAnswer = await rpc.createAnswer() | |||||
await rpc.setLocalDescription(localAnswer) | |||||
wire({kind, value: localAnswer, target}) | |||||
} | |||||
else if(value.type === 'answer') { | |||||
const remoteAnswer = new RTCSessionDescription(value) | |||||
await rpc.setRemoteDescription(remoteAnswer).catch(e => e) | |||||
} | |||||
else if(value.type === 'candidate') { | |||||
const candidate = new RTCIceCandidate(value.candidate) | |||||
await rpc.addIceCandidate(candidate).catch(e => e) | |||||
} | |||||
} | |||||
function destroyConnection(username) { | |||||
if(datachannels[username]) { | |||||
datachannels[username].close() | |||||
delete datachannels[username] | |||||
} | |||||
if(connections[username]) { | |||||
connections[username].getReceivers().forEach(r => r.track.stop()) | |||||
connections[username].close() | |||||
delete connections[username] | |||||
} | |||||
} | |||||
function destroyAll() { | |||||
const people = new Set() | |||||
const collect = [connections, datachannels, streams, screen] | |||||
.map(collection => Object.keys(collection)) | |||||
.forEach(keys => keys.forEach(key => people.add(key))) | |||||
people.forEach(destroyConnection) | |||||
} | |||||
addEventListener('stream-refresh', reloadAllStreams) | |||||
addEventListener('stream', (e) => setStream(e.detail.value)) | |||||
addEventListener('rpc', (e) => handlePeerInfo(e.detail)) | |||||
addEventListener('join', (e) => createConnection(e.detail.value)) | |||||
addEventListener('leave', (e) => destroyConnection(e.detail.value)) | |||||
addEventListener('logout', () => destroyAll()) | |||||
addEventListener('load', () => doNotLog.add('rpc')) |
const ScreenShareConfig = { | const ScreenShareConfig = { | ||||
toggle() { | |||||
if(ScreenShare.isOn && ScreenShare.isStreaming) { | |||||
wire({kind: 'screen-sharing-stop'}) | |||||
view() { | |||||
const start = async () => { | |||||
await ScreenShare.getStream() | |||||
wire({kind: 'screen-share-start'}) | |||||
} | |||||
const stop = () => wire({kind: 'screen-share-stop'}) | |||||
if (ScreenShare.source === null) { | |||||
return m('button', {onclick: start}, 'start screen sharing') | |||||
} | |||||
else if (ScreenShare.isSelf) { | |||||
return m('button', {onclick: stop}, 'stop screen sharing') | |||||
} | } | ||||
else { | else { | ||||
ScreenShare.requestScreen() | |||||
return m('button[disabled]', `${ScreenShare.source} is screen sharing`) | |||||
} | } | ||||
}, | |||||
view() { | |||||
const checked = ScreenShare.isOn | |||||
const onclick = ScreenShareConfig.toggle | |||||
return m('label.styled', | |||||
m('input[type=checkbox]', {checked, onclick}), | |||||
'screen-share', | |||||
) | |||||
} | } | ||||
} | } | ||||
const ScreenShare = { | const ScreenShare = { | ||||
streamer: null, | |||||
stream: null, | |||||
get isStreaming() { | |||||
return ScreenShare.streamer === State.username | |||||
source: null, | |||||
stream: new MediaStream(), | |||||
get isSelf() { | |||||
return ScreenShare.source === State.username | |||||
}, | }, | ||||
get isOn() { | |||||
return !! ScreenShare.streamer | |||||
async getStream() { | |||||
const stream = await navigator.mediaDevices.getDisplayMedia({}) | |||||
stream.getTracks() | |||||
.forEach(tr => ScreenShare.stream.addTrack(tr, stream)) | |||||
}, | }, | ||||
async requestScreen() { | |||||
// ScreenShare.stream = await navigator.mediaDevices.getDisplayMedia() | |||||
// ScreenShare.streamer = State.username | |||||
// wire({kind: 'screen-sharing-start'}) | |||||
async stopStream() { | |||||
ScreenShare.stream.getTracks() | |||||
.forEach(tr => tr.stop()) | |||||
ScreenShare.stream = new MediaStream() | |||||
}, | }, | ||||
view() { | view() { | ||||
const style = { | const style = { | ||||
overflow: 'scroll', | overflow: 'scroll', | ||||
backgroundColor: 'black', | |||||
backgroundColor: 'gray', | |||||
color: 'white', | color: 'white', | ||||
fontFamily: 'monospace', | fontFamily: 'monospace', | ||||
position: 'relative', | |||||
} | } | ||||
return ScreenShare.isOn && m('.screen-share', {style}, | |||||
m('.streamer', `${ScreenShare.streamer}'s stream`), | |||||
m('video.screen[playsinline][autoplay]', {srcObject: ScreenShare.stream}), | |||||
return m('.screen-share', {style}, | |||||
ScreenShare.isSelf ? m('span', 'You are sharing your screen') : | |||||
ScreenShare.source ? m('video[playsinline][autoplay]', {srcObject: ScreenShare.stream}) | |||||
: [], | |||||
) | ) | ||||
}, | }, | ||||
} | } | ||||
addEventListener('screen-sharing-start', ({detail}) => { | |||||
ScreenShare.streamer = detail.source | |||||
function sendScreen(target, stream) { | |||||
signal({kind: 'screen-start', value: {target, stream}}) | |||||
} | |||||
addEventListener('screen-share-start', async ({detail: {source}}) => { | |||||
const isNew = ScreenShare.source === null | |||||
ScreenShare.source = source | |||||
if(isNew && ScreenShare.isSelf) { | |||||
State.others.forEach(user => sendScreen(user, ScreenShare.stream)) | |||||
} | |||||
}) | |||||
addEventListener('screen-share-stop', () => { | |||||
ScreenShare.stopStream() | |||||
ScreenShare.source = null | |||||
}) | }) | ||||
addEventListener('screen-sharing-stop', ({detail}) => { | |||||
if(ScreenShare.stream) { | |||||
ScreenShare.stream.getTracks().forEach(track => track.stop()) | |||||
ScreenShare.stream = null | |||||
addEventListener('join', ({detail: {value: user}}) => { | |||||
if(ScreenShare.isSelf) { | |||||
wire({kind: 'screen-share-start', target: user}) | |||||
sendScreen(user, ScreenShare.stream) | |||||
} | } | ||||
ScreenShare.streamer = null | |||||
}) | }) | ||||
addEventListener('load', () => { | addEventListener('load', () => { | ||||
doNotLog.add('screen-share-start') | doNotLog.add('screen-share-start') | ||||
Headers.push([ScreenShareConfig]) | Headers.push([ScreenShareConfig]) | ||||
Apps.push([ScreenShare, {key: 'screen-share-container'}]) | Apps.push([ScreenShare, {key: 'screen-share-container'}]) | ||||
}) | }) | ||||
setTimeout(ScreenShare.requestScreen, 200) |
} | } | ||||
} | } | ||||
const requestStream = async () => { | const requestStream = async () => { | ||||
if(StreamContainer.videoOn || StreamContainer.audioOn) { | |||||
await navigator.mediaDevices | |||||
.getUserMedia(VideoConfig) | |||||
.then(stream => | |||||
signal({kind: 'stream', value: {source: State.username, stream}}) | |||||
) | |||||
.catch(e => console.log(e)) | |||||
} | |||||
else { | |||||
await navigator.mediaDevices | |||||
.getUserMedia(VideoConfig) | |||||
.then(stream => | |||||
signal({kind: 'stream', value: {source: State.username, stream}}) | |||||
) | |||||
.catch(() => { | |||||
const stream = new MediaStream() | const stream = new MediaStream() | ||||
signal({kind: 'stream', value: {source: State.username, stream}}) | signal({kind: 'stream', value: {source: State.username, stream}}) | ||||
} | |||||
}) | |||||
} | } | ||||
const Video = { | const Video = { | ||||
setUp: (username) => ({dom}) => { | setUp: (username) => ({dom}) => { | ||||
dom.muted = true | dom.muted = true | ||||
requestStream() | requestStream() | ||||
} | } | ||||
videoElements[username] = dom | |||||
}, | |||||
tearDown: (username) => () => { | |||||
const stream = videoElements[username].srcObject | |||||
if(stream) stream.getTracks().forEach(tr => tr.stop()) | |||||
videoElements[username] = null | |||||
delete videoElements[username] | |||||
}, | }, | ||||
view({attrs: {username}}) { | view({attrs: {username}}) { | ||||
const styleOuter = { | const styleOuter = { | ||||
), | ), | ||||
m('video[playsinline][autoplay]', { | m('video[playsinline][autoplay]', { | ||||
style: styleVideo, | style: styleVideo, | ||||
srcObject: streams[username], | |||||
oncreate: this.setUp(username), | oncreate: this.setUp(username), | ||||
onremove: this.tearDown(username), | |||||
}), | }), | ||||
) | ) | ||||
}, | }, |
<link rel="stylesheet" href="/apps/chat.css"/> | <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/marked.min.js"></script> | ||||
<!-- <script src="/libs/purify.min.js"></script> --> | |||||
<script src="/libs/purify.min.js"></script> | |||||
<script src="/pico.js" defer></script> | <script src="/pico.js" defer></script> | ||||
<script src="/apps/rpc.js"></script> | <script src="/apps/rpc.js"></script> | ||||
<script src="/apps/streams.js"></script> | |||||
<!-- <script src="/apps/screen.js"></script> --> | |||||
<script src="/apps/screen.js"></script> | |||||
<!-- <script src="/apps/rpc.js"></script> --> | |||||
<!-- <script src="/apps/streams.js"></script> --> | |||||
<!-- <script src="/apps/chat.js"></script> --> | <!-- <script src="/apps/chat.js"></script> --> | ||||
<!-- <script src="/apps/volume.js"></script> --> | <!-- <script src="/apps/volume.js"></script> --> | ||||
</head> | </head> | ||||
justify-items: start; | justify-items: start; | ||||
margin-right: auto; | margin-right: auto; | ||||
} | } | ||||
video { | |||||
background-color: black; | |||||
} | |||||
main { | main { | ||||
overflow: hidden; | overflow: hidden; | ||||
display: grid; | display: grid; |
* | * | ||||
*/ | */ | ||||
addEventListener('resize', () => m.redraw()) | addEventListener('resize', () => m.redraw()) | ||||
// message: {kind, value, target?} | |||||
const wire = (message) => State.websocket.send(JSON.stringify(message)) | const wire = (message) => State.websocket.send(JSON.stringify(message)) | ||||
const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message})) | const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message})) | ||||
const listen = (kind, handler) => { | const listen = (kind, handler) => { | ||||
} | } | ||||
const Headers = [ | const Headers = [ | ||||
['button', {onclick: Base.sendLogout}, 'log-out'], | ['button', {onclick: Base.sendLogout}, 'log-out'], | ||||
// m('button', {onclick: VolumeMap.toggle}, 'volume'), | |||||
// m('button', {onclick: ScreenShare.toggle}, 'screen'), | |||||
] | ] | ||||
const Apps = [ | const Apps = [ | ||||
// m(Shadow, {key: 'map-shadow', app: VolumeMap}), | |||||
// m(Shadow, {key: 'screen-shadow', app: ScreenShare}), | |||||
] | ] | ||||
addEventListener('load', () => m.mount(document.body, Base)) | addEventListener('load', () => m.mount(document.body, Base)) |
else: | else: | ||||
value = data.get('value') | value = data.get('value') | ||||
if 'target' in data: | if 'target' in data: | ||||
recipients = {username, data['target']} | |||||
targets = {v for k, v in room.items() if k in recipients} | |||||
targets = {v for k, v in room.items() if k == data['target']} | |||||
await broadcast(source=username, value=value, targets=targets) | await broadcast(source=username, value=value, targets=targets) | ||||
else: | else: | ||||
await broadcast(source=username, value=value) | await broadcast(source=username, value=value) |
- {'kind': 'state', 'online': ['Norman', 'Ray']} | - {'kind': 'state', 'online': ['Norman', 'Ray']} | ||||
- {'kind': 'state', 'online': ['Norman', 'Ray', 'Emma']} | - {'kind': 'state', 'online': ['Norman', 'Ray', 'Emma']} | ||||
+ {'kind': 'post', 'value': '1', 'target': 'Ray'} | + {'kind': 'post', 'value': '1', 'target': 'Ray'} | ||||
- {'kind': 'post', 'value': '1', 'source': 'Norman'} | |||||
- {'kind': 'post', 'value': '3', 'source': 'Emma'} | - {'kind': 'post', 'value': '3', 'source': 'Emma'} | ||||
) | ) | ||||
client2 = _make_client('ws://localhost:8642/x', 0.11, Script() | client2 = _make_client('ws://localhost:8642/x', 0.11, Script() | ||||
- {'kind': 'state', 'online': ['Norman', 'Ray', 'Emma']} | - {'kind': 'state', 'online': ['Norman', 'Ray', 'Emma']} | ||||
- {'kind': 'post', 'value': '1', 'source': 'Norman'} | - {'kind': 'post', 'value': '1', 'source': 'Norman'} | ||||
+ {'kind': 'post', 'value': '2', 'target': 'Emma'} | + {'kind': 'post', 'value': '2', 'target': 'Emma'} | ||||
- {'kind': 'post', 'value': '2', 'source': 'Ray'} | |||||
- {'kind': 'state', 'online': ['Ray', 'Emma']} | - {'kind': 'state', 'online': ['Ray', 'Emma']} | ||||
) | ) | ||||
client3 = _make_client('ws://localhost:8642/x', 0.12, Script() | client3 = _make_client('ws://localhost:8642/x', 0.12, Script() | ||||
- {'kind': 'state', 'online': ['Norman', 'Ray', 'Emma']} | - {'kind': 'state', 'online': ['Norman', 'Ray', 'Emma']} | ||||
- {'kind': 'post', 'value': '2', 'source': 'Ray'} | - {'kind': 'post', 'value': '2', 'source': 'Ray'} | ||||
+ {'kind': 'post', 'value': '3', 'target': 'Norman'} | + {'kind': 'post', 'value': '3', 'target': 'Norman'} | ||||
- {'kind': 'post', 'value': '3', 'source': 'Emma'} | |||||
- {'kind': 'state', 'online': ['Ray', 'Emma']} | - {'kind': 'state', 'online': ['Ray', 'Emma']} | ||||
- {'kind': 'state', 'online': ['Emma']} | - {'kind': 'state', 'online': ['Emma']} | ||||
) | ) |