Kaynağa Gözat

update galene code

Dele Olajide 2 yıl önce
ebeveyn
işleme
a708333c12

+ 1 - 1
index.html

@@ -102,9 +102,9 @@
         auto_away: 300,
         auto_away: 300,
         auto_reconnect: true,
         auto_reconnect: true,
         discover_connection_methods: false,
         discover_connection_methods: false,
-        bosh_service_url: 'https://conversejs.org/http-bind/',
         view_mode: 'fullscreen',
         view_mode: 'fullscreen',
         loglevel: 'debug',
         loglevel: 'debug',
+		galene_host: 'localhost',
         whitelisted_plugins: [
         whitelisted_plugins: [
 			"download-dialog", 
 			"download-dialog", 
 			"stickers", 
 			"stickers", 

+ 0 - 0
packages/galene/converse-galene.js


+ 210 - 549
packages/galene/galene-ui.js

@@ -41,6 +41,7 @@ let token = null;
  * @property {string} [send]
  * @property {string} [send]
  * @property {string} [request]
  * @property {string} [request]
  * @property {boolean} [activityDetection]
  * @property {boolean} [activityDetection]
+ * @property {boolean} [displayAll]
  * @property {Array.<number>} [resolution]
  * @property {Array.<number>} [resolution]
  * @property {boolean} [mirrorView]
  * @property {boolean} [mirrorView]
  * @property {boolean} [blackboardMode]
  * @property {boolean} [blackboardMode]
@@ -219,6 +220,13 @@ function reflectSettings() {
         store = true;
         store = true;
     }
     }
 
 
+    if(settings.hasOwnProperty('displayAll')) {
+        getInputElement('displayallbox').checked = settings.displayAll;
+    } else {
+        settings.displayAll = getInputElement('displayallbox').checked;
+        store = true;
+    }
+
     if(settings.hasOwnProperty('preprocessing')) {
     if(settings.hasOwnProperty('preprocessing')) {
         getInputElement('preprocessingbox').checked = settings.preprocessing;
         getInputElement('preprocessingbox').checked = settings.preprocessing;
     } else {
     } else {
@@ -351,8 +359,6 @@ function gotClose(code, reason) {
     if(code != 1000) {
     if(code != 1000) {
         console.warn('Socket close', code, reason);
         console.warn('Socket close', code, reason);
     }
     }
-	
-	closeConnection();
 }
 }
 
 
 /**
 /**
@@ -369,7 +375,7 @@ function gotDownStream(c) {
         displayError(e);
         displayError(e);
     };
     };
     c.ondowntrack = function(track, transceiver, label, stream) {
     c.ondowntrack = function(track, transceiver, label, stream) {
-        setMedia(c, false);
+        setMedia(c);
     };
     };
     c.onnegotiationcompleted = function() {
     c.onnegotiationcompleted = function() {
         resetMedia(c);
         resetMedia(c);
@@ -381,7 +387,7 @@ function gotDownStream(c) {
     if(getSettings().activityDetection)
     if(getSettings().activityDetection)
         c.setStatsInterval(activityDetectionInterval);
         c.setStatsInterval(activityDetectionInterval);
 
 
-    setMedia(c, false);
+    setMedia(c);
 }
 }
 
 
 // Store current browser viewport height in css variable
 // Store current browser viewport height in css variable
@@ -394,6 +400,33 @@ function setViewportHeight() {
     resizePeers();
     resizePeers();
 }
 }
 
 
+// On resize and orientation change, we update viewport height
+addEventListener('resize', setViewportHeight);
+addEventListener('orientationchange', setViewportHeight);
+
+getButtonElement('presentbutton').onclick = async function(e) {
+    e.preventDefault();
+    let button = this;
+    if(!(button instanceof HTMLButtonElement))
+        throw new Error('Unexpected type for this.');
+    // there's a potential race condition here: the user might click the
+    // button a second time before the stream is set up and the button hidden.
+    button.disabled = true;
+    try {
+        let id = findUpMedia('camera');
+        if(!id)
+            await addLocalMedia();
+    } finally {
+        button.disabled = false;
+    }
+};
+
+getButtonElement('unpresentbutton').onclick = function(e) {
+    e.preventDefault();
+    closeUpMedia('camera');
+    resizePeers();
+};
+
 /**
 /**
  * @param {string} id
  * @param {string} id
  * @param {boolean} visible
  * @param {boolean} visible
@@ -514,14 +547,6 @@ document.getElementById('sharebutton').onclick = function(e) {
     addShareMedia();
     addShareMedia();
 };
 };
 
 
-document.getElementById('closebutton').onclick = async function(e) {
-    e.preventDefault();
-	console.debug("closebutton - click");
-	
-	await serverConnection.leave(group);
-	closeConnection();
-};
-
 getSelectElement('filterselect').onchange = async function(e) {
 getSelectElement('filterselect').onchange = async function(e) {
     if(!(this instanceof HTMLSelectElement))
     if(!(this instanceof HTMLSelectElement))
         throw new Error('Unexpected type for this');
         throw new Error('Unexpected type for this');
@@ -642,6 +667,18 @@ getInputElement('activitybox').onchange = function(e) {
     }
     }
 };
 };
 
 
+getInputElement('displayallbox').onchange = function(e) {
+    if(!(this instanceof HTMLInputElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({displayAll: this.checked});
+    for(let id in serverConnection.down) {
+        let c = serverConnection.down[id];
+        let elt = document.getElementById('peer-' + c.localId);
+        showHideMedia(c, elt);
+    }
+};
+
+
 /**
 /**
  * @this {Stream}
  * @this {Stream}
  * @param {Object<string,any>} stats
  * @param {Object<string,any>} stats
@@ -1294,7 +1331,7 @@ async function replaceUpStream(c) {
     let media = /** @type{HTMLVideoElement} */
     let media = /** @type{HTMLVideoElement} */
         (document.getElementById('media-' + c.localId));
         (document.getElementById('media-' + c.localId));
     setUpStream(cn, c.stream);
     setUpStream(cn, c.stream);
-    await setMedia(cn, true,
+    await setMedia(cn,
                    cn.label == 'camera' && getSettings().mirrorView,
                    cn.label == 'camera' && getSettings().mirrorView,
                    cn.label == 'video' && media);
                    cn.label == 'video' && media);
     return cn;
     return cn;
@@ -1394,13 +1431,27 @@ async function addLocalMedia(localId) {
     }
     }
 
 
     setUpStream(c, stream);
     setUpStream(c, stream);
-    await setMedia(c, true, settings.mirrorView);
+    await setMedia(c, settings.mirrorView);
     setButtonsVisibility();
     setButtonsVisibility();
 }
 }
 
 
 let safariScreenshareDone = false;
 let safariScreenshareDone = false;
 
 
 async function addShareMedia() {
 async function addShareMedia() {
+    if(!safariScreenshareDone) {
+        if(isSafari()) {
+            let ok = confirm(
+                'Screen sharing in Safari is badly broken.  ' +
+                    'It will work at first, ' +
+                    'but then your video will randomly freeze.  ' +
+                    'Are you sure that you wish to enable screensharing?'
+            );
+            if(!ok)
+                return
+        }
+        safariScreenshareDone = true;
+    }
+
     /** @type {MediaStream} */
     /** @type {MediaStream} */
     let stream = null;
     let stream = null;
     try {
     try {
@@ -1416,17 +1467,10 @@ async function addShareMedia() {
         return;
         return;
     }
     }
 
 
-    if(!safariScreenshareDone) {
-        if(isSafari())
-            displayWarning('Screen sharing under Safari is experimental.  ' +
-                           'Please use a different browser if possible.');
-        safariScreenshareDone = true;
-    }
-
     let c = newUpStream();
     let c = newUpStream();
     c.label = 'screenshare';
     c.label = 'screenshare';
     setUpStream(c, stream);
     setUpStream(c, stream);
-    await setMedia(c, true);
+    await setMedia(c);
     setButtonsVisibility();
     setButtonsVisibility();
 }
 }
 
 
@@ -1471,7 +1515,7 @@ async function addFileMedia(file) {
         displayWarning('You have been muted');
         displayWarning('You have been muted');
     }
     }
 
 
-    await setMedia(c, true, false, video);
+    await setMedia(c, false, video);
     c.userdata.play = true;
     c.userdata.play = true;
     setButtonsVisibility();
     setButtonsVisibility();
 }
 }
