diff --git a/.gitignore b/.gitignore index 7c7c21c9551b5c02572f460fd69a8a70d511e72d..2966cf9287994282f90bb34f9b9e3b3b0eebd030 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,7 @@ typings/ # react / gatsby (customised) public/js/app.js +public/js/queue.js # vuepress build output .vuepress/dist @@ -163,4 +164,7 @@ tags # Ignore all local history of files .history +# Jupyter checkpoints +**/.ipynb_checkpoints + # End of https://www.gitignore.io/api/vim,node,macos,visualstudiocode diff --git a/package-lock.json b/package-lock.json index 6e9d29fb5a58712a7a6764d441c07aac4809a737..3c2dd2db0e0006c37baa783f35ef75c0ba28d875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4938,7 +4938,6 @@ "filetransfer": "^2.0.4", "hark": "^1.2.0", "mockconsole": "0.0.1", - "pako": "^1.0.10", "rtcpeerconnection": "file:src/rtcpeerconnection", "socket.io-client": "^2.3.0", "webrtc-adapter": "^7.3.0", diff --git a/package.json b/package.json index 79966736740510c71a3274e2775e03d1d36ef7ff..7ba1899f33eb7f487e3977d7780476ac98985855 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,11 @@ "dotenv": "^8.2.0", "express": "^4.17.1", "liowebrtc": "file:src/liowebrtc", + "pako": "^1.0.10", "signalbuddy": "file:src/signalbuddy", "uuid": "^3.3.3", "webrtc-adapter": "^7.3.0", + "what-the-pack": "^2.0.3", "y-array": "^10.1.4", "y-map": "^10.1.3", "y-memory": "^8.0.9", diff --git a/public/index.html b/public/index.html index 9191814f9bd0b8f0ade96e56baef9c6bb21f5e74..2786830805f63ca7436be14656a952ed34ce5496 100644 --- a/public/index.html +++ b/public/index.html @@ -74,11 +74,29 @@ <div id="brush-colour"> <h3>Brush colour</h3> </div> - - <a href="#"> + <a href="#" style="margin-left: 1%"> <div id="rectangle"></div> </a> </div> + <div class="pen-body"> + <div id="brush-colour"> + <h3>Brush size</h3> + </div> + + <div class="slide-size"> + <input + type="range" + min="1" + max="100" + value="10" + class="slider" + id="range" + /> + <p style="text-align: center; margin: 0px"> + Size: <span id="value"></span> + </p> + </div> + </div> </div> </div> <div id="palette" class="properties" style="padding-top: 150px"> @@ -92,6 +110,7 @@ viewBox="0 10 100 70" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" + style="@media only screen and (max-width: 600px) { viewBox: -50 10 200 100 }" > <defs> <path @@ -171,7 +190,7 @@ </svg> <div id="others"> <div id="other-palette"> - <b>Others</b> + <b>Other colours</b> </div> <label id="colours"> <input id="other-colours" type="color" value="blue" /> diff --git a/public/service-worker.js b/public/service-worker.js index 5871487b5fe4ffd94ed873635e38ec3d665b8439..971cfcd807b0b91b549b816f7b290d37174a8c4e 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -12,6 +12,7 @@ const FILES_TO_CACHE = [ "/styles.css", "/cursor.svg", "/js/app.js", + "/js/queue.js", "/logo.png", "/manifest.json", "/assets/fonts/martel-v4-latin/martel-v4-latin-regular.eot", diff --git a/public/styles.css b/public/styles.css index 96bac6340bc8844e65b9df056b1f11871be2e7f0..b8f3cc401e328a0a7e53753024afd5b171e7965c 100644 --- a/public/styles.css +++ b/public/styles.css @@ -361,11 +361,85 @@ button.selected { text-align: center; } +.slide-size { + width: 90%; + height: 20%; + margin-left: 2.2%; +} + +.slider { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 25px; + background: #d3d3d3; + outline: none; + opacity: 0.7; + -webkit-transition: 0.2s; + transition: opacity 0.2s; + margin-top: 30px; +} + +.slider:hover { + opacity: 1; +} + +.slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 25px; + height: 25px; + background: #4f4f4f; + cursor: pointer; +} + +.slider::-moz-range-thumb { + width: 25px; + height: 25px; + background: #4f4f4f; + cursor: pointer; +} + +@media only screen and (max-width: 600px) { + .properties { + display: none; + position: fixed; + z-index: 1; + padding-top: 80px; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0, 0, 0); + background-color: rgba(0, 0, 0, 0.4); + } + + .palette-selector { + position: relative; + background-color: #fefefe; + margin: auto; + padding: 0; + border: 1px solid #888; + width: 80%; + height: 80%; + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.1); + font-family: "Martel", serif; + } + + .wheel-header { + padding: 2px 16px; + background-color: #2f2f2f; + color: white; + text-align: center; + } +} + #others { margin-top: 2%; display: flex; align-items: center; - padding-left: 40%; + justify-content: center; background-color: #3cbc8d; color: white; padding-top: 4px; @@ -393,8 +467,20 @@ button.selected { transition-duration: 0.4s; } -#other-colours { - visibility: hidden; +@supports (-webkit-overflow-scrolling: touch) { + #colours { + visibility: hidden; + } + + #other-colours { + visibility: visible; + } +} + +@supports not (-webkit-overflow-scrolling: touch) { + #other-colours { + visibility: hidden; + } } @font-face { diff --git a/src/app.js b/src/app.js index d40a51b977fc8a95f864b48857a63e3f8f21f21b..d909baf29730517c6984afad5422d5f6f1540f03 100644 --- a/src/app.js +++ b/src/app.js @@ -55,30 +55,32 @@ const onRoomConnect = (room_) => { HTML.connectedPeers.innerHTML = "" } - insertHTMLPeerElement(id) + getOrInsertPeerById(id) updateOverallStatusIcon() }) room.addEventListener("userLeave", ({ detail: id }) => { - HTML.connectedPeers.removeChild(getPeerById(id)) + HTML.connectedPeers.removeChild(getOrInsertPeerById(id)) if (HTML.connectedPeers.children.length == 0) { HTML.connectedPeers.innerHTML = "No peers are connected" } + + updateOverallStatusIcon() }) room.addEventListener("weSyncedWithPeer", ({ detail: id }) => { - getPeerById(id).children[1].className = "peer-status synced" + getOrInsertPeerById(id).children[1].className = "peer-status synced" updateOverallStatusIcon() }) room.addEventListener("waitingForSyncStep", ({ detail: id }) => { - getPeerById(id).children[2].className = "peer-status negotiating" + getOrInsertPeerById(id).children[2].className = "peer-status negotiating" updateOverallStatusIcon() }) room.addEventListener("peerSyncedWithUs", ({ detail: id }) => { - getPeerById(id).children[2].className = "peer-status synced" + getOrInsertPeerById(id).children[2].className = "peer-status synced" updateOverallStatusIcon() }) @@ -146,7 +148,7 @@ for (let i = 1; i < svg.length; i++) { const paletteColour = event.target.getAttribute("fill") HTML.rectangle.style.backgroundColor = paletteColour HTML.picker.value = paletteColour - HTML.labelColour.style.backgroundColor = paletteColour + HTML.labelColours.style.backgroundColor = paletteColour canvas.setStrokeColour(paletteColour) hideElement(HTML.palette) }) @@ -163,7 +165,33 @@ function hideElement(element) { HTML.picker.addEventListener("change", () => { const paletteColour = event.target.value HTML.rectangle.style.backgroundColor = paletteColour - HTML.labelColour.style.backgroundColor = paletteColour + HTML.labelColours.style.backgroundColor = paletteColour + canvas.setStrokeColour(paletteColour) +}) + +HTML.output.innerHTML = HTML.slider.value + +HTML.slider.oninput = function() { + HTML.output.innerHTML = this.value + canvas.setStrokeRadius(this.value / 10) +} + +const x = window.matchMedia( + "only screen and (orientation: landscape) and (max-width: 600px)", +) +x.addListener(() => { + if (x.matches) { + HTML.wheel.setAttribute("viewBox", "-50 10 200 100") + HTML.palette.setAttribute("style", "padding-top: 50px") + } else { + HTML.wheel.setAttribute("viewBox", "0 10 100 100") + } +}) + +HTML.picker.addEventListener("change", () => { + const paletteColour = event.target.value + HTML.rectangle.style.backgroundColor = paletteColour + HTML.labelColours.style.backgroundColor = paletteColour canvas.setStrokeColour(paletteColour) }) @@ -182,7 +210,7 @@ HTML.peerButton.addEventListener("click", () => { HTML.peerIDElem.value = "" }) -HTML.roomConnectButton.addEventListener("click", () => { +const onRoomJoinEnter = () => { const selectedRoomID = HTML.roomIDElem.value if (!selectedRoomID || selectedRoomID == room.name) { return @@ -197,9 +225,26 @@ HTML.roomConnectButton.addEventListener("click", () => { HTML.connectedPeers.innerHTML = "No peers are connected" tryRoomConnect(selectedRoomID) +} + +HTML.roomConnectButton.addEventListener("click", onRoomJoinEnter) + +HTML.roomIDElem.addEventListener("keydown", (event) => { + if (event.key == "Enter") { + event.target.blur() + + onRoomJoinEnter() + } }) -const insertHTMLPeerElement = (id) => { +const getOrInsertPeerById = (id) => { + for (const peerElem of HTML.connectedPeers.children) { + const peerId = peerElem.children[0].innerHTML + if (peerId == id) { + return peerElem + } + } + const peerElem = document.createElement("li") const peerId = document.createElement("div") peerId.style.display = "inline" @@ -216,15 +261,8 @@ const insertHTMLPeerElement = (id) => { peerElem.appendChild(theirStatus) HTML.connectedPeers.appendChild(peerElem) -} -const getPeerById = (id) => { - for (const peerElem of HTML.connectedPeers.children) { - const peerId = peerElem.children[0].innerHTML - if (peerId == id) { - return peerElem - } - } + return peerElem } const updateOverallStatusIcon = () => { @@ -252,7 +290,11 @@ canvas.input.addEventListener("strokestart", ({ detail: e }) => { if (currentTool == tools.PEN) { pathIDsByPointerID.set( e.pointerId, - room.addPath([...mousePos, e.pressure, canvas.getStrokeColour()]), + room.addPath([ + ...mousePos, + canvas.getStrokeRadius(e.pressure), + canvas.getStrokeColour(), + ]), ) } else if (currentTool == tools.ERASER) { eraseEverythingAtPosition(mousePos[0], mousePos[1], ERASER_RADIUS, room) @@ -273,7 +315,7 @@ canvas.input.addEventListener("strokemove", ({ detail: e }) => { if (currentTool == tools.PEN) { room.extendPath(pathIDsByPointerID.get(e.pointerId), [ ...mousePos, - e.pressure, + canvas.getStrokeRadius(e.pressure), canvas.getStrokeColour(), ]) } else if (currentTool == tools.ERASER) { diff --git a/src/canvas.js b/src/canvas.js index e39bf0e2acec85e0a4c9f6609a01a18ba1730f42..2173bc72b9698e4a39e0007ac90e0f3c80fc1802 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -18,17 +18,18 @@ const smoothLine = line() const pathGroupElems = new Map() -let stroke_colour = "blue" -export const MIN_STROKE_RADIUS = 0.1 -export const MAX_STROKE_RADIUS = 3.9 +let strokeColour = "blue" +let strokeRadius = 1 +export const MIN_PRESSURE = 0.1 +export const MAX_PRESSURE = 1.0 const MAX_POINT_DISTANCE = 5 -const MAX_PRESSURE_DELTA = 0.05 +const MAX_RADIUS_DELTA = 0.05 // Interpolate a path so that: // - The distance between two adjacent points is capped at MAX_POINT_DISTANCE. // - The pressure delta between two adjacent points is capped at -// MAX_PRESSURE_DELTA +// MAX_RADIUS_DELTA // If paths are too choppy, try decreasing these constants. const smoothPath = ([...path]) => { // Apply MAX_POINT_DISTANCE. @@ -52,12 +53,12 @@ const smoothPath = ([...path]) => { i += newPoints.length } - // Apply MAX_PRESSURE_DELTA. + // Apply MAX_RADIUS_DELTA. for (let i = 1; i < path.length; i++) { const dx = path[i][0] - path[i - 1][0] const dy = path[i][1] - path[i - 1][1] const dw = path[i][2] - path[i - 1][2] - const segmentsToSplit = Math.ceil(dw / MAX_PRESSURE_DELTA) + const segmentsToSplit = Math.ceil(dw / MAX_RADIUS_DELTA) const newPoints = [] for (let j = 1; j < segmentsToSplit; j++) { newPoints.push([ @@ -74,15 +75,11 @@ const smoothPath = ([...path]) => { return path } -const getStrokeRadius = (pressure) => { - return MIN_STROKE_RADIUS + pressure * (MAX_STROKE_RADIUS - MIN_STROKE_RADIUS) -} - export const input = new EventTarget() const createSvgElem = (tagName) => document.createElementNS(SVG_URL, tagName) -function erasurePoints(point0, point1, [start, fin]) { +const erasurePoints = (point0, point1, [start, fin]) => { if (start >= fin) return if (start <= 0) start = 0 if (fin >= 1) fin = 1 @@ -100,14 +97,14 @@ function erasurePoints(point0, point1, [start, fin]) { ]) } -function ensurePathGroupElem(id) { +const ensurePathGroupElem = (id) => { let groupElem = pathGroupElems.get(id) if (groupElem == null) { groupElem = createSvgElem("g") - groupElem.setAttribute("stroke", stroke_colour) groupElem.setAttribute("fill", "none") + groupElem.setAttribute("stroke-linecap", "round") groupElem.setAttribute("pointer-events", "none") HTML.canvas.appendChild(groupElem) @@ -118,33 +115,34 @@ function ensurePathGroupElem(id) { return groupElem } -function renderPoint(point, colour) { +const renderPoint = (point) => { const circleElem = createSvgElem("circle") circleElem.setAttribute("stroke", "none") - circleElem.setAttribute("fill", colour) + circleElem.setAttribute("fill", point[3]) circleElem.setAttribute("cx", point[0]) circleElem.setAttribute("cy", point[1]) - circleElem.setAttribute("r", getStrokeRadius(point[2])) + circleElem.setAttribute("r", point[2]) return circleElem } -function renderSubpath(subpath) { +const renderSubpath = (subpath) => { if (subpath.length == 1) { return renderPoint(subpath[0]) } const pathElem = createSvgElem("path") - pathElem.setAttribute("stroke-width", getStrokeRadius(subpath[0][2]) * 2) + pathElem.setAttribute("stroke", subpath[0][3]) + pathElem.setAttribute("stroke-width", subpath[0][2] * 2) pathElem.setAttribute("d", smoothLine(subpath)) return pathElem } -function isValidPoint(point) { +const isValidPoint = (point) => { return point != null && point[0] != null } -function getEraseIntervalsForPointInPath(intervals, pathID, pointID) { +const getEraseIntervalsForPointInPath = (intervals, pathID, pointID) => { if (!intervals) return undefined const eraseIntervalsForPath = intervals[pathID] if (!eraseIntervalsForPath) return undefined @@ -153,7 +151,7 @@ function getEraseIntervalsForPointInPath(intervals, pathID, pointID) { const POINT_ERASE_LIMIT = 0.0001 -function pointWasErased(eraseIntervals) { +const pointWasErased = (eraseIntervals) => { return ( eraseIntervals.length && eraseIntervals[0] && @@ -161,7 +159,7 @@ function pointWasErased(eraseIntervals) { ) } -function needToDrawLastPoint(points, pathID, eraseIntervals) { +const needToDrawLastPoint = (points, pathID, eraseIntervals) => { if (points.length < 2) return true const penultimatePointIndex = points.length - 2 const penPointEraseIntervals = getEraseIntervalsForPointInPath( @@ -182,10 +180,11 @@ function needToDrawLastPoint(points, pathID, eraseIntervals) { return true } -function applyErasureIntervals(pathID, points, erasureIntervals) { +const applyErasureIntervals = (pathID, points, erasureIntervals) => { if (points.length == 0) { return [] } + const subpaths = [] let subpath = [] @@ -271,7 +270,7 @@ export const clear = () => { } // Necessary since buttons property is non standard on iOS versions < 13.2 -function isValidPointerEvent(e) { +const isValidPointerEvent = (e) => { return e.buttons & 1 || e.pointerType === "touch" } @@ -289,10 +288,22 @@ canvas.addEventListener("pointerleave", dispatchPointerEvent("strokeend")) canvas.addEventListener("pointermove", dispatchPointerEvent("strokemove")) canvas.addEventListener("touchmove", (e) => e.preventDefault()) -export function setStrokeColour(colour) { - stroke_colour = colour +export const setStrokeColour = (colour) => { + strokeColour = colour +} + +export const getStrokeColour = () => { + return strokeColour +} + +export const setStrokeRadius = (radius) => { + strokeRadius = radius +} + +const calculateStrokeRadius = (pressure, radius) => { + return radius * (MIN_PRESSURE + pressure * (MAX_PRESSURE - MIN_PRESSURE)) } -export function getStrokeColour() { - return stroke_colour +export const getStrokeRadius = (pressure) => { + return calculateStrokeRadius(pressure, strokeRadius) } diff --git a/src/elements.js b/src/elements.js index 9bab250a95996d204eed8bc8bc4ec8db6511d3e7..cf472b7cb559dd0b80284f3f8b1e6ddac9735a30 100644 --- a/src/elements.js +++ b/src/elements.js @@ -26,4 +26,6 @@ export const palette = document.getElementById("palette") export const rectangle = document.getElementById("rectangle") export const wheel = document.getElementById("wheel") export const picker = document.getElementById("other-colours") -export const labelColour = document.getElementById("colours") +export const slider = document.getElementById("range") +export const output = document.getElementById("value") +export const labelColours = document.getElementById("colours") diff --git a/src/liowebrtc b/src/liowebrtc index 9b066b28a56a902b7ba0fe1532508bf28f269604..ce4a2ebe160804ed84f7b6fc3bd10c91e766bdcd 160000 --- a/src/liowebrtc +++ b/src/liowebrtc @@ -1 +1 @@ -Subproject commit 9b066b28a56a902b7ba0fe1532508bf28f269604 +Subproject commit ce4a2ebe160804ed84f7b6fc3bd10c91e766bdcd diff --git a/src/queue.js b/src/queue.js new file mode 100644 index 0000000000000000000000000000000000000000..6aeed4438621d58337dab675e90ae85bef79c7a5 --- /dev/null +++ b/src/queue.js @@ -0,0 +1,93 @@ +"use strict" + +import MessagePack from "what-the-pack" +import pako from "pako" +import uuidv4 from "uuid/v4" + +const MESSAGE_BUFFER_SIZE = 2 ** 24 // 16MB +const MESSAGE_SLICE_SIZE = 2 ** 10 // 1KB + +const { encode, decode } = MessagePack.initialize(MESSAGE_BUFFER_SIZE) + +const buffer = {} + +self.onmessage = (event) => { + if (!event || !event.data) { + return + } + + if (event.data.method == "send" || event.data.method == "broadcast") { + let message = event.data.message + const compressed = typeof message == "object" + const uuid = uuidv4() + + message = encode(message) + + if (compressed) { + message = pako.deflate(message) + } + + for ( + let offset = 0; + offset < message.length; + offset += MESSAGE_SLICE_SIZE + ) { + event.data.message = { + uuid, + message: message.subarray(offset, offset + MESSAGE_SLICE_SIZE), + slice: offset / MESSAGE_SLICE_SIZE, + length: Math.ceil(message.length / MESSAGE_SLICE_SIZE), + compressed, + } + + self.postMessage(event.data) + } + } else if (event.data.method == "received") { + let message = event.data.message.message + + if (event.data.message.length > 1) { + let messages = buffer[event.data.peer.id] + if (!messages) { + messages = {} + buffer[event.data.peer.id] = messages + } + + let slices = messages[event.data.message.uuid] + if (!slices) { + slices = [] + messages[event.data.message.uuid] = slices + } + + slices.push(event.data.message) + + if (slices.length < slices[slices.length - 1].length) { + return + } + + message = new Uint8Array( + slices.reduce((acc, s) => acc + s.message.length, 0), + ) + + slices.sort((a, b) => a.slice - b.slice) + + let offset = 0 + + for (const slice of slices) { + message.set(slice.message, offset) + offset += slice.message.length + } + + delete messages[event.data.message.uuid] + } + + if (event.data.message.compressed) { + message = pako.inflate(message) + } + + message = decode(MessagePack.Buffer.from(message)) + + event.data.message = message + + self.postMessage(event.data) + } +} diff --git a/src/room.js b/src/room.js index 441e635e35ed10a8529aa5e306321c49530855c3..8f73fcf53732e9f32a8aee911791f8b109e6ee64 100644 --- a/src/room.js +++ b/src/room.js @@ -110,9 +110,23 @@ class Room extends EventTarget { name: "webrtc", url: "/", room: this.name, + handshake: { + initial: 100, + interval: 500, + }, + heartbeat: { + interval: 500, + minimum: 1000, + timeout: 10000, + }, onUserEvent: (event) => { - if (event.action == "userID") { - const { id } = event + if (event.action == "userConnection") { + const { quality } = event + this.dispatchEvent( + new CustomEvent("userConnection", { detail: quality }), + ) + } else if (event.action == "userID") { + const { user: id } = event this.ownID = id this.dispatchEvent(new CustomEvent("allocateOwnID", { detail: id })) } else if (event.action == "userJoined") { diff --git a/src/y-webrtc/index.js b/src/y-webrtc/index.js index 2324e71cfb00b54c5b42c9bc83b7b04679eddca2..ba19f1032078541c92eb502ba87b81012f637b11 100644 --- a/src/y-webrtc/index.js +++ b/src/y-webrtc/index.js @@ -14,6 +14,73 @@ function extend(Y) { super(y, options) this.webrtcOptions = options + this.webrtcOptions.handshake = this.webrtcOptions.handshake || {} + this.webrtcOptions.handshake.initial = + this.webrtcOptions.handshake.initial || 100 + this.webrtcOptions.handshake.interval = + this.webrtcOptions.handshake.interval || 500 + + this.webrtcOptions.heartbeat = this.webrtcOptions.heartbeat || {} + this.webrtcOptions.heartbeat.interval = + this.webrtcOptions.heartbeat.interval || 500 + this.webrtcOptions.heartbeat.minimum = + this.webrtcOptions.heartbeat.minimum || 1000 + this.webrtcOptions.heartbeat.timeout = + this.webrtcOptions.heartbeat.timeout || 10000 + + this.queue = new Worker("js/queue.js") + this.queue.onmessage = (event) => { + const method = event.data.method + + if (method == "send") { + const { uid, channel, message } = event.data + + // y-js db transactions can send messages after a peer has disconnected + if ( + (channel == "y-js" && !this.peers.has(uid)) || + !this.webrtc.getPeerById(uid) + ) { + return + } + + this.webrtc.whisper(this.webrtc.getPeerById(uid), channel, message) + } else if (method == "broadcast") { + const { channel, message } = event.data + + return this.webrtc.shout(channel, message) + } else if (method == "received") { + const { type, message, peer } = event.data + + if (type === "tw-ml") { + // Handshakes can only be sent and received directly + if (message === "tw") { + // Response message in the handshake + this.queue.postMessage({ + method: "send", + uid: peer.id, + channel: "tw-ml", + message: "ml", + }) + } else if (message == "ml") { + // Handshake completed + this.checkAndInsertPeer(peer.id) + } + } else { + this.checkAndInsertPeer(peer.id) + + if (type === "y-js") { + this.checkAndInsertPeer(peer.id) + + if (message.type === "sync done") { + this.raiseUserEvent("peerSyncedWithUs", { user: peer.id }) + } + + this.receiveMessage(peer.id, message) + } + } + } + } + if (options.onUserEvent) { this.onUserEvent(options.onUserEvent) } @@ -28,13 +95,13 @@ function extend(Y) { this.webrtc = new LioWebRTC({ url: this.webrtcOptions.url, dataOnly: true, - /*network: { + constraints: { minPeers: 4, maxPeers: 8, - },*/ + }, }) - this.peers = new Set() + this.peers = new Map() this.webrtc.on("ready", () => { this.webrtc.joinRoom(this.webrtcOptions.room) @@ -45,9 +112,13 @@ function extend(Y) { }) this.webrtc.on("leftRoom", () => { - console.log("LEFT ROOM") + console.log("TODO: LEFT ROOM") }) + this.webrtc.on("channelError", (a, b, c, d) => + console.log("TODO: CHANNEL ERROR", a, b, c, d), + ) + this.webrtc.on("channelOpen", (dataChannel, peer) => { this.checkAndEnsureUser() @@ -63,40 +134,36 @@ function extend(Y) { return } - console.log("ping", peer.id) - // Initial message in the handshake - this.webrtc.whisper(peer, "tw-ml", "tw") - - setTimeout(handshake.bind(this, peer), 500) + this.queue.postMessage({ + method: "send", + uid: peer.id, + channel: "tw-ml", + message: "tw", + }) + + setTimeout( + handshake.bind(this, peer), + this.webrtcOptions.handshake.interval, + ) } - setTimeout(handshake.bind(this, peer), 100) + setTimeout( + handshake.bind(this, peer), + this.webrtcOptions.handshake.initial, + ) }) this.webrtc.on("receivedPeerData", (type, message, peer) => { this.checkAndEnsureUser() - if (message.type !== "update") { - console.log("receivedData", peer.id, message) - } - - if (message.type === "sync done") { - this.raiseUserEvent("peerSyncedWithUs", peer.id) - } - - if (type === "y-js") { - this.checkAndInsertPeer(peer.id) - this.receiveMessage(peer.id, message) - } else if (type === "tw-ml") { - if (message === "tw") { - // Response message in the handshake - this.webrtc.whisper(peer, "tw-ml", "ml") - } else if (message == "ml") { - // Handshake completed - this.checkAndInsertPeer(peer.id) - } - } + // Message could have been forwarded but yjs only needs to know about directly connected peers + this.queue.postMessage({ + method: "received", + type, + message, + peer: { id: peer.forwardedBy ? peer.forwardedBy.id : peer.id }, + }) }) this.webrtc.on("channelClose", (dataChannel, peer) => { @@ -113,7 +180,7 @@ function extend(Y) { return } - this.raiseUserEvent("userID", id) + this.raiseUserEvent("userID", { user: id }) this.setUserId(id) } @@ -124,13 +191,91 @@ function extend(Y) { return } - this.peers.add(uid) + const health = { + lastStatsResolved: true, + lastReceivedBytes: 0, + lastReceivedTimestamp: Date.now(), + } + health.cb = setInterval( + this.heartbeat.bind(this, this.webrtc.getPeerById(uid), health), + this.webrtcOptions.heartbeat.interval, + ) - console.log("createdPeer", uid) + this.peers.set(uid, health) this.userJoined(uid, "master") } + heartbeat(peer, health) { + const _peer = this.webrtc.getPeerById(peer.id) + + if (!_peer || _peer !== peer || !this.peers.has(peer.id)) { + clearInterval(health.cb) + + return + } + + if (!health.lastStatsResolved) { + return peer.end(true) + } + health.lastStatsResolved = false + + const self = this + + peer.getStats(null).then((stats) => { + health.lastStatsResolved = true + + let disconnect = true + + stats.forEach((report) => { + if ( + report.type == "candidate-pair" && + report.bytesSent > 0 && + report.bytesReceived > 0 && + report.writable + ) { + const timeSinceLastReceived = + Date.now() - health.lastReceivedTimestamp + + if (report.bytesReceived != health.lastReceivedBytes) { + health.lastReceivedBytes = report.bytesReceived + health.lastReceivedTimestamp = Date.now() + } else if ( + timeSinceLastReceived > self.webrtcOptions.heartbeat.timeout + ) { + return + } else if ( + timeSinceLastReceived > self.webrtcOptions.heartbeat.interval + ) { + self.queue.postMessage({ + method: "send", + uid: peer.id, + channel: "heartbeat", + }) + } + + this.raiseUserEvent("userConnection", { + quality: + 1.0 - + (self.webrtcOptions.heartbeat.timeout - + Math.max( + timeSinceLastReceived, + self.webrtcOptions.heartbeat.minimum, + )) / + (self.webrtcOptions.heartbeat.timeout - + self.webrtcOptions.heartbeat.minimum), + }) + + disconnect = false + } + }) + + if (disconnect) { + peer.end(true) + } + }) + } + // Ensure that y-js knows that the peer has left checkAndRemovePeer(uid) { if (!this.peers.has(uid)) { @@ -139,8 +284,6 @@ function extend(Y) { this.peers.delete(uid) - console.log("removedPeer", uid) - this.userLeft(uid) } @@ -149,6 +292,8 @@ function extend(Y) { } disconnect() { + this.queue.terminate() + this.webrtc.quit() super.disconnect() @@ -160,33 +305,26 @@ function extend(Y) { super.reconnect() } - raiseUserEvent(action, user_id) { + raiseUserEvent(action, data) { + const event = Object.assign({ action }, data) + for (const f of this.userEventListeners) { - f({ action: action, user: user_id }) + f(event) } } send(uid, message) { - // y-js db transactions can send messages after a peer has disconnected - if (!this.peers.has(uid) || !this.webrtc.getPeerById(uid)) { - return - } - - console.log("send", uid, message) - if (message.type === "sync step 1") { - this.raiseUserEvent("waitingForSyncStep", uid) + this.raiseUserEvent("waitingForSyncStep", { user: uid }) } else if (message.type === "sync done") { - this.raiseUserEvent("weSyncedWithPeer", uid) + this.raiseUserEvent("weSyncedWithPeer", { user: uid }) } - this.webrtc.whisper(this.webrtc.getPeerById(uid), "y-js", message) + this.queue.postMessage({ method: "send", channel: "y-js", uid, message }) } broadcast(message) { - if (message.type !== "update") console.log("broadcast", message) - - this.webrtc.shout("y-js", message) + this.queue.postMessage({ method: "broadcast", channel: "y-js", message }) } isDisconnected() { diff --git a/src/yjs b/src/yjs index ba068ea6a62d6a124047fd3dd0075adecd5d149a..c2a07807dfaa1e5e6b667d83aa4a7d348c759c0f 160000 --- a/src/yjs +++ b/src/yjs @@ -1 +1 @@ -Subproject commit ba068ea6a62d6a124047fd3dd0075adecd5d149a +Subproject commit c2a07807dfaa1e5e6b667d83aa4a7d348c759c0f diff --git a/webpack.common.js b/webpack.common.js index 1c8c6567d4a91292e26fcb3ec441332eaf9dad8e..bcb271eee9f39438cb631d914c77bb6a9f118728 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -3,9 +3,12 @@ const webpack = require("webpack") module.exports = { mode: "development", - entry: "./src/app.js", + entry: { + app: "./src/app.js", + queue: "./src/queue.js", + }, output: { - filename: "app.js", + filename: "[name].js", path: path.resolve(__dirname, "public/js"), }, plugins: [