Переглянути джерело

update galene protocol from v1 to v2

Dele Olajide 2 роки тому
батько
коміт
fd791c3f7f
4 змінених файлів з 700 додано та 210 видалено
  1. 545 120
      packages/galene/galene-ui.js
  2. 34 42
      packages/galene/galene.css
  3. 24 8
      packages/galene/index.html
  4. 97 40
      packages/galene/protocol.js

+ 545 - 120
packages/galene/galene-ui.js

@@ -32,6 +32,9 @@ let groupStatus = {};
 /** @type {string} */
 /** @type {string} */
 let token = null;
 let token = null;
 
 
+/** @type {boolean} */
+let connectingAgain = false;
+
 /**
 /**
  * @typedef {Object} settings
  * @typedef {Object} settings
  * @property {boolean} [localMute]
  * @property {boolean} [localMute]
@@ -289,28 +292,39 @@ function setConnected(connected) {
     } else {
     } else {
         userbox.classList.add('invisible');
         userbox.classList.add('invisible');
         connectionbox.classList.remove('invisible');
         connectionbox.classList.remove('invisible');
-        displayError('Disconnected', 'error');
+        if(!connectingAgain)
+            displayError('Disconnected', 'error');
         hideVideo();
         hideVideo();
         window.onresize = null;
         window.onresize = null;
     }
     }
 }
 }
 
 
 /**
 /**
-  * @this {ServerConnection}
-  * @param {string} [username]
-  */
-async function gotConnected(username) {
+ * @this {ServerConnection}
+ */
+async function gotConnected() {
+    setConnected(true);
+    let again = connectingAgain;
+    connectingAgain = false;
+    await join(again);
+}
+
+/**
+ * @param {boolean} again
+ */
+async function join(again) {
+    let username = getInputElement('username').value.trim();
     let credentials;
     let credentials;
     if(token) {
     if(token) {
         credentials = {
         credentials = {
             type: 'token',
             type: 'token',
             token: token,
             token: token,
         };
         };
-        token = null;
+        if(!again)
+            // the first time around, we need to join with no username in
+            // order to give the server a chance to reply with 'need-username'.
+            username = null;
     } else {
     } else {
-        setConnected(true);
-
-        username = getInputElement('username').value.trim();
         let pw = getInputElement('password').value;
         let pw = getInputElement('password').value;
         getInputElement('password').value = '';
         getInputElement('password').value = '';
         if(!groupStatus.authServer)
         if(!groupStatus.authServer)
@@ -325,7 +339,7 @@ async function gotConnected(username) {
     }
     }
 
 
     try {
     try {
-        await this.join(group, username, credentials);
+        await serverConnection.join(group, username, credentials);
     } catch(e) {
     } catch(e) {
         console.error(e);
         console.error(e);
         displayError(e);
         displayError(e);
@@ -607,9 +621,6 @@ function mapRequest(what) {
     case 'audio':
     case 'audio':
         return {'': ['audio']};
         return {'': ['audio']};
         break;
         break;
-    case 'screenshare-low':
-        return {screenshare: ['audio','video-low'], '': ['audio']};
-        break;
     case 'screenshare':
     case 'screenshare':
         return {screenshare: ['audio','video'], '': ['audio']};
         return {screenshare: ['audio','video'], '': ['audio']};
         break;
         break;
@@ -1227,7 +1238,7 @@ function setUpStream(c, stream) {
             let bps = getMaxVideoThroughput();
             let bps = getMaxVideoThroughput();
             // Firefox doesn't like us setting the RID if we're not
             // Firefox doesn't like us setting the RID if we're not
             // simulcasting.
             // simulcasting.
-            if(simulcast) {
+            if(simulcast && c.label !== 'screenshare') {
                 encodings.push({
                 encodings.push({
                     rid: 'h',
                     rid: 'h',
                     maxBitrate: bps || unlimitedRate,
                     maxBitrate: bps || unlimitedRate,
@@ -1255,23 +1266,15 @@ function setUpStream(c, stream) {
             sendEncodings: encodings,
             sendEncodings: encodings,
         });
         });
 
 
-        // Firefox workaround
-        function match(a, b) {
-            if(!a || !b)
-                return false;
-            if(a.length !== b.length)
-                return false;
-            for(let i = 0; i < a.length; i++) {
-                if(a.maxBitrate !== b.maxBitrate)
-                    return false;
+        // Firefox before 110 does not implement sendEncodings, and
+        // requires this hack, which throws an exception on Chromium.
+        try {
+            let p = tr.sender.getParameters();
+            if(!p.encodings) {
+                p.encodings = encodings;
+                tr.sender.setParameters(p);
             }
             }
-            return true;
-        }
-
-        let p = tr.sender.getParameters();
-        if(!p || !match(p.encodings, encodings)) {
-            p.encodings = encodings;
-            tr.sender.setParameters(p);
+        } catch(e) {
         }
         }
     }
     }
 
 
@@ -2091,6 +2094,88 @@ function stringCompare(a, b) {
     return 0
     return 0
 }
 }
 
 
+/**
+ * @param {string} v
+ */
+function dateFromInput(v) {
+    let d = new Date(v);
+    if(d.toString() === 'Invalid Date')
+        throw new Error('Invalid date');
+    return d;
+}
+
+/**
+ * @param {Date} d
+ */
+function dateToInput(d) {
+    let dd = new Date(d);
+    dd.setMinutes(dd.getMinutes() - dd.getTimezoneOffset());
+    return dd.toISOString().slice(0, -1);
+}
+
+function inviteMenu() {
+    let d = /** @type {HTMLDialogElement} */
+        (document.getElementById('invite-dialog'));
+    if(!('HTMLDialogElement' in window) || !d.showModal) {
+        makeToken();
+        return;
+    }
+    d.returnValue = '';
+    let c = getButtonElement('invite-cancel');
+    c.onclick = function(e) { d.close('cancel'); };
+    let u = getInputElement('invite-username');
+    u.value = '';
+    let now = new Date();
+    now.setMilliseconds(0);
+    now.setSeconds(0);
+    let nb = getInputElement('invite-not-before');
+    nb.min = dateToInput(now);
+    let ex = getInputElement('invite-expires');
+    let expires = new Date(now);
+    expires.setDate(expires.getDate() + 2);
+    ex.min = dateToInput(expires);
+    ex.value = dateToInput(expires);
+    d.showModal();
+}
+
+document.getElementById('invite-dialog').onclose = function(e) {
+    if(!(this instanceof HTMLDialogElement))
+        throw new Error('Unexpected type for this');
+    let dialog = /** @type {HTMLDialogElement} */(this);
+    if(dialog.returnValue !== 'invite')
+        return;
+    let u = getInputElement('invite-username');
+    let username = u.value.trim() || null;
+    let nb = getInputElement('invite-not-before');
+    let notBefore = null;
+    if(nb.value) {
+        try {
+            notBefore = dateFromInput(nb.value);
+        } catch(e) {
+            displayError(`Couldn't parse ${nb.value}: ${e}`);
+            return;
+        }
+    }
+    let ex = getInputElement('invite-expires');
+    let expires = null;
+    if(ex.value) {
+        try {
+            expires = dateFromInput(ex.value);
+        } catch(e) {
+            displayError(`Couldn't parse ${nb.value}: ${e}`);
+            return;
+        }
+    }
+    let template = {}
+    if(username)
+        template.username = username;
+    if(notBefore)
+        template['not-before'] = notBefore;
+    if(expires)
+        template.expires = expires;
+    makeToken(template);
+};
+
 /**
 /**
  * @param {HTMLElement} elt
  * @param {HTMLElement} elt
  */
  */
@@ -2116,7 +2201,13 @@ function userMenu(elt) {
                     'setdata', serverConnection.id, {'raisehand': true},
                     'setdata', serverConnection.id, {'raisehand': true},
                 );
                 );
             }});
             }});