@@ -1647,25 +1691,24 @@ function scheduleReconsiderDownRate() {
  * setMedia adds a new media element corresponding to stream c.
  * setMedia adds a new media element corresponding to stream c.
  *
  *
  * @param {Stream} c
  * @param {Stream} c
- * @param {boolean} isUp
- *     - indicates whether the stream goes in the up direction
  * @param {boolean} [mirror]
  * @param {boolean} [mirror]
  *     - whether to mirror the video
  *     - whether to mirror the video
  * @param {HTMLVideoElement} [video]
  * @param {HTMLVideoElement} [video]
  *     - the video element to add.  If null, a new element with custom
  *     - the video element to add.  If null, a new element with custom
  *       controls will be created.
  *       controls will be created.
  */
  */
-async function setMedia(c, isUp, mirror, video) {
-    let peersdiv = document.getElementById('peers');
-
+async function setMedia(c, mirror, video) {
     let div = document.getElementById('peer-' + c.localId);
     let div = document.getElementById('peer-' + c.localId);
     if(!div) {
     if(!div) {
         div = document.createElement('div');
         div = document.createElement('div');
         div.id = 'peer-' + c.localId;
         div.id = 'peer-' + c.localId;
         div.classList.add('peer');
         div.classList.add('peer');
+        let peersdiv = document.getElementById('peers');
         peersdiv.appendChild(div);
         peersdiv.appendChild(div);
     }
     }
 
 
+    showHideMedia(c, div)
+
     let media = /** @type {HTMLVideoElement} */
     let media = /** @type {HTMLVideoElement} */
         (document.getElementById('media-' + c.localId));
         (document.getElementById('media-' + c.localId));
     if(!media) {
     if(!media) {
@@ -1673,7 +1716,7 @@ async function setMedia(c, isUp, mirror, video) {
             media = video;
             media = video;
         } else {
         } else {
             media = document.createElement('video');
             media = document.createElement('video');
-            if(isUp)
+            if(c.up)
                 media.muted = true;
                 media.muted = true;
         }
         }
 
 
@@ -1693,7 +1736,7 @@ async function setMedia(c, isUp, mirror, video) {
     if(!video && media.srcObject !== c.stream)
     if(!video && media.srcObject !== c.stream)
         media.srcObject = c.stream;
         media.srcObject = c.stream;
 
 
-    if(!isUp) {
+    if(!c.up) {
         media.onfullscreenchange = function(e) {
         media.onfullscreenchange = function(e) {
             forceDownRate(c.id, document.fullscreenElement === media, false);
             forceDownRate(c.id, document.fullscreenElement === media, false);
         }
         }
@@ -1713,7 +1756,7 @@ async function setMedia(c, isUp, mirror, video) {
     showVideo();
     showVideo();
     resizePeers();
     resizePeers();
 
 
-    if(!isUp && isSafari() && !findUpMedia('camera')) {
+    if(!c.up && isSafari() && !findUpMedia('camera')) {
         // Safari doesn't allow autoplay unless the user has granted media access
         // Safari doesn't allow autoplay unless the user has granted media access
         try {
         try {
             let stream = await navigator.mediaDevices.getUserMedia({audio: true});
             let stream = await navigator.mediaDevices.getUserMedia({audio: true});
@@ -1723,6 +1766,29 @@ async function setMedia(c, isUp, mirror, video) {
     }
     }
 }
 }
 
 
+
+/**
+ * @param {Stream} c
+ * @param {HTMLElement} elt
+ */
+function showHideMedia(c, elt) {
+    let display = c.up || getSettings().displayAll;
+    if(!display && c.stream) {
+        let tracks = c.stream.getTracks();
+        for(let i = 0; i < tracks.length; i++) {
+            let t = tracks[i];
+            if(t.kind === 'video') {
+                display = true;
+                break;
+            }
+        }
+    }
+    if(display)
+        elt.classList.remove('peer-hidden');
+    else
+        elt.classList.add('peer-hidden');
+}
+
 /**
 /**
  * resetMedia resets the source stream of the media element associated
  * resetMedia resets the source stream of the media element associated
  * with c.  This has the side-effect of resetting any frozen frames.
  * with c.  This has the side-effect of resetting any frozen frames.
@@ -2150,6 +2216,26 @@ function setUserStatus(id, elt, userinfo) {
         elt.classList.add('user-status-raisehand');
         elt.classList.add('user-status-raisehand');
     else
     else
         elt.classList.remove('user-status-raisehand');
         elt.classList.remove('user-status-raisehand');
+
+    let microphone=false, camera = false;
+    for(let label in userinfo.streams) {
+        for(let kind in userinfo.streams[label]) {
+            if(kind == 'audio')
+                microphone = true;
+            else
+                camera = true;
+        }
+    }
+    if(camera) {
+        elt.classList.remove('user-status-microphone');
+        elt.classList.add('user-status-camera');
+    } else if(microphone) {
+        elt.classList.add('user-status-microphone');
+        elt.classList.remove('user-status-camera');
+    } else {
+        elt.classList.remove('user-status-microphone');
+        elt.classList.remove('user-status-camera');
+    }
 }
 }
 
 
 /**
 /**
@@ -2304,96 +2390,11 @@ async function gotJoined(kind, group, perms, status, data, message) {
     }
     }
 }
 }
 
 
-/** @type {Object<string,TransferredFile>} */
-let transferredFiles = {};
-
-/**
- * A file in the process of being transferred.
- *
- * @constructor
- */
-function TransferredFile(id, userid, up, username, name, type, size) {
-    /** @type {string} */
-    this.id = id;
-    /** @type {string} */
-    this.userid = userid;
-    /** @type {boolean} */
-    this.up = up;
-    /** @type {string} */
-    this.username = username;
-    /** @type {string} */
-    this.name = name;
-    /** @type {string} */
-    this.type = type;
-    /** @type {number} */
-    this.size = size;
-    /** @type {File} */
-    this.file = null;
-    /** @type {RTCPeerConnection} */
-    this.pc = null;
-    /** @type {RTCDataChannel} */
-    this.dc = null;
-    /** @type {Array<RTCIceCandidateInit>} */
-    this.candidates = [];
-    /** @type {Array<Blob|ArrayBuffer>} */
-    this.data = [];
-    /** @type {number} */
-    this.datalen = 0;
-}
-
-TransferredFile.prototype.fullid = function() {
-    return this.userid + (this.up ? '+' : '-') + this.id;
-};
-
-/**
- * @param {boolean} up
- * @param {string} userid
- * @param {string} fileid
- * @returns {TransferredFile}
- */
-TransferredFile.get = function(up, userid, fileid) {
-    return transferredFiles[userid + (up ? '+' : '-') + fileid];
-};
-
-TransferredFile.prototype.close = function() {
-    if(this.dc) {
-        this.dc.onclose = null;
-        this.dc.onerror = null;
-        this.dc.onmessage = null;
-    }
-    if(this.pc)
-        this.pc.close();
-    this.dc = null;
-    this.pc = null;
-    this.data = [];
-    this.datalen = 0;
-    delete(transferredFiles[this.fullid()]);
-}
-
-TransferredFile.prototype.pushData = function(data) {
-    if(data instanceof Blob) {
-        this.datalen += data.size;
-    } else if(data instanceof ArrayBuffer) {
-        this.datalen += data.byteLength;
-    } else {
-        throw new Error('unexpected type for received data');
-    }
-    this.data.push(data);
-}
-
-TransferredFile.prototype.getData = function() {
-    let blob = new Blob(this.data, {type: this.type});
-    if(blob.size != this.datalen)
-        throw new Error('Inconsistent data size');
-    this.data = [];
-    this.datalen = 0;
-    return blob;
-}
-
 /**
 /**
  * @param {TransferredFile} f
  * @param {TransferredFile} f
  */
  */
-function fileTransferBox(f) {
+function gotFileTransfer(f) {
+    f.onevent = gotFileTransferEvent;
     let p = document.createElement('p');
     let p = document.createElement('p');
     if(f.up)
     if(f.up)
         p.textContent =
         p.textContent =
@@ -2404,27 +2405,20 @@ function fileTransferBox(f) {
         `User ${f.username} offered to send us a file ` +
         `User ${f.username} offered to send us a file ` +
         `called "${f.name}" of size ${f.size}.`
         `called "${f.name}" of size ${f.size}.`
     let bno = null, byes = null;
     let bno = null, byes = null;
-    if(f.up) {
-        bno = document.createElement('button');
-        bno.textContent = 'Cancel';
-        bno.onclick = function(e) {
-            cancelFile(f);
-        };
-        bno.id = "bno-" + f.fullid();
-    } else {
+    if(!f.up) {
         byes = document.createElement('button');
         byes = document.createElement('button');
         byes.textContent = 'Accept';
         byes.textContent = 'Accept';
         byes.onclick = function(e) {
         byes.onclick = function(e) {
-            getFile(f);
+            f.receive();
         };
         };
         byes.id = "byes-" + f.fullid();
         byes.id = "byes-" + f.fullid();
-        bno = document.createElement('button');
-        bno.textContent = 'Decline';
-        bno.onclick = function(e) {
-            rejectFile(f);
-        };
-        bno.id = "bno-" + f.fullid();
     }
     }
+    bno = document.createElement('button');
+    bno.textContent = f.up ? 'Cancel' : 'Reject';
+    bno.onclick = function(e) {
+        f.cancel();
+    };
+    bno.id = "bno-" + f.fullid();
     let status = document.createElement('div');
     let status = document.createElement('div');
     status.id = 'status-' + f.fullid();
     status.id = 'status-' + f.fullid();
     if(!f.up) {
     if(!f.up) {
@@ -2476,334 +2470,49 @@ function setFileStatus(f, status, delyes, delno) {
 }
 }
 
 
 /**
 /**
- * @param {TransferredFile} f
- * @param {any} message
- */
-function failFile(f, message) {
-    if(!f.dc)
-        return;
-    console.error('File transfer failed:', message);
-    setFileStatus(f, message ? `Failed: ${message}` : 'Failed.');
-    f.close();
-}
-
-/**
- * @param {string} id
- * @param {File} file
- */
-function offerFile(id, file) {
-    let fileid = newRandomId();
-    let username = serverConnection.users[id].username;
-    let f = new TransferredFile(
-        fileid, id, true, username, file.name, file.type, file.size,
-    );
-    f.file = file;
-    transferredFiles[f.fullid()] = f;
-    try {
-        fileTransferBox(f);
-        serverConnection.userMessage('offerfile', id, {
-            id: fileid,
-            name: f.name,
-            size: f.size,
-            type: f.type,
-        });
-    } catch(e) {
-        displayError(e);
-        f.close();
-    }
-}
-
-/**
- * @param {TransferredFile} f
- */
-function cancelFile(f) {
-    serverConnection.userMessage('cancelfile', f.userid, {
-        id: f.id,
-    });
-    f.close();
-    setFileStatus(f, 'Cancelled.', true, true);
-}
-
-/**
- * @param {TransferredFile} f
- */
-async function getFile(f) {
-    if(f.pc)
-        throw new Error("Download already in progress");
-    setFileStatus(f, 'Connecting...', true);
-    let pc = new RTCPeerConnection(serverConnection.getRTCConfiguration());
-    if(!pc)
-        throw new Error("Couldn't create peer connection");
-    f.pc = pc;
-    f.candidates = [];
-    pc.onsignalingstatechange = function(e) {
-        if(pc.signalingState === 'stable') {
-            f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn));
-            f.candidates = [];
-        }
-    };
-    pc.onicecandidate = function(e) {
-        serverConnection.userMessage('filedownice', f.userid, {
-            id: f.id,
-            candidate: e.candidate,
-        });
-    };
-    f.dc = pc.createDataChannel('file');
-    f.data = [];
-    f.datalen = 0;
-    f.dc.onclose = function(e) {
-        try {
-            closeReceiveFileData(f);
-        } catch(e) {
-            failFile(f, e);
-        }
-    };
-    f.dc.onmessage = function(e) {
-        try {
-            receiveFileData(f, e.data);
-        } catch(e) {
-            failFile(f, e);
-        }
-    };
-    f.dc.onerror = function(e) {
-        /** @ts-ignore */
-        let err = e.error;
-        failFile(f, err);
-    };
-    let offer = await pc.createOffer();
-    if(!offer)
-        throw new Error("Couldn't create offer");
-    await pc.setLocalDescription(offer);
-    serverConnection.userMessage('getfile', f.userid, {
-        id: f.id,
-        offer: pc.localDescription.sdp,
-    });
-    setFileStatus(f, 'Negotiating...', true);
-}
-
-/**
- * @param {TransferredFile} f
+ * @this {TransferredFile}
+ * @param {string} state
+ * @param {any} [data]
  */
  */
-async function rejectFile(f) {
-    serverConnection.userMessage('rejectfile', f.userid, {
-        id: f.id,
-    });
-    setFileStatus(f, 'Rejected.', true, true);
-    f.close();
-}
-
-/**
- * @param {TransferredFile} f
- * @param {string} sdp
- */
-async function sendOfferedFile(f, sdp) {
-    if(f.pc)
-        throw new Error('Transfer already in progress');
-
-    setFileStatus(f, 'Negotiating...', true);
-    let pc = new RTCPeerConnection(serverConnection.getRTCConfiguration());
-    if(!pc)
-        throw new Error("Couldn't create peer connection");
-    f.pc = pc;
-    f.candidates = [];
-    pc.onicecandidate = function(e) {
-        serverConnection.userMessage('fileupice', f.userid, {
-            id: f.id,
-            candidate: e.candidate,
-        });
-    };
-    pc.onsignalingstatechange = function(e) {
-        if(pc.signalingState === 'stable') {
-            f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn));
-            f.candidates = [];
-        }
-    };
-    pc.ondatachannel = function(e) {
-        if(f.dc)
-            throw new Error('Duplicate datachannel');
-        f.dc = /** @type{RTCDataChannel} */(e.channel);
-        f.dc.onclose = function(e) {
-            try {
-                closeSendFileData(f);
-            } catch(e) {
-                failFile(f, e);
-            }
-        };
-        f.dc.onerror = function(e) {
-            /** @ts-ignore */
-            let err = e.error;
-            failFile(f, err);
-        }
-        f.dc.onmessage = function(e) {
-            try {
-                ackSendFileData(f, e.data);
-            } catch(e) {
-                failFile(f, e);
-            }
-        };
-        sendFileData(f).catch(e => failFile(f, e));
-    };
-
-    await pc.setRemoteDescription({
-        type: 'offer',
-        sdp: sdp,
-    });
-
-    let answer = await pc.createAnswer();
-    if(!answer)
-        throw new Error("Couldn't create answer");
-    await pc.setLocalDescription(answer);
-    serverConnection.userMessage('sendfile', f.userid, {
-        id: f.id,
-        answer: pc.localDescription.sdp,
-    });
-    setFileStatus(f, 'Uploading...', true);
-}
-
-/**
- * @param {TransferredFile} f
- * @param {string} sdp
- */
-async function receiveFile(f, sdp) {
-    await f.pc.setRemoteDescription({
-        type: 'answer',
-        sdp: sdp,
-    });
-    setFileStatus(f, 'Downloading...', true);
-}
-
-/**
- * @param {TransferredFile} f
- */
-async function sendFileData(f) {
-    let r = f.file.stream().getReader();
-
-    f.dc.bufferedAmountLowThreshold = 65536;
-
-    async function write(a) {
-        while(f.dc.bufferedAmount > f.dc.bufferedAmountLowThreshold) {
-            await new Promise((resolve, reject) => {
-                if(!f.dc) {
-                    reject(new Error('File is closed.'));
-                    return;
-                }
-                f.dc.onbufferedamountlow = function(e) {
-                    if(!f.dc) {
-                        reject(new Error('File is closed.'));
-                        return;
-                    }
-                    f.dc.onbufferedamountlow = null;
-                    resolve();
-                }
-            });
-        }
-        f.dc.send(a);
-        f.datalen += a.length;
-        setFileStatus(f, `Uploading... ${f.datalen}/${f.size}`, true);
-    }
-
-    while(true) {
-        let v = await r.read();
-        if(v.done)
-            break;
-        if(!(v.value instanceof Uint8Array))
-            throw new Error('Unexpected type for chunk');
-        if(v.value.length <= 16384) {
-            await write(v.value);
-        } else {
-            for(let i = 0; i < v.value.length; i += 16384) {
-                let a = new Uint8Array(
-                    v.value.buffer, i, Math.min(16384, v.value.length - i),
-                );
-                await write(a);
-            }
-        }
-    }
-}
-
-/**
- * @param {TransferredFile} f
- */
-function ackSendFileData(f, data) {
-    if(data === 'done' && f.datalen == f.size)
+function gotFileTransferEvent(state, data) {
+    let f = this;
+    switch(state) {
+    case 'inviting':
+        break;
+    case 'connecting':
+        setFileStatus(f, 'Connecting...', true);
+        break;
+    case 'connected':
+        if(f.up)
+            setFileStatus(f, `Sending... ${f.datalen}/${f.size}`);
+        else
+            setFileStatus(f, `Receiving... ${f.datalen}/${f.size}`);
+        break;
+    case 'done':
         setFileStatus(f, 'Done.', true, true);
         setFileStatus(f, 'Done.', true, true);
-    else
-        setFileStatus(f, 'Failed.', true, true);
-    f.dc.onclose = null;
-    f.dc.onerror = null;
-    f.close();
-}
-
-/**
- * @param {TransferredFile} f
- */
-function closeSendFileData(f) {
-    setFileStatus(f, 'Failed.', true, true);
-    f.close();
-}
-
-/**
- * @param {TransferredFile} f
- * @param {Blob|ArrayBuffer} data
- */
-function receiveFileData(f, data) {
-    f.pushData(data);
-    setFileStatus(f, `Downloading... ${f.datalen}/${f.size}`, true);
-
-    if(f.datalen < f.size)
-        return;
-
-    if(f.datalen != f.size) {
-        setFileStatus(f, 'Failed.', true, true);
-        f.close();
-        return;
-    }
-
-    f.dc.onmessage = null;
-    doneReceiveFileData(f);
-}
-
-/**
- * @param {TransferredFile} f
- */
-async function doneReceiveFileData(f) {
-    setFileStatus(f, 'Done.', true, true);
-    let blob = f.getData();
-
-    await new Promise((resolve, reject) => {
-        let timer = setTimeout(function(e) { resolve(); }, 2000);
-        f.dc.onclose = function(e) {
-            clearTimeout(timer);
-            resolve();
-        };
-        f.dc.onerror = function(e) {
-            clearTimeout(timer);
-            resolve();
-        };
-        f.dc.send('done');
-    });
-
-    f.dc.onclose = null;
-    f.dc.onerror = null;
-    f.close();
-
-    let url = URL.createObjectURL(blob);
-    let a = document.createElement('a');
-    a.href = url;
-    a.textContent = f.name;
-    a.download = f.name;
-    a.type = f.type;
-    a.click();
-    URL.revokeObjectURL(url);
-}
-
-/**
- * @param {TransferredFile} f
- */
-function closeReceiveFileData(f) {
-    if(f.datalen !== f.size) {
-        setFileStatus(f, 'Failed.', true, true)
-        f.close();
+        if(!f.up) {
+            let url = URL.createObjectURL(data);
+            let a = document.createElement('a');
+            a.href = url;
+            a.textContent = f.name;
+            a.download = f.name;
+            a.type = f.mimetype;
+            a.click();
+            URL.revokeObjectURL(url);
+        }
+        break;
+    case 'cancelled':
+        if(data)
+            setFileStatus(f, `Cancelled: ${data.toString()}.`, true, true);
+        else
+            setFileStatus(f, 'Cancelled.', true, true);
+        break;
+    case 'closed':
+        break;
+    default:
+        console.error(`Unexpected state "${state}"`);
+        f.cancel(`unexpected state "${state}" (this shouldn't happen)`);
+        break;
     }
     }
 }
 }
 
 
@@ -2844,70 +2553,6 @@ function gotUserMessage(id, dest, username, time, privileged, kind, message) {
             console.error(`Got unprivileged message of kind ${kind}`);
             console.error(`Got unprivileged message of kind ${kind}`);
         }
         }
         break;
         break;
-    case 'offerfile': {
-        let f = new TransferredFile(
-            message.id, id, false, username,
-            message.name, message.type, message.size,
-        );
-        transferredFiles[f.fullid()] = f;
-        fileTransferBox(f);
-        break;
-    }
-    case 'cancelfile': {
-        let f = TransferredFile.get(false, id, message.id);
-        if(!f)
-            throw new Error('unexpected cancelfile');
-        setFileStatus(f, 'Cancelled.', true, true);
-        f.close();
-        break;
-    }
-    case 'getfile': {
-        let f = TransferredFile.get(true, id, message.id);
-        if(!f)
-            throw new Error('unexpected getfile');
-        sendOfferedFile(f, message.offer);
-        break;
-    }
-    case 'rejectfile': {
-        let f = TransferredFile.get(true, id, message.id);
-        if(!f)
-            throw new Error('unexpected rejectfile');
-        setFileStatus(f, 'Rejected.', true, true);
-        f.close();
-        break;
-    }
-    case 'sendfile': {
-        let f = TransferredFile.get(false, id, message.id);
-        if(!f)
-            throw new Error('unexpected sendfile');
-        receiveFile(f, message.answer);
-        break;
-    }
-    case 'filedownice': {
-        let f = TransferredFile.get(true, id, message.id);
-        if(!f.pc) {
-            console.warn('Unexpected filedownice');
-            return;
-        }
-        if(f.pc.signalingState === 'stable')
-            f.pc.addIceCandidate(message.candidate).catch(console.warn);
-        else
-            f.candidates.push(message.candidate);
-        break;
-    }
-    case 'fileupice': {
-        let f = TransferredFile.get(false, id, message.id);
-        if(!f.pc) {
-            console.warn('Unexpected fileupice');
-            return;
-        }
-        if(f.pc.signalingState === 'stable')
-            f.pc.addIceCandidate(message.candidate).catch(console.warn);
-        else
-            f.candidates.push(message.candidate);
-        break;
-
-    }
     default:
     default:
         console.warn(`Got unknown user message ${kind}`);
         console.warn(`Got unknown user message ${kind}`);
         break;
         break;
@@ -3011,12 +2656,10 @@ function addToChatbox(peerId, dest, nick, time, privileged, history, kind, messa
     if(kind !== 'me') {
     if(kind !== 'me') {
         let p = formatLines(message.toString().split('\n'));
         let p = formatLines(message.toString().split('\n'));
         let doHeader = true;
         let doHeader = true;
-        if(!peerId && !dest && !nick) {
-            doHeader = false;
-        } else if(lastMessage.nick !== (nick || null) ||
-                  lastMessage.peerId !== peerId ||
-                  lastMessage.dest !== (dest || null) ||
-                  !time || !lastMessage.time) {
+        if(lastMessage.nick !== (nick || null) ||
+           lastMessage.peerId !== (peerId || null) ||
+           lastMessage.dest !== (dest || null) ||
+           !time || !lastMessage.time) {
             doHeader = true;
             doHeader = true;
         } else {
         } else {
             let delta = time - lastMessage.time;
             let delta = time - lastMessage.time;
@@ -3025,16 +2668,14 @@ function addToChatbox(peerId, dest, nick, time, privileged, history, kind, messa
 
 
         if(doHeader) {
         if(doHeader) {
             let header = document.createElement('p');
             let header = document.createElement('p');
-            if(peerId || nick || dest) {
-                let user = document.createElement('span');
-                let u = serverConnection.users[dest];
-                let name = (u && u.username);
-                user.textContent = dest ?
-                    `${nick||'(anon)'} \u2192 ${name || '(anon)'}` :
-                    (nick || '(anon)');
-                user.classList.add('message-user');
-                header.appendChild(user);
-            }
+            let user = document.createElement('span');
+            let u = dest && serverConnection.users[dest];
+            let name = (u && u.username);
+            user.textContent = dest ?
+                `${nick || '(anon)'} \u2192 ${name || '(anon)'}` :
+                (nick || '(anon)');
+            user.classList.add('message-user');
+            header.appendChild(user);
             header.classList.add('message-header');
             header.classList.add('message-header');
             container.appendChild(header);
             container.appendChild(header);
             if(time) {
             if(time) {
@@ -3491,7 +3132,7 @@ function sendFile(id) {
         let files = this.files;
         let files = this.files;
         for(let i = 0; i < files.length; i++) {
         for(let i = 0; i < files.length; i++) {
             try {
             try {
-                offerFile(id, files[i]);
+                serverConnection.sendFile(id, files[i]);
             } catch(e) {
             } catch(e) {
                 console.error(e);
                 console.error(e);
                 displayError(e);
                 displayError(e);
@@ -3846,8 +3487,14 @@ async function serverConnect() {
     serverConnection.onjoined = gotJoined;
     serverConnection.onjoined = gotJoined;
     serverConnection.onchat = addToChatbox;
     serverConnection.onchat = addToChatbox;
     serverConnection.onusermessage = gotUserMessage;
     serverConnection.onusermessage = gotUserMessage;
+    serverConnection.onfiletransfer = gotFileTransfer;
+
+    let url = null; //groupStatus.endpoint;
+    if(!url) {
+        console.warn("no endpoint in status");
+        url = `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`;
+    }
 
 
-    let url = `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`;
     try {
     try {
         await serverConnection.connect(url);
         await serverConnection.connect(url);
     } catch(e) {
     } catch(e) {
@@ -3857,11 +3504,6 @@ async function serverConnect() {
 }
 }
 
 
 async function start() {
 async function start() {
-    group = decodeURIComponent(
-        location.pathname.replace(/^\/[a-z]*\//, '').replace(/\/$/, '')
-    );
-
-    /** @type {Object} */
     try {
     try {
         let r = await fetch(".status.json")
         let r = await fetch(".status.json")
         if(!r.ok)
         if(!r.ok)
@@ -3869,7 +3511,17 @@ async function start() {
         groupStatus = await r.json()
         groupStatus = await r.json()
     } catch(e) {
     } catch(e) {
         console.error(e);
         console.error(e);
-        return;
+        displayWarning("Couldn't fetch status: " + e);
+        groupStatus = {};
+    }
+
+    if(groupStatus.name) {
+        group = groupStatus.name;
+    } else {
+        console.warn("no group name in status");
+        group = decodeURIComponent(
+            location.pathname.replace(/^\/[a-z]*\//, '').replace(/\/$/, ''),
+        );
     }
     }
 
 
     let parms = new URLSearchParams(window.location.search);
     let parms = new URLSearchParams(window.location.search);
@@ -3892,11 +3544,20 @@ async function start() {
     setViewportHeight();
     setViewportHeight();
 }
 }
 
 
+// BAO
 //start();
 //start();
 
 
 window.onload = async function() {	
 window.onload = async function() {	
 
 
-// On resize and orientation change, we update viewport height
+	document.getElementById('closebutton').onclick = async function(e) {
+		e.preventDefault();
+		console.debug("closebutton - click");
+		
+		await serverConnection.leave(group);
+		closeConnection();
+	};
+
+	// On resize and orientation change, we update viewport height
 	addEventListener('resize', setViewportHeight);
 	addEventListener('resize', setViewportHeight);
 	addEventListener('orientationchange', setViewportHeight);
 	addEventListener('orientationchange', setViewportHeight);
 
 
@@ -3937,7 +3598,7 @@ window.onload = async function() {
 
 
 	const host = urlParam("host");
 	const host = urlParam("host");
 	const username = urlParam("username");	
 	const username = urlParam("username");	
-    const password = "Welcome123";
+    const password = urlParam("password");
 	
 	
 	group = "public/" + urlParam("group");	
 	group = "public/" + urlParam("group");	
 	setTitle(capitalise(group));
 	setTitle(capitalise(group));

+ 15 - 15
packages/galene/galene.js

@@ -83,34 +83,34 @@
                 }
                 }
             });
             });
 
 
-            _converse.api.listen.on('getToolbarButtons', async function(toolbar_el, buttons)
+            _converse.api.listen.on('getToolbarButtons', function(toolbar_el, buttons)
             {
             {
                 console.debug("getToolbarButtons", toolbar_el.model.get("jid"));
                 console.debug("getToolbarButtons", toolbar_el.model.get("jid"));
                 let color = "fill:var(--chat-toolbar-btn-color);";
                 let color = "fill:var(--chat-toolbar-btn-color);";
                 if (toolbar_el.model.get("type") === "chatroom") color = "fill:var(--muc-toolbar-btn-color);";
                 if (toolbar_el.model.get("type") === "chatroom") color = "fill:var(--muc-toolbar-btn-color);";
+						
+				buttons.push(html`
+					<button class="plugin-galene" title="${__('Galene Meeting')}" @click=${performVideo}/>
+						<svg style="width:20px; height:20px; ${color}" viewBox="0 0 452.388 452.388"  xml:space="preserve"> <g> 	<g id="Layer_8_38_"> 		<path d="M441.677,43.643H10.687C4.785,43.643,0,48.427,0,54.329v297.425c0,5.898,4.785,10.676,10.687,10.676h162.069v25.631 			c0,0.38,0.074,0.722,0.112,1.089h-23.257c-5.407,0-9.796,4.389-9.796,9.795c0,5.408,4.389,9.801,9.796,9.801h158.506 			c5.406,0,9.795-4.389,9.795-9.801c0-5.406-4.389-9.795-9.795-9.795h-23.256c0.032-0.355,0.115-0.709,0.115-1.089V362.43H441.7 			c5.898,0,10.688-4.782,10.688-10.676V54.329C452.37,48.427,447.589,43.643,441.677,43.643z M422.089,305.133 			c0,5.903-4.784,10.687-10.683,10.687H40.96c-5.898,0-10.684-4.783-10.684-10.687V79.615c0-5.898,4.786-10.684,10.684-10.684 			h370.446c5.898,0,10.683,4.785,10.683,10.684V305.133z M303.942,290.648H154.025c0-29.872,17.472-55.661,42.753-67.706 			c-15.987-10.501-26.546-28.571-26.546-49.13c0-32.449,26.306-58.755,58.755-58.755c32.448,0,58.753,26.307,58.753,58.755 			c0,20.553-10.562,38.629-26.545,49.13C286.475,234.987,303.942,260.781,303.942,290.648z"/> </g></g> </svg>
+					</button>
+				`);						
 				
 				
+                return buttons;
+            });
+
+            _converse.api.listen.on('connected', async function()
+            {
+                window.connection = _converse.connection;
+								
 				const features = await _converse.api.disco.getFeatures(_converse.api.settings.get("galene_host"));
 				const features = await _converse.api.disco.getFeatures(_converse.api.settings.get("galene_host"));
 				
 				
 				features.each(feature => {
 				features.each(feature => {
 					const fieldname = feature.get('var');
 					const fieldname = feature.get('var');
 					
 					
 					if (fieldname == "urn:xmpp:sfu:galene:0") {
 					if (fieldname == "urn:xmpp:sfu:galene:0") {
-						console.debug("SFU found");
-						
-						buttons.push(html`
-							<button class="plugin-galene" title="${__('Galene Meeting')}" @click=${performVideo}/>
-								<svg style="width:20px; height:20px; ${color}" viewBox="0 0 452.388 452.388"  xml:space="preserve"> <g> 	<g id="Layer_8_38_"> 		<path d="M441.677,43.643H10.687C4.785,43.643,0,48.427,0,54.329v297.425c0,5.898,4.785,10.676,10.687,10.676h162.069v25.631 			c0,0.38,0.074,0.722,0.112,1.089h-23.257c-5.407,0-9.796,4.389-9.796,9.795c0,5.408,4.389,9.801,9.796,9.801h158.506 			c5.406,0,9.795-4.389,9.795-9.801c0-5.406-4.389-9.795-9.795-9.795h-23.256c0.032-0.355,0.115-0.709,0.115-1.089V362.43H441.7 			c5.898,0,10.688-4.782,10.688-10.676V54.329C452.37,48.427,447.589,43.643,441.677,43.643z M422.089,305.133 			c0,5.903-4.784,10.687-10.683,10.687H40.96c-5.898,0-10.684-4.783-10.684-10.687V79.615c0-5.898,4.786-10.684,10.684-10.684 			h370.446c5.898,0,10.683,4.785,10.683,10.684V305.133z M303.942,290.648H154.025c0-29.872,17.472-55.661,42.753-67.706 			c-15.987-10.501-26.546-28.571-26.546-49.13c0-32.449,26.306-58.755,58.755-58.755c32.448,0,58.753,26.307,58.753,58.755 			c0,20.553-10.562,38.629-26.545,49.13C286.475,234.987,303.942,260.781,303.942,290.648z"/> </g></g> </svg>
-							</button>
-						`);						
+						console.debug("SFU found");					
 					}
 					}
 				});					
 				});					
-
-                return buttons;
-            });
-
-            _converse.api.listen.on('connected', function()
-            {
-                window.connection = _converse.connection;
             });			
             });			
 
 
             _converse.api.listen.on('afterMessageBodyTransformed', function(text)
             _converse.api.listen.on('afterMessageBodyTransformed', function(text)

+ 8 - 2
packages/galene/index.html

@@ -5,7 +5,8 @@
     <meta charset="utf-8">
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta http-equiv="ScreenOrientation" content="autoRotate:disabled">
     <meta http-equiv="ScreenOrientation" content="autoRotate:disabled">
-    <link rel="stylesheet" type="text/css" href="./common.css"/>
+	<!-- BAO -->
+    <link rel="stylesheet" type="text/css" href="./common.css"/> 
     <link rel="stylesheet" type="text/css" href="./galene.css"/>
     <link rel="stylesheet" type="text/css" href="./galene.css"/>
     <link rel="stylesheet" type="text/css" href="./external/fontawesome/css/fontawesome.min.css"/>
     <link rel="stylesheet" type="text/css" href="./external/fontawesome/css/fontawesome.min.css"/>
     <link rel="stylesheet" type="text/css" href="./external/fontawesome/css/solid.min.css"/>
     <link rel="stylesheet" type="text/css" href="./external/fontawesome/css/solid.min.css"/>
@@ -57,7 +58,7 @@
                     <label>Share Screen</label>
                     <label>Share Screen</label>
                   </div>
                   </div>
                 </li>
                 </li>
-                <li>
+                <li>	<!-- BAO -->
                   <div id="closebutton" class="nav-link nav-button">
                   <div id="closebutton" class="nav-link nav-button">
                     <span><i class="fa fa-sign-out" aria-hidden="true"></i></span>
                     <span><i class="fa fa-sign-out" aria-hidden="true"></i></span>
                     <label>Close</label>
                     <label>Close</label>
@@ -247,6 +248,10 @@
             <label for="activitybox">Activity detection</label>
             <label for="activitybox">Activity detection</label>
           </form>
           </form>
 
 
+          <form>
+            <input id="displayallbox" type="checkbox"/>
+            <label for="displayallbox">Display audio-only users</label>
+          </form>
         </fieldset>
         </fieldset>
       </div>
       </div>
     </div>
     </div>
@@ -287,4 +292,5 @@
     <script src="./external/toastify/toastify.js"></script>
     <script src="./external/toastify/toastify.js"></script>
     <script src="./external/contextual/contextual.js"></script>
     <script src="./external/contextual/contextual.js"></script>
     <script src="./galene-ui.js"></script>
     <script src="./galene-ui.js"></script>
+  </body>
 </html>
 </html>

+ 713 - 6
packages/galene/protocol.js

@@ -202,11 +202,28 @@ function ServerConnection() {
      * @type {(this: ServerConnection, id: string, dest: string, username: string, time: number, privileged: boolean, kind: string, message: unknown) => void}
      * @type {(this: ServerConnection, id: string, dest: string, username: string, time: number, privileged: boolean, kind: string, message: unknown) => void}
      */
      */
     this.onusermessage = null;
     this.onusermessage = null;
+    /**
+     * The set of files currently being transferred.
+     *
+     * @type {Object<string,TransferredFile>}
+    */
+    this.transferredFiles = {};
+    /**
+     * onfiletransfer is called whenever a peer offers a file transfer.
+     *
+     * If the transfer is accepted, it should set up the file transfer
+     * callbacks and return immediately.  It may also throw an exception
+     * in order to reject the file transfer.
+     *
+     * @type {(this: ServerConnection, f: TransferredFile) => void}
+     */
+    this.onfiletransfer = null;
 }
 }
 
 
 /**
 /**
   * @typedef {Object} message
   * @typedef {Object} message
   * @property {string} type
   * @property {string} type
+  * @property {Array<string>} [version]
   * @property {string} [kind]
   * @property {string} [kind]
   * @property {string} [id]
   * @property {string} [id]
   * @property {string} [replace]
   * @property {string} [replace]
@@ -257,14 +274,15 @@ ServerConnection.prototype.send = function(m) {
  * @returns {Promise<ServerConnection>}
  * @returns {Promise<ServerConnection>}
  * @function
  * @function
  */
  */
-ServerConnection.prototype.connect = async function(connection, host) {
+ // BAO
+ServerConnection.prototype.connect = async function(connection, host) {	
     let sc = this;
     let sc = this;
     if(sc.socket) {
     if(sc.socket) {
         sc.socket.close(1000, 'Reconnecting');
         sc.socket.close(1000, 'Reconnecting');
         sc.socket = null;
         sc.socket = null;
     }
     }
-
-    sc.socket = new GaleneSocket(connection, host);
+	// BAO
+	sc.socket = new GaleneSocket(connection, host);
 
 
     return await new Promise((resolve, reject) => {
     return await new Promise((resolve, reject) => {
         this.socket.onerror = function(e) {
         this.socket.onerror = function(e) {
@@ -273,6 +291,7 @@ ServerConnection.prototype.connect = async function(connection, host) {
         this.socket.onopen = function(e) {
         this.socket.onopen = function(e) {
             sc.send({
             sc.send({
                 type: 'handshake',
                 type: 'handshake',
+                version: ["1"],
                 id: sc.id,
                 id: sc.id,
             });
             });
             if(sc.onconnected)
             if(sc.onconnected)
@@ -306,6 +325,8 @@ ServerConnection.prototype.connect = async function(connection, host) {
             let m = JSON.parse(e.data);
             let m = JSON.parse(e.data);
             switch(m.type) {
             switch(m.type) {
             case 'handshake':
             case 'handshake':
+                if(!m.version || !m.version.includes('1'))
+                    console.warn(`Unexpected protocol version ${m.version}.`);
                 break;
                 break;
             case 'offer':
             case 'offer':
                 sc.gotOffer(m.id, m.label, m.source, m.username,
                 sc.gotOffer(m.id, m.label, m.source, m.username,
@@ -380,6 +401,11 @@ ServerConnection.prototype.connect = async function(connection, host) {
                 case 'delete':
                 case 'delete':
                     if(!(m.id in sc.users))
                     if(!(m.id in sc.users))
                         console.warn(`Unknown user ${m.id} ${m.username}`);
                         console.warn(`Unknown user ${m.id} ${m.username}`);
+                    for(let t in sc.transferredFiles) {
+                        let f = sc.transferredFiles[t];
+                        if(f.userid === m.id)
+                            f.fail('user has gone away');
+                    }
                     delete(sc.users[m.id]);
                     delete(sc.users[m.id]);
                     break;
                     break;
                 default:
                 default:
@@ -398,7 +424,9 @@ ServerConnection.prototype.connect = async function(connection, host) {
                     );
                     );
                 break;
                 break;
             case 'usermessage':
             case 'usermessage':
-                if(sc.onusermessage)
+                if(m.kind === 'filetransfer')
+                    sc.fileTransfer(m.source, m.username, m.value);
+                else if(sc.onusermessage)
                     sc.onusermessage.call(
                     sc.onusermessage.call(
                         sc, m.source, m.dest, m.username, m.time,
                         sc, m.source, m.dest, m.username, m.time,
                         m.privileged, m.kind, m.value,
                         m.privileged, m.kind, m.value,
@@ -460,9 +488,35 @@ ServerConnection.prototype.join = async function(group, username, credentials, d
             });
             });
             if(!r.ok)
             if(!r.ok)
                 throw new Error(
                 throw new Error(
-                    `The authorisation server said: ${r.status} ${r.statusText}`,
+                    `The authorisation server said ${r.status} ${r.statusText}`,
                 );
                 );
-            m.token = await r.text();
+            if(r.status === 204) {
+                // no data, fallback to password auth
+                m.password = credentials.password;
+                break;
+            }
+            let ctype = r.headers.get("Content-Type");
+            if(!ctype)
+                throw new Error(
+                    "The authorisation server didn't return a content type",
+                );
+            let semi = ctype.indexOf(";");
+            if(semi >= 0)
+                ctype = ctype.slice(0, semi);
+            ctype = ctype.trim();
+            switch(ctype.toLowerCase()) {
+            case 'application/jwt':
+                let data = await r.text();
+                if(!data)
+                    throw new Error(
+                        "The authorisation server returned empty token",
+                    );
+                m.token = data;
+                break;
+            default:
+                throw new Error(`The authorisation server returned ${ctype}`);
+                break;
+            }
             break;
             break;
         default:
         default:
             throw new Error(`Unknown credentials type ${credentials.type}`);
             throw new Error(`Unknown credentials type ${credentials.type}`);
@@ -1419,3 +1473,656 @@ Stream.prototype.setStatsInterval = function(ms) {
         c.updateStats();
         c.updateStats();
     }, ms);
     }, ms);
 };
 };
+
+
+/**
+ * A file in the process of being transferred.
+ * These are stored in the ServerConnection.transferredFiles dictionary.
+ *
+ * State transitions:
+ * @example
+ * '' -> inviting -> connecting -> connected -> done -> closed
+ * any -> cancelled -> closed
+ *
+ *
+ * @parm {ServerConnection} sc
+ * @parm {string} userid
+ * @parm {string} rid
+ * @parm {boolean} up
+ * @parm {string} username
+ * @parm {string} mimetype
+ * @parm {number} size
+ * @constructor
+ */
+function TransferredFile(sc, userid, id, up, username, name, mimetype, size) {
+    /**
+     * The server connection this file is associated with.
+     *
+     * @type {ServerConnection}
+     */
+    this.sc = sc;
+    /** The id of the remote peer.
+     *
+     * @type {string}
+     */
+    this.userid = userid;
+    /**
+     * The id of this file transfer.
+     *
+     * @type {string}
+     */
+    this.id = id;
+    /**
+     * True if this is an upload.
+     *
+     * @type {boolean}
+     */
+    this.up = up;
+    /**
+     * The state of this file transfer.  See the description of the
+     * constructor for possible state transitions.
+     *
+     * @type {string}
+     */
+    this.state = '';
+    /**
+     * The username of the remote peer.
+     *
+     * @type {string}
+     */
+    this.username = username;
+    /**
+     * The name of the file being transferred.
+     *
+     * @type {string}
+     */
+    this.name = name;
+    /**
+     * The MIME type of the file being transferred.
+     *
+     * @type {string}
+     */
+    this.mimetype = mimetype;
+    /**
+     * The size in bytes of the file being transferred.
+     *
+     * @type {number}
+     */
+    this.size = size;
+    /**
+     * The file being uploaded.  Unused for downloads.
+     *
+     * @type {File}
+     */
+    this.file = null;
+    /**
+     * The peer connection used for the transfer.
+     *
+     * @type {RTCPeerConnection}
+     */
+    this.pc = null;
+    /**
+     * The datachannel used for the transfer.
+     *
+     * @type {RTCDataChannel}
+     */
+    this.dc = null;
+    /**
+     * Buffered remote ICE candidates.
+     *
+     * @type {Array<RTCIceCandidateInit>}
+     */
+    this.candidates = [];
+    /**
+     * The data received to date, stored as a list of blobs or array buffers,
+     * depending on what the browser supports.
+     *
+     * @type {Array<Blob|ArrayBuffer>}
+     */
+    this.data = [];
+    /**
+     * The total size of the data received to date.
+     *
+     * @type {number}
+     */
+    this.datalen = 0;
+    /**
+     * The main filetransfer callback.
+     *
+     * This is called whenever the state of the transfer changes,
+     * but may also be called multiple times in a single state, for example
+     * in order to display a progress bar.  Call this.cancel in order
+     * to cancel the transfer.
+     *
+     * @type {(this: TransferredFile, type: string, [data]: string) => void}
+     */
+    this.onevent = null;
+}
+
+/**
+ * The full id of this file transfer, used as a key in the transferredFiles
+ * dictionary.
+ */
+TransferredFile.prototype.fullid = function() {
+    return this.userid + (this.up ? '+' : '-') + this.id;
+};
+
+/**
+ * Retrieve a transferred file from the transferredFiles dictionary.
+ *
+ * @param {string} userid
+ * @param {string} fileid
+ * @param {boolean} up
+ * @returns {TransferredFile}
+ */
+ServerConnection.prototype.getTransferredFile = function(userid, fileid, up) {
+    return this.transferredFiles[userid + (up ? '+' : '-') + fileid];
+};
+
+/**
+ * Close a file transfer and remove it from the transferredFiles dictionary.
+ * Do not call this, call 'cancel' instead.
+ */
+TransferredFile.prototype.close = function() {
+    let f = this;
+    if(f.state === 'closed')
+        return;
+    if(f.state !== 'done' && f.state !== 'cancelled')
+        console.warn(
+            `TransferredFile.close called in unexpected state ${f.state}`,
+        );
+    if(f.dc) {
+        f.dc.onclose = null;
+        f.dc.onerror = null;
+        f.dc.onmessage = null;
+    }
+    if(f.pc)
+        f.pc.close();
+    f.dc = null;
+    f.pc = null;
+    f.data = [];
+    f.datalen = 0;
+    delete(f.sc.transferredFiles[f.fullid()]);
+    f.event('closed');
+}
+
+/**
+ * Buffer a chunk of data received during a file transfer.  Do not call this.
+ *
+ * @param {Blob|ArrayBuffer} data
+ */
+TransferredFile.prototype.bufferData = function(data) {
+    let f = this;
+    if(f.up)
+        throw new Error('buffering data in the wrong direction');
+    if(data instanceof Blob) {
+        f.datalen += data.size;
+    } else if(data instanceof ArrayBuffer) {
+        f.datalen += data.byteLength;
+    } else {
+        throw new Error('unexpected type for received data');
+    }
+    f.data.push(data);
+}
+
+/**
+ * Retreive the data buffered during a file transfer.  Don't call this.
+ *
+ * @returns {Blob}
+ */
+TransferredFile.prototype.getBufferedData = function() {
+    let f = this;
+    if(f.up)
+        throw new Error('buffering data in wrong direction');
+    let blob = new Blob(f.data, {type: f.mimetype});
+    if(blob.size != f.datalen)
+        throw new Error('Inconsistent data size');
+    f.data = [];
+    f.datalen = 0;
+    return blob;
+}
+
+/**
+ * Set the file's state, and call the onevent callback.
+ *
+ * This calls the callback even if the state didn't change, which is
+ * useful if the client needs to display a progress bar.
+ *
+ * @param {string} state
+ * @param {any} [data]
+ */
+TransferredFile.prototype.event = function(state, data) {
+    let f = this;
+    f.state = state;
+    if(f.onevent)
+        f.onevent.call(f, state, data);
+}
+
+
+/**
+ * Cancel a file transfer.
+ *
+ * Depending on the state, this will either forcibly close the connection,
+ * send a handshake, or do nothing.  It will set the state to cancelled.
+ *
+ * @param {string|Error} [data]
+ */
+TransferredFile.prototype.cancel = function(data) {
+    let f = this;
+    if(f.state === 'closed')
+        return;
+    if(f.state !== '' && f.state !== 'done' && f.state !== 'cancelled') {
+        let m = {
+            type: f.up ? 'cancel' : 'reject',
+            id: f.id,
+        };
+        if(data)
+            m.message = data.toString();
+        f.sc.userMessage('filetransfer', f.userid, m);
+    }
+    if(f.state !== 'done' && f.state !== 'cancelled')
+        f.event('cancelled', data);
+    f.close();
+}
+
+/**
+ * Forcibly terminate a file transfer.
+ *
+ * This is like cancel, but will not attempt to handshake.
+ * Use cancel instead of this, unless you know what you are doing.
+ *
+ * @param {string|Error} [data]
+ */
+TransferredFile.prototype.fail = function(data) {
+    let f = this;
+    if(f.state === 'done' || f.state === 'cancelled' || f.state === 'closed')
+        return;
+    f.event('cancelled', data);
+    f.close();
+}
+
+/**
+ * Initiate a file upload.
+ *
+ * This will cause the onfiletransfer callback to be called, at which
+ * point you should set up the onevent callback.
+ *
+ * @param {string} id
+ * @param {File} file
+ */
+ServerConnection.prototype.sendFile = function(id, file) {
+    let sc = this;
+    let fileid = newRandomId();
+    let user = sc.users[id];
+    if(!user)
+        throw new Error('offering upload to unknown user');
+    let f = new TransferredFile(
+        sc, id, fileid, true, user.username, file.name, file.type, file.size,
+    );
+    f.file = file;
+
+    try {
+        if(sc.onfiletransfer)
+            sc.onfiletransfer.call(sc, f);
+        else
+            throw new Error('this client does not implement file transfer');
+    } catch(e) {
+        f.cancel(e);
+        return;
+    }
+
+    sc.transferredFiles[f.fullid()] = f;
+    sc.userMessage('filetransfer', id, {
+        type: 'invite',
+        id: fileid,
+        name: f.name,
+        size: f.size,
+        mimetype: f.mimetype,
+    });
+    f.event('inviting');
+}
+
+/**
+ * Receive a file.
+ *
+ * Call this after the onfiletransfer callback has yielded an incoming
+ * file (up field set to false).  If you wish to reject the file transfer,
+ * call cancel instead.
+ */
+TransferredFile.prototype.receive = async function() {
+    let f = this;
+    if(f.up)
+        throw new Error('Receiving in wrong direction');
+    if(f.pc)
+        throw new Error('Download already in progress');
+    let pc = new RTCPeerConnection(f.sc.getRTCConfiguration());
+    if(!pc) {
+        let err = new Error("Couldn't create peer connection");
+        f.fail(err);
+        return;
+    }
+    f.pc = pc;
+    f.event('connecting');
+
+    f.candidates = [];
+    pc.onsignalingstatechange = function(e) {
+        if(pc.signalingState === 'stable') {
+            f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn));
+            f.candidates = [];
+        }
+    };
+    pc.onicecandidate = function(e) {
+        f.sc.userMessage('filetransfer', f.userid, {
+            type: 'downice',
+            id: f.id,
+            candidate: e.candidate,
+        });
+    };
+    f.dc = pc.createDataChannel('file');
+    f.data = [];
+    f.datalen = 0;
+    f.dc.onclose = function(e) {
+        f.cancel('remote peer closed connection');
+    };
+    f.dc.onmessage = function(e) {
+        f.receiveData(e.data).catch(e => f.cancel(e));
+    };
+    f.dc.onerror = function(e) {
+        /** @ts-ignore */
+        let err = e.error;
+        f.cancel(err)
+    };
+    let offer = await pc.createOffer();
+    if(!offer) {
+        f.cancel(new Error("Couldn't create offer"));
+        return;
+    }
+    await pc.setLocalDescription(offer);
+    f.sc.userMessage('filetransfer', f.userid, {
+        type: 'offer',
+        id: f.id,
+        sdp: pc.localDescription.sdp,
+    });
+}
+
+/**
+ * Negotiate a file transfer on the sender side.  Don't call this.
+ *
+ * @param {string} sdp
+ */
+TransferredFile.prototype.answer = async function(sdp) {
+    let f = this;
+    if(!f.up)
+        throw new Error('Sending file in wrong direction');
+    if(f.pc)
+        throw new Error('Transfer already in progress');
+    let pc = new RTCPeerConnection(f.sc.getRTCConfiguration());
+    if(!pc) {
+        let err = new Error("Couldn't create peer connection");
+        f.fail(err);
+        return;
+    }
+    f.pc = pc;
+    f.event('connecting');
+
+    f.candidates = [];
+    pc.onicecandidate = function(e) {
+        f.sc.userMessage('filetransfer', f.userid, {
+            type: 'upice',
+            id: f.id,
+            candidate: e.candidate,
+        });
+    };
+    pc.onsignalingstatechange = function(e) {
+        if(pc.signalingState === 'stable') {
+            f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn));
+            f.candidates = [];
+        }
+    };
+    pc.ondatachannel = function(e) {
+        if(f.dc) {
+            f.cancel(new Error('Duplicate datachannel'));
+            return;
+        }
+        f.dc = /** @type{RTCDataChannel} */(e.channel);
+        f.dc.onclose = function(e) {
+            f.cancel('remote peer closed connection');
+        };
+        f.dc.onerror = function(e) {
+            /** @ts-ignore */
+            let err = e.error;
+            f.cancel(err);
+        }
+        f.dc.onmessage = function(e) {
+            if(e.data === 'done' && f.datalen === f.size) {
+                f.event('done');
+                f.dc.onclose = null;
+                f.dc.onerror = null;
+                f.close();
+            } else {
+                f.cancel(new Error('unexpected data from receiver'));
+            }
+        }
+        f.send().catch(e => f.cancel(e));
+    };
+
+    await pc.setRemoteDescription({
+        type: 'offer',
+        sdp: sdp,
+    });
+
+    let answer = await pc.createAnswer();
+    if(!answer)
+        throw new Error("Couldn't create answer");
+    await pc.setLocalDescription(answer);
+    f.sc.userMessage('filetransfer', f.userid, {
+        type: 'answer',
+        id: f.id,
+        sdp: pc.localDescription.sdp,
+    });
+
+    f.event('connected');
+}
+
+/**
+ * Transfer file data.  Don't call this, it is called automatically
+ * after negotiation completes.
+ */
+TransferredFile.prototype.send = async function() {
+    let f = this;
+    if(!f.up)
+        throw new Error('sending in wrong direction');
+    let r = f.file.stream().getReader();
+
+    f.dc.bufferedAmountLowThreshold = 65536;
+
+    async function write(a) {
+        while(f.dc.bufferedAmount > f.dc.bufferedAmountLowThreshold) {
+            await new Promise((resolve, reject) => {
+                if(!f.dc) {
+                    reject(new Error('File is closed.'));
+                    return;
+                }
+                f.dc.onbufferedamountlow = function(e) {
+                    if(!f.dc) {
+                        reject(new Error('File is closed.'));
+                        return;
+                    }
+                    f.dc.onbufferedamountlow = null;
+                    resolve();
+                }
+            });
+        }
+        f.dc.send(a);
+        f.datalen += a.length;
+        // we're already in the connected state, but invoke callbacks to
+        // that the application can display progress
+        f.event('connected');
+    }
+
+    while(true) {
+        let v = await r.read();
+        if(v.done)
+            break;
+        let data = v.value;
+        if(!(data instanceof Uint8Array))
+            throw new Error('Unexpected type for chunk');
+        /* Base SCTP only supports up to 16kB data chunks.  There are
+           extensions to handle larger chunks, but they don't interoperate
+           between browsers, so we chop the file into small pieces. */
+        if(data.length <= 16384) {
+            await write(data);
+        } else {
+            for(let i = 0; i < v.value.length; i += 16384) {
+                let d = new Uint8Array(
+                    data.buffer, i, Math.min(16384, data.length - i),
+                );
+                await write(d);
+            }
+        }
+    }
+}
+
+/**
+ * Called after we receive an answer.  Don't call this.
+ *
+ * @param {string} sdp
+ */
+TransferredFile.prototype.receiveFile = async function(sdp) {
+    let f = this;
+    if(f.up)
+        throw new Error('Receiving in wrong direction');
+    await f.pc.setRemoteDescription({
+        type: 'answer',
+        sdp: sdp,
+    });
+    f.event('connected');
+}
+
+/**
+ * Called whenever we receive a chunk of data.  Don't call this.
+ *
+ * @param {Blob|ArrayBuffer} data
+ */
+TransferredFile.prototype.receiveData = async function(data) {
+    let f = this;
+    if(f.up)
+        throw new Error('Receiving in wrong direction');
+    f.bufferData(data);
+
+    if(f.datalen < f.size) {
+        f.event('connected');
+        return;
+    }
+
+    f.dc.onmessage = null;
+
+    if(f.datalen != f.size) {
+        f.cancel('unexpected file size');
+        return;
+    }
+
+    let blob = f.getBufferedData();
+    f.event('done', blob);
+
+    await new Promise((resolve, reject) => {
+        let timer = setTimeout(function(e) { resolve(); }, 2000);
+        f.dc.onclose = function(e) {
+            clearTimeout(timer);
+            resolve();
+        };
+        f.dc.onerror = function(e) {
+            clearTimeout(timer);
+            resolve();
+        };
+        f.dc.send('done');
+    });
+
+    f.close();
+}
+
+/**
+ * fileTransfer handles a usermessage of kind 'filetransfer'.  Don't call
+ * this, it is called automatically as needed.
+ *
+ * @param {string} id
+ * @param {string} username
+ * @param {object} message
+ */
+ServerConnection.prototype.fileTransfer = function(id, username, message) {
+    let sc = this;
+    switch(message.type) {
+    case 'invite': {
+        let f = new TransferredFile(
+            sc, id, message.id, false, username,
+            message.name, message.mimetype, message.size,
+        );
+        f.state = 'inviting';
+
+        try {
+            if(sc.onfiletransfer)
+                sc.onfiletransfer.call(sc, f);
+            else
+                f.cancel('this client does not implement file transfer');
+        } catch(e) {
+            f.cancel(e);
+            return;
+        }
+
+        if(f.fullid() in sc.transferredFiles) {
+            console.error('Duplicate id for file transfer');
+            f.cancel("duplicate id (this shouldn't happen)");
+            return;
+        }
+        sc.transferredFiles[f.fullid()] = f;
+        break;
+    }
+    case 'offer': {
+        let f = sc.getTransferredFile(id, message.id, true);
+        if(!f) {
+            console.error('Unexpected offer for file transfer');
+            return;
+        }
+        f.answer(message.sdp).catch(e => f.cancel(e));
+        break;
+    }
+    case 'answer': {
+        let f = sc.getTransferredFile(id, message.id, false);
+        if(!f) {
+            console.error('Unexpected answer for file transfer');
+            return;
+        }
+        f.receiveFile(message.sdp).catch(e => f.cancel(e));
+        break;
+    }
+    case 'downice':
+    case 'upice': {
+        let f = sc.getTransferredFile(
+            id, message.id, message.type === 'downice',
+        );
+        if(!f || !f.pc) {
+            console.warn(`Unexpected ${message.type} for file transfer`);
+            return;
+        }
+        if(f.pc.signalingState === 'stable')
+            f.pc.addIceCandidate(message.candidate).catch(console.warn);
+        else
+            f.candidates.push(message.candidate);
+        break;
+    }
+    case 'cancel':
+    case 'reject': {
+        let f = sc.getTransferredFile(id, message.id, message.type === 'reject');
+        if(!f) {
+            console.error(`Unexpected ${message.type} for file transfer`);
+            return;
+        }
+        f.event('cancelled');
+        f.close();
+        break;
+    }
+    default:
+        console.error(`Unknown filetransfer message ${message.type}`);
+        break;
+    }
+}