-        if(serverConnection.permissions.indexOf('present')>= 0 && canFile())
+        if(serverConnection.version !== "1" &&
+           serverConnection.permissions.indexOf('token') >= 0) {
+            items.push({label: 'Invite user', onClick: () => {
+                inviteMenu();
+            }});
+        }
+        if(serverConnection.permissions.indexOf('present') >= 0 && canFile())
             items.push({label: 'Broadcast file', onClick: presentFile});
             items.push({label: 'Broadcast file', onClick: presentFile});
         items.push({label: 'Restart media', onClick: renegotiateStreams});
         items.push({label: 'Restart media', onClick: renegotiateStreams});
     } else {
     } else {
@@ -2318,29 +2409,42 @@ function setTitle(title) {
  * @param {Array<string>} perms
  * @param {Array<string>} perms
  * @param {Object<string,any>} status
  * @param {Object<string,any>} status
  * @param {Object<string,any>} data
  * @param {Object<string,any>} data
+ * @param {string} error
  * @param {string} message
  * @param {string} message
  */
  */
-async function gotJoined(kind, group, perms, status, data, message) {
+async function gotJoined(kind, group, perms, status, data, error, message) {
     let present = presentRequested;
     let present = presentRequested;
     presentRequested = null;
     presentRequested = null;
 
 
     switch(kind) {
     switch(kind) {
     case 'fail':
     case 'fail':
-        displayError('The server said: ' + message);
+        if(error === 'need-username' || error === 'duplicate-username') {
+            setVisibility('passwordform', false);
+            connectingAgain = true;
+        } else {
+            token = null;
+        }
+        if(error !== 'need-username')
+            displayError('The server said: ' + message);
         this.close();
         this.close();
         setButtonsVisibility();
         setButtonsVisibility();
         return;
         return;
     case 'redirect':
     case 'redirect':
         this.close();
         this.close();
+        token = null;
         document.location.href = message;
         document.location.href = message;
         return;
         return;
     case 'leave':
     case 'leave':
         this.close();
         this.close();
+        token = null;
         setButtonsVisibility();
         setButtonsVisibility();
         return;
         return;
     case 'join':
     case 'join':
     case 'change':
     case 'change':
-        groupStatus = status;
+        token = null;
+        // don't discard endPoint and friends
+        for(let key in status)
+            groupStatus[key] = status[key];
         setTitle((status && status.displayName) || capitalise(group));
         setTitle((status && status.displayName) || capitalise(group));
         displayUsername();
         displayUsername();
         setButtonsVisibility();
         setButtonsVisibility();
@@ -2348,11 +2452,14 @@ async function gotJoined(kind, group, perms, status, data, message) {
             return;
             return;
         break;
         break;
     default:
     default:
+        token = null;
         displayError('Unknown join message');
         displayError('Unknown join message');
         this.close();
         this.close();
         return;
         return;
     }
     }
 
 
+    token = null;
+
     let input = /** @type{HTMLTextAreaElement} */
     let input = /** @type{HTMLTextAreaElement} */
         (document.getElementById('input'));
         (document.getElementById('input'));
     input.placeholder = 'Type /help for help';
     input.placeholder = 'Type /help for help';
@@ -2419,12 +2526,15 @@ function gotFileTransfer(f) {
         f.cancel();
         f.cancel();
     };
     };
     bno.id = "bno-" + f.fullid();
     bno.id = "bno-" + f.fullid();
-    let status = document.createElement('div');
+    let status = document.createElement('span');
     status.id = 'status-' + f.fullid();
     status.id = 'status-' + f.fullid();
     if(!f.up) {
     if(!f.up) {
         status.textContent =
         status.textContent =
             '(Choosing "Accept" will disclose your IP address.)';
             '(Choosing "Accept" will disclose your IP address.)';
     }
     }
+    let statusp = document.createElement('p');
+    statusp.id = 'statusp-' + f.fullid();
+    statusp.appendChild(status);
     let div = document.createElement('div');
     let div = document.createElement('div');
     div.id = 'file-' + f.fullid();
     div.id = 'file-' + f.fullid();
     div.appendChild(p);
     div.appendChild(p);
@@ -2432,7 +2542,7 @@ function gotFileTransfer(f) {
         div.appendChild(byes);
         div.appendChild(byes);
     if(bno)
     if(bno)
         div.appendChild(bno);
         div.appendChild(bno);
-    div.appendChild(status);
+    div.appendChild(statusp);
     div.classList.add('message');
     div.classList.add('message');
     div.classList.add('message-private');
     div.classList.add('message-private');
     div.classList.add('message-row');
     div.classList.add('message-row');
@@ -2444,28 +2554,74 @@ function gotFileTransfer(f) {
 /**
 /**
  * @param {TransferredFile} f
  * @param {TransferredFile} f
  * @param {string} status
  * @param {string} status
- * @param {boolean} [delyes]
- * @param {boolean} [delno]
+ * @param {number} [value]
  */
  */
-function setFileStatus(f, status, delyes, delno) {
-    let statusdiv = document.getElementById('status-' + f.fullid());
-    if(!statusdiv)
-        throw new Error("Couldn't find statusdiv");
-    statusdiv.textContent = status;
-    if(delyes || delno) {
-        let div = document.getElementById('file-' + f.fullid());
-        if(!div)
-            throw new Error("Couldn't find file div");
-        if(delyes) {
-            let byes = document.getElementById('byes-' + f.fullid())
-            if(byes)
-                div.removeChild(byes);
-        }
-        if(delno) {
-            let bno = document.getElementById('bno-' + f.fullid())
-            if(bno)
-                div.removeChild(bno);
-        }
+function setFileStatus(f, status, value) {
+    let statuselt = document.getElementById('status-' + f.fullid());
+    if(!statuselt)
+        throw new Error("Couldn't find statusp");
+    statuselt.textContent = status;
+    if(value) {
+        let progress = document.getElementById('progress-' + f.fullid());
+         if(!progress || !(progress instanceof HTMLProgressElement))
+            throw new Error("Couldn't find progress element");
+        progress.value = value;
+        let label = document.getElementById('progresstext-' + f.fullid());
+        let percent = Math.round(100 * value / progress.max);
+        label.textContent = `${percent}%`;
+    }
+}
+
+/**
+ * @param {TransferredFile} f
+ * @param {number} [max]
+ */
+function createFileProgress(f, max) {
+    let statusp = document.getElementById('statusp-' + f.fullid());
+    if(!statusp)
+        throw new Error("Couldn't find status div");
+    /** @type HTMLProgressElement */
+    let progress = document.createElement('progress');
+    progress.id = 'progress-' + f.fullid();
+    progress.classList.add('file-progress');
+    progress.max = max;
+    progress.value = 0;
+    statusp.appendChild(progress);
+    let progresstext = document.createElement('span');
+    progresstext.id = 'progresstext-' + f.fullid();
+    progresstext.textContent = '0%';
+    statusp.appendChild(progresstext);
+}
+
+/**
+ * @param {TransferredFile} f
+ * @param {boolean} delyes
+ * @param {boolean} delno
+ * @param {boolean} [delprogress]
+ */
+function delFileStatusButtons(f, delyes, delno, delprogress) {
+    let div = document.getElementById('file-' + f.fullid());
+    if(!div)
+        throw new Error("Couldn't find file div");
+    if(delyes) {
+        let byes = document.getElementById('byes-' + f.fullid())
+        if(byes)
+            div.removeChild(byes);
+    }
+    if(delno) {
+        let bno = document.getElementById('bno-' + f.fullid())
+        if(bno)
+            div.removeChild(bno);
+    }
+    if(delprogress) {
+        let statusp = document.getElementById('statusp-' + f.fullid());
+        let progress = document.getElementById('progress-' + f.fullid());
+        let progresstext =
+            document.getElementById('progresstext-' + f.fullid());
+        if(progress)
+            statusp.removeChild(progress);
+        if(progresstext)
+            statusp.removeChild(progresstext);
     }
     }
 }
 }
 
 
@@ -2480,16 +2636,16 @@ function gotFileTransferEvent(state, data) {
     case 'inviting':
     case 'inviting':
         break;
         break;
     case 'connecting':
     case 'connecting':
-        setFileStatus(f, 'Connecting...', true);
+        delFileStatusButtons(f, true, false);
+        setFileStatus(f, 'Connecting...');
+        createFileProgress(f, f.size);
         break;
         break;
     case 'connected':
     case 'connected':
-        if(f.up)
-            setFileStatus(f, `Sending... ${f.datalen}/${f.size}`);
-        else
-            setFileStatus(f, `Receiving... ${f.datalen}/${f.size}`);
+        setFileStatus(f, f.up ? 'Sending...' : 'Receiving...', f.datalen);
         break;
         break;
     case 'done':
     case 'done':
-        setFileStatus(f, 'Done.', true, true);
+        delFileStatusButtons(f, true, true, true);
+        setFileStatus(f, 'Done.');
         if(!f.up) {
         if(!f.up) {
             let url = URL.createObjectURL(data);
             let url = URL.createObjectURL(data);
             let a = document.createElement('a');
             let a = document.createElement('a');
@@ -2502,10 +2658,11 @@ function gotFileTransferEvent(state, data) {
         }
         }
         break;
         break;
     case 'cancelled':
     case 'cancelled':
+        delFileStatusButtons(f, true, true, true);
         if(data)
         if(data)
-            setFileStatus(f, `Cancelled: ${data.toString()}.`, true, true);
+            setFileStatus(f, `Cancelled: ${data.toString()}.`);
         else
         else
-            setFileStatus(f, 'Cancelled.', true, true);
+            setFileStatus(f, 'Cancelled.');
         break;
         break;
     case 'closed':
     case 'closed':
         break;
         break;
@@ -2520,38 +2677,83 @@ function gotFileTransferEvent(state, data) {
  * @param {string} id
  * @param {string} id
  * @param {string} dest
  * @param {string} dest
  * @param {string} username
  * @param {string} username
- * @param {number} time
+ * @param {Date} time
  * @param {boolean} privileged
  * @param {boolean} privileged
  * @param {string} kind
  * @param {string} kind
+ * @param {string} error
  * @param {any} message
  * @param {any} message
  */
  */
-function gotUserMessage(id, dest, username, time, privileged, kind, message) {
+function gotUserMessage(id, dest, username, time, privileged, kind, error, message) {
     switch(kind) {
     switch(kind) {
     case 'kicked':
     case 'kicked':
     case 'error':
     case 'error':
     case 'warning':
     case 'warning':
     case 'info':
     case 'info':
-        let from = id ? (username || 'Anonymous') : 'The Server';
-        if(privileged)
-            displayError(`${from} said: ${message}`, kind);
-        else
+        if(!privileged) {
             console.error(`Got unprivileged message of kind ${kind}`);
             console.error(`Got unprivileged message of kind ${kind}`);
+            return;
+        }
+        let from = id ? (username || 'Anonymous') : 'The Server';
+        displayError(`${from} said: ${message}`, kind);
         break;
         break;
     case 'mute':
     case 'mute':
-        if(privileged) {
-            setLocalMute(true, true);
-            let by = username ? ' by ' + username : '';
-            displayWarning(`You have been muted${by}`);
-        } else {
+        if(!privileged) {
             console.error(`Got unprivileged message of kind ${kind}`);
             console.error(`Got unprivileged message of kind ${kind}`);
+            return;
         }
         }
+        setLocalMute(true, true);
+        let by = username ? ' by ' + username : '';
+        displayWarning(`You have been muted${by}`);
         break;
         break;
     case 'clearchat':
     case 'clearchat':
-        if(privileged) {
-            clearChat();
-        } else {
+        if(!privileged) {
+            console.error(`Got unprivileged message of kind ${kind}`);
+            return;
+        }
+        clearChat();
+        break;
+    case 'token':
+        if(!privileged) {
             console.error(`Got unprivileged message of kind ${kind}`);
             console.error(`Got unprivileged message of kind ${kind}`);
+            return;
+        }
+        if(error) {
+            displayError(`Token operation failed: ${message}`)
+            return
+        }
+        if(typeof message != 'object') {
+            displayError('Unexpected type for token');
+            return;
+        }
+        let f = formatToken(message, false);
+        localMessage(f[0] + ': ' + f[1]);
+        if('share' in navigator) {
+            try {
+                navigator.share({
+                    title: `Invitation to Galene group ${message.group}`,
+                    text: f[0],
+                    url: f[1],
+                });
+            } catch(e) {
+                console.warn("Share failed", e);
+            }
+        }
+        break;
+    case 'tokenlist':
+        if(!privileged) {
+            console.error(`Got unprivileged message of kind ${kind}`);
+            return;
+        }
+        if(error) {
+            displayError(`Token operation failed: ${message}`)
+            return
+        }
+        let s = '';
+        for(let i = 0; i < message.length; i++) {
+            let f = formatToken(message[i], true);
+            s = s + f[0] + ': ' + f[1] + "\n";
         }
         }
+        localMessage(s);
         break;
         break;
     default:
     default:
         console.warn(`Got unknown user message ${kind}`);
         console.warn(`Got unknown user message ${kind}`);
@@ -2559,6 +2761,48 @@ function gotUserMessage(id, dest, username, time, privileged, kind, message) {
     }
     }
 };
 };
 
 
+/**
+ * @param {Object} token
+ * @param {boolean} [details]
+ */
+function formatToken(token, details) {
+    let url = new URL(window.location.href);
+    let params = new URLSearchParams();
+    params.append('token', token.token);
+    url.search = params.toString();
+    let foruser = '', by = '', togroup = '';
+    if(token.username)
+        foruser = ` for user ${token.username}`;
+    if(details) {
+        if(token.issuedBy)
+            by = ' issued by ' + token.issuedBy;
+        if(token.issuedAt) {
+            if(by === '')
+                by = ' issued at ' + token.issuedAt;
+            else
+                by = by + ' at ' + (new Date(token.issuedAt)).toLocaleString();
+        }
+    } else {
+        if(token.group)
+            togroup = ' to group ' + token.group;
+    }
+    let since = '';
+    if(token["not-before"])
+        since = ` since ${(new Date(token['not-before'])).toLocaleString()}`
+    /** @type{Date} */
+    let expires = null;
+    let until = '';
+    if(token.expires) {
+        expires = new Date(token.expires)
+        until = ` until ${expires.toLocaleString()}`;
+    }
+    return [
+        (expires && (expires >= new Date())) ?
+            `Invitation${foruser}${togroup}${by} valid${since}${until}` :
+            `Expired invitation${foruser}${togroup}${by}`,
+        url.toString(),
+    ];
+}
 
 
 const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#&()=?]+[-a-zA-Z0-9@:%/_\\+~#&()=]/g;
 const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#&()=?]+[-a-zA-Z0-9@:%/_\\+~#&()=]/g;
 
 
@@ -2605,16 +2849,15 @@ function formatLines(lines) {
 }
 }
 
 
 /**
 /**
- * @param {number} time
+ * @param {Date} time
  * @returns {string}
  * @returns {string}
  */
  */
 function formatTime(time) {
 function formatTime(time) {
-    let delta = Date.now() - time;
-    let date = new Date(time);
-    let m = date.getMinutes();
+    let delta = Date.now() - time.getTime();
+    let m = time.getMinutes();
     if(delta > -30000)
     if(delta > -30000)
-        return date.getHours() + ':' + ((m < 10) ? '0' : '') + m;
-    return date.toLocaleString();
+        return time.getHours() + ':' + ((m < 10) ? '0' : '') + m;
+    return time.toLocaleString();
 }
 }
 
 
 /**
 /**
@@ -2622,7 +2865,7 @@ function formatTime(time) {
  * @property {string} [nick]
  * @property {string} [nick]
  * @property {string} [peerId]
  * @property {string} [peerId]
  * @property {string} [dest]
  * @property {string} [dest]
- * @property {number} [time]
+ * @property {Date} [time]
  */
  */
 
 
 /** @type {lastMessage} */
 /** @type {lastMessage} */
@@ -2632,7 +2875,7 @@ let lastMessage = {};
  * @param {string} peerId
  * @param {string} peerId
  * @param {string} dest
  * @param {string} dest
  * @param {string} nick
  * @param {string} nick
- * @param {number} time
+ * @param {Date} time
  * @param {boolean} privileged
  * @param {boolean} privileged
  * @param {boolean} history
  * @param {boolean} history
  * @param {string} kind
  * @param {string} kind
@@ -2662,7 +2905,7 @@ function addToChatbox(peerId, dest, nick, time, privileged, history, kind, messa
            !time || !lastMessage.time) {
            !time || !lastMessage.time) {
             doHeader = true;
             doHeader = true;
         } else {
         } else {
-            let delta = time - lastMessage.time;
+            let delta = time.getTime() - lastMessage.time.getTime();
             doHeader = delta < 0 || delta > 60000;
             doHeader = delta < 0 || delta > 60000;
         }
         }
 
 
@@ -2725,7 +2968,7 @@ function addToChatbox(peerId, dest, nick, time, privileged, history, kind, messa
  * @param {string} message
  * @param {string} message
  */
  */
 function localMessage(message) {
 function localMessage(message) {
-    return addToChatbox(null, null, null, Date.now(), false, false, '', message);
+    return addToChatbox(null, null, null, new Date(), false, false, '', message);
 }
 }
 
 
 function clearChat() {
 function clearChat() {
@@ -2878,6 +3121,151 @@ commands.subgroups = {
     }
     }
 };
 };
 
 
+/**
+ * @type {Object<string,number>}
+ */
+const units = {
+    s: 1000,
+    min: 60 * 1000,
+    h: 60 * 60 * 1000,
+    d: 24 * 60 * 60 * 1000,
+    mon: 31 * 24 * 60 * 60 * 1000,
+    yr: 365 * 24 * 60 * 60 * 1000,
+};
+
+/**
+ * @param {string} s
+ * @returns {Date|number}
+ */
+function parseExpiration(s) {
+    if(!s)
+        return null;
+    let re = /^([0-9]+)(s|min|h|d|yr)$/
+    let e = re.exec(s)
+    if(e) {
+        let unit = units[e[2]];
+        if(!unit)
+            throw new Error(`Couldn't find unit ${e[2]}`);
+        return parseInt(e[1]) * unit;
+    }
+    let d = new Date(s);
+    if(d.toString() === 'Invalid Date')
+        throw new Error("Couldn't parse expiration date");
+    return d;
+}
+
+function protocol2Predicate() {
+    if(serverConnection.version === "1")
+        return "This server is too old";
+    return null;
+}
+
+function makeTokenPredicate() {
+    return protocol2Predicate() ||
+        (serverConnection.permissions.indexOf('token') < 0 ?
+         "You don't have permission to create tokens" : null);
+}
+
+function editTokenPredicate() {
+    return protocol2Predicate() ||
+        (serverConnection.permissions.indexOf('token') < 0 ||
+         serverConnection.permissions.indexOf('op') < 0 ?
+         "You don't have permission to edit or list tokens" : null);
+}
+
+/**
+ * @param {Object} [template]
+ */
+function makeToken(template) {
+    if(!template)
+        template = {};
+    let v = {
+        group: group,
+    }
+    if('username' in template)
+        v.username = template.username;
+    if('expires' in template)
+        v.expires = template.expires;
+    else
+        v.expires = units.d;
+    if('not-before' in template)
+        v["not-before"] = template["not-before"];
+    if('permissions' in template)
+        v.permissions = template.permissions;
+    else if(serverConnection.permissions.indexOf('present') >= 0)
+        v.permissions = ['present'];
+    else
+        v.permissions = [];
+    serverConnection.groupAction('maketoken', v);
+}
+
+commands.invite = {
+    predicate: makeTokenPredicate,
+    description: "create an invitation link",
+    parameters: "[username] [expiration]",
+    f: (c, r) => {
+        let p = parseCommand(r);
+        let template = {};
+        if(p[0])
+            template.username = p[0];
+        let expires = parseExpiration(p[1]);
+        if(expires)
+            template.expires = expires;
+        makeToken(template);
+    }
+}
+
+/**
+ * @param {string} t
+ */
+function parseToken(t) {
+    let m = /^https?:\/\/.*?token=([^?]+)/.exec(t);
+    if(m) {
+        return m[1];
+    } else if(!/^https?:\/\//.exec(t)) {
+        return t
+    } else {
+        throw new Error("Couldn't parse link");
+    }
+}
+
+commands.reinvite = {
+    predicate: editTokenPredicate,
+    description: "extend an invitation link",
+    parameters: "link [expiration]",
+    f: (c, r) => {
+        let p = parseCommand(r);
+        let v = {}
+        v.token = parseToken(p[0]);
+        if(p[1])
+            v.expires = parseExpiration(p[1]);
+        else
+            v.expires = units.d;
+        serverConnection.groupAction('edittoken', v);
+    }
+}
+
+commands.revoke = {
+    predicate: editTokenPredicate,
+    description: "revoke an invitation link",
+    parameters: "link",
+    f: (c, r) => {
+        let token = parseToken(r);
+        serverConnection.groupAction('edittoken', {
+            token: token,
+            expires: -units.s,
+        });
+    }
+}
+
+commands.listtokens = {
+    predicate: editTokenPredicate,
+    description: "list invitation links",
+    f: (c, r) => {
+        serverConnection.groupAction('listtokens');
+    }
+}
+
 function renegotiateStreams() {
 function renegotiateStreams() {
     for(let id in serverConnection.up)
     for(let id in serverConnection.up)
         serverConnection.up[id].restartIce();
         serverConnection.up[id].restartIce();
@@ -2960,7 +3348,7 @@ commands.msg = {
             throw new Error(`Unknown user ${p[0]}`);
             throw new Error(`Unknown user ${p[0]}`);
         serverConnection.chat('', id, p[1]);
         serverConnection.chat('', id, p[1]);
         addToChatbox(serverConnection.id, id, serverConnection.username,
         addToChatbox(serverConnection.id, id, serverConnection.username,
-                     Date.now(), false, false, '', p[1]);
+                     new Date(), false, false, '', p[1]);
     }
     }
 };
 };
 
 
@@ -3395,12 +3783,8 @@ document.getElementById('userform').onsubmit = async function(e) {
     e.preventDefault();
     e.preventDefault();
     if(connecting)
     if(connecting)
         return;
         return;
-    connecting = true;
-    try {
-        await serverConnect();
-    } finally {
-        connecting = false;
-    }
+
+    setVisibility('passwordform', true);
 
 
     if(getInputElement('presentboth').checked)
     if(getInputElement('presentboth').checked)
         presentRequested = 'both';
         presentRequested = 'both';
@@ -3408,8 +3792,15 @@ document.getElementById('userform').onsubmit = async function(e) {
         presentRequested = 'mike';
         presentRequested = 'mike';
     else
     else
         presentRequested = null;
         presentRequested = null;
-
     getInputElement('presentoff').checked = true;
     getInputElement('presentoff').checked = true;
+
+    // Connect to the server, gotConnected will join.
+    connecting = true;
+    try {
+        await serverConnect();
+    } finally {
+        connecting = false;
+    }
 };
 };
 
 
 document.getElementById('disconnectbutton').onclick = function(e) {
 document.getElementById('disconnectbutton').onclick = function(e) {
@@ -3489,7 +3880,7 @@ async function serverConnect() {
     serverConnection.onusermessage = gotUserMessage;
     serverConnection.onusermessage = gotUserMessage;
     serverConnection.onfiletransfer = gotFileTransfer;
     serverConnection.onfiletransfer = gotFileTransfer;
 
 
-    let url = null; //groupStatus.endpoint;
+    let url = groupStatus.endpoint;
     if(!url) {
     if(!url) {
         console.warn("no endpoint in status");
         console.warn("no endpoint in status");
         url = `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`;
         url = `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`;
@@ -3532,14 +3923,16 @@ async function start() {
     addFilters();
     addFilters();
     setMediaChoices(false).then(e => reflectSettings());
     setMediaChoices(false).then(e => reflectSettings());
 
 
-    if(parms.has('token')) {
+    if(parms.has('token'))
         token = parms.get('token');
         token = parms.get('token');
+
+    if(token) {
         await serverConnect();
         await serverConnect();
     } else if(groupStatus.authPortal) {
     } else if(groupStatus.authPortal) {
         window.location.href = groupStatus.authPortal;
         window.location.href = groupStatus.authPortal;
     } else {
     } else {
-        let container = document.getElementById("login-container");
-        container.classList.remove('invisible');
+        setVisibility('login-container', true);
+        document.getElementById('username').focus()
     }
     }
     setViewportHeight();
     setViewportHeight();
 }
 }
@@ -3547,6 +3940,9 @@ async function start() {
 // BAO
 // BAO
 //start();
 //start();
 
 
+
+let connection = window.top?.connection, standalone = false;
+
 window.onload = async function() {	
 window.onload = async function() {	
 
 
 	document.getElementById('closebutton').onclick = async function(e) {
 	document.getElementById('closebutton').onclick = async function(e) {
@@ -3595,27 +3991,49 @@ window.onload = async function() {
     serverConnection.onjoined = gotJoined;
     serverConnection.onjoined = gotJoined;
     serverConnection.onchat = addToChatbox;
     serverConnection.onchat = addToChatbox;
     serverConnection.onusermessage = gotUserMessage;
     serverConnection.onusermessage = gotUserMessage;
-
-	const host = urlParam("host");
-	const username = urlParam("username");	
-    const password = urlParam("password");
+    serverConnection.onfiletransfer = gotFileTransfer;	
 	
 	
-	group = "public/" + urlParam("group");	
+	group = urlParam("group") ? "public/" + urlParam("group") : "public";	
 	setTitle(capitalise(group));
 	setTitle(capitalise(group));
     addFilters();
     addFilters();
     setMediaChoices(false).then(e => reflectSettings());	
     setMediaChoices(false).then(e => reflectSettings());	
+	handleConnection();
+}
 
 
-    const connection = window.top?.connection;
-	console.debug("onload", connection, host, username);	
+async function handleConnection() {
+	const host = urlParam("host") || location.hostname;
+	const username = urlParam("username");	
+	
+	console.debug("handleConnection", connection, host, username);	
 	
 	
 	if (connection) {
 	if (connection) {
 		await serverConnection.connect(connection, host);
 		await serverConnection.connect(connection, host);
-		setViewportHeight();			
-	}
+		setViewportHeight();	
+		
+	} else {
+		standalone = true;
+		connection = new Strophe.Connection((location.protocol == "https:" ? "wss:" : "ws:") + "//" + location.host + "/ws/");
+		
+		connection.connect(host, null, async function (status) {
+			console.debug("onload xmpp.connect", status);
+
+			if (status === Strophe.Status.CONNECTED) {
+				connection.send($pres());
+				await serverConnection.connect(connection, host);
+				setViewportHeight();					
+			}
+			else
+
+			if (status === Strophe.Status.DISCONNECTED) {
+
+			}
+		});		
+	}	
 }
 }
 
 
 window.onunload = async function() {	
 window.onunload = async function() {	
 	serverConnection.close();
 	serverConnection.close();
+	if (standalone && connection) connection.disconnect();
 }
 }
 
 
 function urlParam (name) {
 function urlParam (name) {
@@ -3624,17 +4042,23 @@ function urlParam (name) {
 	return unescape(results[1] || undefined);
 	return unescape(results[1] || undefined);
 }
 }
 
 
-async function amConnected() {
-	console.debug("amConnected");		
-	const username = urlParam("username");
+function amConnected() {
+	console.debug("amConnected");	
+    setConnected(true);	
+	connectingAgain = false;
+	
+	const username = urlParam("username") || localStorage.getItem("galene_username") || prompt("Enter username");
 	const pw = "";
 	const pw = "";
-
-    try {
-        await serverConnection.join(group, username, pw);
-    } catch(e) {
-        console.error(e);
-        serverConnection.close();
-    }
+	
+	setTimeout( async() => {
+		try {
+			await serverConnection.join(group, username, pw);
+			localStorage.setItem("galene_username", username);
+		} catch(e) {
+			console.error(e);
+			serverConnection.close();
+		}
+	}, 2000);
 }
 }
 
 
 function closeConnection() {
 function closeConnection() {
@@ -3643,3 +4067,4 @@ function closeConnection() {
 		location.href = "about:blank";		
 		location.href = "about:blank";		
 	}, 1000);	
 	}, 1000);	
 }
 }
+

+ 34 - 42
packages/galene/galene.css

@@ -905,6 +905,10 @@ h1 {
     border: 2px solid #610a86;
     border: 2px solid #610a86;
 }
 }
 
 
+.peer-hidden {
+    display: none;
+}
+
 .media {
 .media {
     width: 100%;
     width: 100%;
     max-height: calc(var(--vh, 1vh) * 100 - 76px);
     max-height: calc(var(--vh, 1vh) * 100 - 76px);
@@ -1057,48 +1061,6 @@ legend {
     100% { box-shadow: 0 0 15px #600aa0; }
     100% { box-shadow: 0 0 15px #600aa0; }
 }
 }
 
 
-/*   Dropdown Menu */
-.dropbtn {
-    cursor: pointer;
-}
-
-.dropdown {
-    position: relative;
-    display: inline-block;
-}
-
-.dropdown-content {
-    display: none;
-    position: absolute;
-    background-color: #fff;
-    max-width: 300px;
-    min-width: 200px;
-    margin-top: 7px;
-    overflow: auto;
-    right: 7px;
-    box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
-    z-index: 1;
-    padding: 15px;
-}
-
-.dropdown-content a {
-    color: black;
-    padding: 12px 16px;
-    text-decoration: none;
-    display: block;
-}
-
-.dropdown a:hover {background-color: #ddd;}
-
-.show {display: block;}
-
-.dropdown-content label{
-    display: block;
-    margin-top: 15px;
-}
-
-/*  END Dropdown Menu */
-
 /*  Sidebar left */
 /*  Sidebar left */
 
 
 #left-sidebar {
 #left-sidebar {
@@ -1198,6 +1160,22 @@ header .collapse:hover {
     content: "\f256";
     content: "\f256";
 }
 }
 
 
+#users > div::after {
+    font-family: 'Font Awesome 6 Free';
+    color: #808080;
+    margin-left: 5px;
+    font-weight: 900;
+    float: right;
+}
+
+#users > div.user-status-microphone::after {
+    content: "\f130";
+}
+
+#users > div.user-status-camera::after {
+    content: "\f030";
+}
+
 .close-icon {
 .close-icon {
     font: normal 1em/1 Arial, sans-serif;
     font: normal 1em/1 Arial, sans-serif;
     display: inline-block;
     display: inline-block;
@@ -1349,3 +1327,17 @@ header .collapse:hover {
     --contextualOverflowIcon: #999;
     --contextualOverflowIcon: #999;
     --contextualSeperator: #999;
     --contextualSeperator: #999;
 }
 }
+
+.contextualMenu{
+    z-index: 2999;
+}
+
+.file-progress {
+    accent-color: #610a86;
+    margin-left: 10px;
+    margin-right: 10px;
+}
+
+#invite-dialog {
+    background-color: #eee;
+}

+ 24 - 8
packages/galene/index.html

@@ -5,7 +5,7 @@
     <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">
-	<!-- BAO -->
+	<!-- BAO -->	
     <link rel="stylesheet" type="text/css" href="./common.css"/> 
     <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"/>
@@ -63,7 +63,7 @@
                     <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>
                   </div>
                   </div>
-                </li>				
+                </li>					
                 <li>
                 <li>
                   <div class="nav-button nav-link nav-more" id="openside">
                   <div class="nav-button nav-link nav-more" id="openside">
                     <span><i class="fas fa-ellipsis-v" aria-hidden="true"></i></span>
                     <span><i class="fas fa-ellipsis-v" aria-hidden="true"></i></span>
@@ -111,9 +111,11 @@
                     <label for="username">Username</label>
                     <label for="username">Username</label>
                     <input id="username" type="text" name="username"
                     <input id="username" type="text" name="username"
                            autocomplete="username" class="form-control"/>
                            autocomplete="username" class="form-control"/>
-                    <label for="password">Password</label>
-                    <input id="password" type="password" name="password"
-                           autocomplete="current-password" class="form-control"/>
+                    <div id="passwordform">
+                      <label for="password">Password</label>
+                      <input id="password" type="password" name="password"
+                             autocomplete="current-password" class="form-control"/>
+                    </div>
                     <label>Enable at start:</label>
                     <label>Enable at start:</label>
                     <div class="present-switch">
                     <div class="present-switch">
                       <p class="switch-radio">
                       <p class="switch-radio">
@@ -236,9 +238,8 @@
             <select id="requestselect" class="select select-inline">
             <select id="requestselect" class="select select-inline">
               <option value="">nothing</option>
               <option value="">nothing</option>
               <option value="audio">audio only</option>
               <option value="audio">audio only</option>
-              <option value="screenshare-low">screen share (low)</option>
-              <option value="screenshare">screen share</option>
-              <option value="everything-low">everything (low)</option>
+              <option value="screenshare">screenshare only</option>
+              <option value="everything-low">low quality</option>
               <option value="everything" selected>everything</option>
               <option value="everything" selected>everything</option>
             </select>
             </select>
           </form>
           </form>
@@ -286,6 +287,21 @@
       </div>
       </div>
     </div>
     </div>
 
 
+    <dialog id="invite-dialog">
+      <form method="dialog">
+        <label for="invite-username">Username (optional):</label>
+        <input id="invite-username" type="text"/>
+        <br>
+        <label for="invite-not-before">Not before:</label>
+        <input id="invite-not-before" type="datetime-local"/>
+        <br>
+        <label for="invite-expires">Expires:</label>
+        <input id="invite-expires" type="datetime-local"/>
+        <br>
+        <button id="invite-cancel" value="cancel" type="button">Cancel</button>
+        <button value="invite" value="invite">Invite</button>
+    </dialog>
+	<!-- BAO -->	
     <script src="./protocol.js"></script>
     <script src="./protocol.js"></script>
     <script src="./stophe.min.js"></script>	
     <script src="./stophe.min.js"></script>	
     <script src="./galene-socket.js"></script>		
     <script src="./galene-socket.js"></script>		

+ 97 - 40
packages/galene/protocol.js

@@ -106,6 +106,12 @@ function ServerConnection() {
      * @type {WebSocket}
      * @type {WebSocket}
      */
      */
     this.socket = null;
     this.socket = null;
+    /**
+     * The negotiated protocol version.
+     *
+     * @type {string}
+     */
+    this.version = null;
     /**
     /**
      * The set of all up streams, indexed by their id.
      * The set of all up streams, indexed by their id.
      *
      *
@@ -173,7 +179,7 @@ function ServerConnection() {
      *
      *
      * kind is one of 'join', 'fail', 'change' or 'leave'.
      * kind is one of 'join', 'fail', 'change' or 'leave'.
      *
      *
-     * @type{(this: ServerConnection, kind: string, group: string, permissions: Array<string>, status: Object<string,any>, data: Object<string,any>, message: string) => void}
+     * @type{(this: ServerConnection, kind: string, group: string, permissions: Array<string>, status: Object<string,any>, data: Object<string,any>, error: string, message: string) => void}
      */
      */
     this.onjoined = null;
     this.onjoined = null;
     /**
     /**
@@ -187,7 +193,7 @@ function ServerConnection() {
     /**
     /**
      * onchat is called whenever a new chat message is received.
      * onchat is called whenever a new chat message is received.
      *
      *
-     * @type {(this: ServerConnection, id: string, dest: string, username: string, time: number, privileged: boolean, history: boolean, kind: string, message: unknown) => void}
+     * @type {(this: ServerConnection, id: string, dest: string, username: string, time: Date, privileged: boolean, history: boolean, kind: string, message: unknown) => void}
      */
      */
     this.onchat = null;
     this.onchat = null;
     /**
     /**
@@ -199,7 +205,7 @@ function ServerConnection() {
      * 'id' is non-null, 'privileged' indicates whether the message was
      * 'id' is non-null, 'privileged' indicates whether the message was
      * sent by an operator.
      * sent by an operator.
      *
      *
-     * @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: Date, privileged: boolean, kind: string, error: string, message: unknown) => void}
      */
      */
     this.onusermessage = null;
     this.onusermessage = null;
     /**
     /**
@@ -225,6 +231,7 @@ function ServerConnection() {
   * @property {string} type
   * @property {string} type
   * @property {Array<string>} [version]
   * @property {Array<string>} [version]
   * @property {string} [kind]
   * @property {string} [kind]
+  * @property {string} [error]
   * @property {string} [id]
   * @property {string} [id]
   * @property {string} [replace]
   * @property {string} [replace]
   * @property {string} [source]
   * @property {string} [source]
@@ -239,6 +246,7 @@ function ServerConnection() {
   * @property {string} [group]
   * @property {string} [group]
   * @property {unknown} [value]
   * @property {unknown} [value]
   * @property {boolean} [noecho]
   * @property {boolean} [noecho]
+  * @property {string|number} [time]
   * @property {string} [sdp]
   * @property {string} [sdp]
   * @property {RTCIceCandidate} [candidate]
   * @property {RTCIceCandidate} [candidate]
   * @property {string} [label]
   * @property {string} [label]
@@ -274,15 +282,17 @@ ServerConnection.prototype.send = function(m) {
  * @returns {Promise<ServerConnection>}
  * @returns {Promise<ServerConnection>}
  * @function
  * @function
  */
  */
- // BAO
-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 WebSocket(url);
 	// BAO
 	// BAO
-	sc.socket = new GaleneSocket(connection, host);
+	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) {
@@ -291,7 +301,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"],
+                version: ["2", "1"],
                 id: sc.id,
                 id: sc.id,
             });
             });
             if(sc.onconnected)
             if(sc.onconnected)
@@ -314,7 +324,7 @@ ServerConnection.prototype.connect = async function(connection, host) {
                     sc.onuser.call(sc, id, 'delete');
                     sc.onuser.call(sc, id, 'delete');
             }
             }
             if(sc.group && sc.onjoined)
             if(sc.group && sc.onjoined)
-                sc.onjoined.call(sc, 'leave', sc.group, [], {}, {}, '');
+                sc.onjoined.call(sc, 'leave', sc.group, [], {}, {}, '', '');
             sc.group = null;
             sc.group = null;
             sc.username = null;
             sc.username = null;
             if(sc.onclose)
             if(sc.onclose)
@@ -323,11 +333,25 @@ ServerConnection.prototype.connect = async function(connection, host) {
         };
         };
         this.socket.onmessage = function(e) {
         this.socket.onmessage = function(e) {
             let m = JSON.parse(e.data);
             let m = JSON.parse(e.data);
+			console.debug("socket.onmessage", m);			
             switch(m.type) {
             switch(m.type) {
-            case 'handshake':
-                if(!m.version || !m.version.includes('1'))
-                    console.warn(`Unexpected protocol version ${m.version}.`);
+            case 'handshake': {
+                /** @type {string} */
+                let v;
+                if(!m.version || !(m.version instanceof Array) ||
+                   m.version.length < 1 || typeof(m.version[0]) !== 'string') {
+                    v = null;
+                } else {
+                    v = m.version[0];
+                }
+                if(v === "1" || v === "2") {
+                    sc.version = v;
+                } else {
+                    console.warn(`Unknown protocol version ${v || m.version}`);
+                    sc.version = "1"
+                }
                 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,
                             m.sdp, m.replace);
                             m.sdp, m.replace);
@@ -348,28 +372,32 @@ ServerConnection.prototype.connect = async function(connection, host) {
                 sc.gotRemoteIce(m.id, m.candidate);
                 sc.gotRemoteIce(m.id, m.candidate);
                 break;
                 break;
             case 'joined':
             case 'joined':
-                if(sc.group) {
-                    if(m.group !== sc.group) {
-                        throw new Error('Joined multiple groups');
-                    }
-                } else {
-                    sc.group = m.group;
-                }
-                sc.username = m.username;
-                sc.permissions = m.permissions || [];
-                sc.rtcConfiguration = m.rtcConfiguration || null;
-                if(m.kind == 'leave') {
+                if(m.kind === 'leave' || m.kind === 'fail') {
                     for(let id in sc.users) {
                     for(let id in sc.users) {
                         delete(sc.users[id]);
                         delete(sc.users[id]);
                         if(sc.onuser)
                         if(sc.onuser)
                             sc.onuser.call(sc, id, 'delete');
                             sc.onuser.call(sc, id, 'delete');
                     }
                     }
+                    sc.username = null;
+                    sc.permissions = [];
+                    sc.rtcConfiguration = null;
+                } else if(m.kind === 'join' || m.kind == 'change') {
+                    if(m.kind === 'join' && sc.group) {
+                        throw new Error('Joined multiple groups');
+                    } else if(m.kind === 'change' && m.group != sc.group) {
+                        console.warn('join(change) for inconsistent group');
+                        break;
+                    }
+                    sc.group = m.group;
+                    sc.username = m.username;
+                    sc.permissions = m.permissions || [];
+                    sc.rtcConfiguration = m.rtcConfiguration || null;
                 }
                 }
                 if(sc.onjoined)
                 if(sc.onjoined)
                     sc.onjoined.call(sc, m.kind, m.group,
                     sc.onjoined.call(sc, m.kind, m.group,
                                      m.permissions || [],
                                      m.permissions || [],
                                      m.status, m.data,
                                      m.status, m.data,
-                                     m.value || null);
+                                     m.error || null, m.value || null);
                 break;
                 break;
             case 'user':
             case 'user':
                 switch(m.kind) {
                 switch(m.kind) {
@@ -419,8 +447,8 @@ ServerConnection.prototype.connect = async function(connection, host) {
             case 'chathistory':
             case 'chathistory':
                 if(sc.onchat)
                 if(sc.onchat)
                     sc.onchat.call(
                     sc.onchat.call(
-                        sc, m.source, m.dest, m.username, m.time, m.privileged,
-                        m.type === 'chathistory', m.kind, m.value,
+                        sc, m.source, m.dest, m.username, parseTime(m.time),
+                        m.privileged, m.type === 'chathistory', m.kind, m.value,
                     );
                     );
                 break;
                 break;
             case 'usermessage':
             case 'usermessage':
@@ -428,8 +456,8 @@ ServerConnection.prototype.connect = async function(connection, host) {
                     sc.fileTransfer(m.source, m.username, m.value);
                     sc.fileTransfer(m.source, m.username, m.value);
                 else if(sc.onusermessage)
                 else if(sc.onusermessage)
                     sc.onusermessage.call(
                     sc.onusermessage.call(
-                        sc, m.source, m.dest, m.username, m.time,
-                        m.privileged, m.kind, m.value,
+                        sc, m.source, m.dest, m.username, parseTime(m.time),
+                        m.privileged, m.kind, m.error, m.value,
                     );
                     );
                 break;
                 break;
             case 'ping':
             case 'ping':
@@ -448,6 +476,25 @@ ServerConnection.prototype.connect = async function(connection, host) {
     });
     });
 };
 };
 
 
+/**
+ * Protocol version 1 uses integers for dates, later versions use dates in
+ * ISO 8601 format.  This function takes a date in either format and
+ * returns a Date object.
+ *
+ * @param {string|number} value
+ * @returns {Date}
+ */
+function parseTime(value) {
+    if(!value)
+        return null;
+    try {
+        return new Date(value);
+    } catch(e) {
+        console.warn(`Couldn't parse ${value}:`, e);
+        return null;
+    }
+}
+
 /**
 /**
  * join requests to join a group.  The onjoined callback will be called
  * join requests to join a group.  The onjoined callback will be called
  * when we've effectively joined.
  * when we've effectively joined.
@@ -462,8 +509,10 @@ ServerConnection.prototype.join = async function(group, username, credentials, d
         type: 'join',
         type: 'join',
         kind: 'join',
         kind: 'join',
         group: group,
         group: group,
-        username: username,
     };
     };
+    if(typeof username !== 'undefined' && username !== null)
+        m.username = username;
+
     if((typeof credentials) === 'string') {
     if((typeof credentials) === 'string') {
         m.password = credentials;
         m.password = credentials;
     } else {
     } else {
@@ -713,16 +762,15 @@ ServerConnection.prototype.userMessage = function(kind, dest, value, noecho) {
  * groupAction sends a request to act on the current group.
  * groupAction sends a request to act on the current group.
  *
  *
  * @param {string} kind
  * @param {string} kind
- *     - One of 'clearchat', 'lock', 'unlock', 'record' or 'unrecord'.
- * @param {string} [message] - An optional user-readable message.
+ * @param {any} [data]
  */
  */
-ServerConnection.prototype.groupAction = function(kind, message) {
+ServerConnection.prototype.groupAction = function(kind, data) {
     this.send({
     this.send({
         type: 'groupaction',
         type: 'groupaction',
         source: this.id,
         source: this.id,
         kind: kind,
         kind: kind,
         username: this.username,
         username: this.username,
-        value: message,
+        value: data,
     });
     });
 };
 };
 
 
@@ -1647,7 +1695,8 @@ TransferredFile.prototype.close = function() {
 }
 }
 
 
 /**
 /**
- * Buffer a chunk of data received during a file transfer.  Do not call this.
+ * Buffer a chunk of data received during a file transfer.
+ * Do not call this, it is called automatically when data is received.
  *
  *
  * @param {Blob|ArrayBuffer} data
  * @param {Blob|ArrayBuffer} data
  */
  */
@@ -1846,7 +1895,8 @@ TransferredFile.prototype.receive = async function() {
 }
 }
 
 
 /**
 /**
- * Negotiate a file transfer on the sender side.  Don't call this.
+ * Negotiate a file transfer on the sender side.
+ * Don't call this, it is called automatically we receive an offer.
  *
  *
  * @param {string} sdp
  * @param {string} sdp
  */
  */
@@ -1936,6 +1986,7 @@ TransferredFile.prototype.send = async function() {
 
 
     f.dc.bufferedAmountLowThreshold = 65536;
     f.dc.bufferedAmountLowThreshold = 65536;
 
 
+    /** @param {Uint8Array} a */
     async function write(a) {
     async function write(a) {
         while(f.dc.bufferedAmount > f.dc.bufferedAmountLowThreshold) {
         while(f.dc.bufferedAmount > f.dc.bufferedAmountLowThreshold) {
             await new Promise((resolve, reject) => {
             await new Promise((resolve, reject) => {
@@ -1974,9 +2025,7 @@ TransferredFile.prototype.send = async function() {
             await write(data);
             await write(data);
         } else {
         } else {
             for(let i = 0; i < v.value.length; i += 16384) {
             for(let i = 0; i < v.value.length; i += 16384) {
-                let d = new Uint8Array(
-                    data.buffer, i, Math.min(16384, data.length - i),
-                );
+                let d = data.subarray(i, Math.min(i + 16384, data.length));
                 await write(d);
                 await write(d);
             }
             }
         }
         }
@@ -2018,13 +2067,19 @@ TransferredFile.prototype.receiveData = async function(data) {
     f.dc.onmessage = null;
     f.dc.onmessage = null;
 
 
     if(f.datalen != f.size) {
     if(f.datalen != f.size) {
-        f.cancel('unexpected file size');
+        f.cancel('extra data at end of file');
         return;
         return;
     }
     }
 
 
     let blob = f.getBufferedData();
     let blob = f.getBufferedData();
+    if(blob.size != f.size) {
+        f.cancel("inconsistent data size (this shouldn't happen)");
+        return;
+    }
     f.event('done', blob);
     f.event('done', blob);
 
 
+    // we've received the whole file.  Send the final handshake, but don't
+    // complain if the peer has closed the channel in the meantime.
     await new Promise((resolve, reject) => {
     await new Promise((resolve, reject) => {
         let timer = setTimeout(function(e) { resolve(); }, 2000);
         let timer = setTimeout(function(e) { resolve(); }, 2000);
         f.dc.onclose = function(e) {
         f.dc.onclose = function(e) {
@@ -2062,8 +2117,10 @@ ServerConnection.prototype.fileTransfer = function(id, username, message) {
         try {
         try {
             if(sc.onfiletransfer)
             if(sc.onfiletransfer)
                 sc.onfiletransfer.call(sc, f);
                 sc.onfiletransfer.call(sc, f);
-            else
+            else {
                 f.cancel('this client does not implement file transfer');
                 f.cancel('this client does not implement file transfer');
+                return;
+            }
         } catch(e) {
         } catch(e) {
             f.cancel(e);
             f.cancel(e);
             return;
             return;
@@ -2117,7 +2174,7 @@ ServerConnection.prototype.fileTransfer = function(id, username, message) {
             console.error(`Unexpected ${message.type} for file transfer`);
             console.error(`Unexpected ${message.type} for file transfer`);
             return;
             return;
         }
         }
-        f.event('cancelled');
+        f.event('cancelled', message.value || null);
         f.close();
         f.close();
         break;
         break;
     }
     }