| 1 | <!DOCTYPE html> |
| 2 | <html lang="en" style=height:100%> |
| 3 | <head> |
| 4 | <title>{{{title}}} - Messenger</title> |
| 5 | <meta http-equiv=X-UA-Compatible content="IE=edge"> |
| 6 | <meta content="text/html;charset=utf-8" http-equiv=Content-Type> |
| 7 | <meta name="viewport" content="user-scalable=1.0,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0" /> |
| 8 | <meta name=format-detection content="telephone=no"> |
| 9 | <meta name="robots" content="noindex,nofollow"> |
| 10 | <link type="text/css" href="styles/style.css" media="screen" rel="stylesheet" title="CSS" /> |
| 11 | <link type="text/css" href="styles/messenger.css" media="screen" rel="stylesheet" title="CSS" /> |
| 12 | {{{customCSSTags}}} |
| 13 | <link rel="apple-touch-icon" href="/favicon-303x303.png" /> |
| 14 | <script type="text/javascript" src="scripts/common-0.0.1{{min}}.js"></script> |
| 15 | <script type="text/javascript" src="scripts/filesaver.min.js"></script> |
| 16 | {{{customJSTags}}} |
| 17 | </head> |
| 18 | <body style="font-family:Arial,Helvetica,sans-serif"> |
| 19 | <div id="xtop" style="position:absolute;left:0;right:0;top:0;height:38px;background-color:#036;color:#EEE;box-shadow:3px 3px 10px gray"> |
| 20 | <img style="float:left" height="38" src="messenger.png" /> |
| 21 | <div style="position:absolute;background-color:#036;right:0;height:38px"> |
| 22 | <div id="saveButton" class="icon15 topButton" style="margin-right:4px" title="Save conversation" onclick="saveChatSession()"></div> |
| 23 | <div id="notifyButton" class="icon13 topButton" style="display:none" title="Enable browser notification" onclick="enableNotificationsButtonClick()"></div> |
| 24 | <div id="fileButton" class="icon4 topButton" title="Share a file" style="display:none" onclick="fileButtonClick()"></div> |
| 25 | <div id="camButton" class="icon2 topButton" title="Activate camera & microphone" style="display:none" onclick="camButtonClick()"></div> |
| 26 | <div id="micButton" class="icon6 topButton" title="Activate microphone" style="display:none" onclick="micButtonClick()"></div> |
| 27 | <div id="hangupButton" class="icon11 topRedButton" title="Hang up" style="display:none" onclick="hangUpButtonClick(1)"></div> |
| 28 | <div id="recordIcon" class='deskareaicon' title="Server is recording this session" style="background-color:red;margin:6px;margin-top:7px;border-radius:12px;height:24px;width:24px;float:right;display:none"></div> |
| 29 | </div> |
| 30 | <div style="padding-top:9px;padding-left:6px;font-size:20px;display:inline-block"><b><span id="xtitle"></span></b></div> |
| 31 | </div> |
| 32 | <div id="xmiddle" style="position:absolute;left:0;right:0;top:38px;bottom:36px;font-size:18px"> |
| 33 | <div id="xmsgparent" style="position:absolute;left:0;right:0;bottom:0;max-height:100%;overflow-y:auto"> |
| 34 | <div id="xmsg" style="padding:5px"></div> |
| 35 | <div id="typingIndicator" style="display:none;margin-left:5px;clear:both"><img src="images/3dots-24.gif" srcset="images/3dots-48.gif 2x" /></div> |
| 36 | </div> |
| 37 | </div> |
| 38 | <div id="xbottom" style="position:absolute;left:0;right:0;bottom:0px;height:36px;background-color:#036;font-size:18px"> |
| 39 | <table style="width:100%"> |
| 40 | <tr> |
| 41 | <td> |
| 42 | <input id="xouttext" type="text" style="box-sizing:border-box;width:100%;font-size:18px" onfocus=onUserInputFocus(1) onblur=onUserInputFocus(0) onkeyup="updateLocalOutText()" /> |
| 43 | </td> |
| 44 | <td style="width:1px"> |
| 45 | <input type="button" id="sendButton" value="Send" style="box-sizing: border-box;float:right;font-size:18px" onclick="xsend(event)" /> |
| 46 | </td> |
| 47 | <td style="width:1px"> |
| 48 | <input type="button" id="clearButton" value="Clear" style="box-sizing: border-box;float:right;font-size:18px" onclick="displayClear()" /> |
| 49 | </td> |
| 50 | </tr> |
| 51 | </table> |
| 52 | </div> |
| 53 | <div id="remoteVideo" style="position:absolute;right:24px;top:45px;width:320px;height:calc(240px + 30px);background-color:gray;border-radius:12px 12px 12px 12px;box-shadow:3px 3px 10px gray;display:none"> |
| 54 | <div style="position:absolute;right:0;left:0;top:2.5px;text-align:center">Remote</div> |
| 55 | <video id="remoteVideoCanvas" autoplay style="position:absolute;top:20px;left:0;width:100%;height:calc(100% - 30px);background-color:black" onclick="remotePlay(event)"></video> |
| 56 | <div id="remoteClickToView" style="position:absolute;top:20px;left:0;width:100%;height:calc(100% - 30px);color:white;text-align:center;padding-top:20px;display:none" onclick="remotePlay(event)">Click here to view.</div> |
| 57 | </div> |
| 58 | <div id="localVideo" style="position:absolute;right:24px;top:320px;width:160px;height:calc(120px + 30px);background-color:gray;border-radius:12px 12px 12px 12px;box-shadow:3px 3px 10px gray;display:none"> |
| 59 | <div style="position:absolute;right:0;left:0;top:2.5px;text-align:center">Local</div> |
| 60 | <video id="localVideoCanvas" autoplay muted style="position:absolute;top:20px;left:0;right:100%;height:calc(100% - 30px);background-color:black"></video> |
| 61 | </div> |
| 62 | <canvas width="256" height="256" id="remoteImage" style="position:absolute;right:24px;top:45px;width:200px;height:200px;background-color:gray;border-radius:12px 12px 12px 12px;box-shadow:3px 3px 10px gray;display:none" /> |
| 63 | <input id="uploadFileInput" type="file" multiple style="display:none"> |
| 64 | <script type="text/javascript" onunload="onUnLoad()"> |
| 65 | var random = '{{{randomlength}}}' // Random length string for BREACH mitigation |
| 66 | var userInputFocus = 0; |
| 67 | var socket = null; // Websocket object |
| 68 | var state = 0; // Connection state. 0 = Disconnected, 1 = Connecting, 2 = Connected. |
| 69 | var args = XparseUriArgs(); |
| 70 | if (args.key && (isAlphaNumeric(args.key) == false)) { delete args.key; } |
| 71 | if (args.locale && (isAlphaNumeric(args.locale) == false)) { delete args.locale; } |
| 72 | var pushMessaging = (args.pmt == 1); |
| 73 | |
| 74 | // WebRTC sessions and data, audio and video channels |
| 75 | var random = Math.random(); // Selected random, larger value initiates WebRTC. |
| 76 | var webrtcSessions = { }; // WebRTC objects: 0 for data, 1 for outbound audio/video, 2 for inbound audio/video |
| 77 | var webchannel = null; // WebRTC data channel |
| 78 | var localStream = null; |
| 79 | var remoteStream = null; |
| 80 | var multiWebRtc = true; // if set to true, multiple WebRTC sessions will be setup. If false, everything uses one session. |
| 81 | var userMediaSupport = 0; |
| 82 | var notification = null; |
| 83 | getUserMediaSupport(function (x) { userMediaSupport = x; }) |
| 84 | var meshMessengerTitle = '{{{meshMessengerTitle}}}'; |
| 85 | var meshMessengerImage = '{{{meshMessengerImage}}}'; |
| 86 | var remoteUserName = '{{{username}}}'; |
| 87 | var remoteUserId = '{{{userid}}}'; |
| 88 | var webrtcconfiguration = '{{{webrtcconfig}}}'; |
| 89 | if (webrtcconfiguration == '') { webrtcconfiguration = null; } else { try { webrtcconfiguration = JSON.parse(decodeURIComponent(webrtcconfiguration)); } catch (ex) { console.log('Invalid WebRTC config: "' + webrtcconfiguration + '".'); webrtcconfiguration = null; } } |
| 90 | var chatTextSession = new Date().toString() + '\r\n'; |
| 91 | var localOutText = false; |
| 92 | var remoteOutText = false; |
| 93 | var remoteImage = false; |
| 94 | var serverRecording = false; |
| 95 | var notificationSupport = true; |
| 96 | |
| 97 | // File transfer state |
| 98 | var fileUploads = []; |
| 99 | var fileDownloads = {}; |
| 100 | var currentFileUpload = null; |
| 101 | var currentFileDownload = null; |
| 102 | |
| 103 | setInterval(resizeVideos, 1000); |
| 104 | function resizeVideos() { |
| 105 | var rheight = 0; |
| 106 | if (remoteStream != null) { |
| 107 | rheight = ((320 * Q('remoteVideoCanvas').videoHeight) / Q('remoteVideoCanvas').videoWidth); |
| 108 | QS('remoteVideo').height = 'calc(' + rheight + 'px + 30px)'; |
| 109 | } else { |
| 110 | if (remoteImage == true) { rheight += 200; } |
| 111 | } |
| 112 | if (localStream != null) { |
| 113 | QS('localVideo').height = 'calc(' + ((160 * Q('localVideoCanvas').videoHeight) / Q('localVideoCanvas').videoWidth) + 'px + 30px)'; |
| 114 | QS('localVideo').top = (rheight + 50 + ((remoteStream != null)?30:0)) + 'px'; |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // Set the title |
| 119 | var newTitle = ''; |
| 120 | if (args.title) { newTitle = decodeURIComponent(args.title); document.title = document.title + ' - ' + decodeURIComponent(args.title); } |
| 121 | else if (meshMessengerTitle == '!') { newTitle = "MeshMessenger"; } else { newTitle = decodeURIComponent(meshMessengerTitle); } |
| 122 | newTitle = newTitle.split('{0}').join(decodeURIComponent(remoteUserName)).split('{1}').join(decodeURIComponent(remoteUserId)); |
| 123 | QH('xtitle', EscapeHtml(newTitle).split(' ').join(' ')); |
| 124 | |
| 125 | // Setup web notifications |
| 126 | try { if (Notification) { QV('notifyButton', Notification.permission != 'granted'); } } catch (ex) { notificationSupport = false; } |
| 127 | |
| 128 | // Listen to drag & drop events |
| 129 | document.addEventListener('dragover', haltEvent, false); |
| 130 | document.addEventListener('dragleave', haltEvent, false); |
| 131 | document.addEventListener('drop', fileDrop, false); |
| 132 | |
| 133 | document.onclick = function (e) { |
| 134 | if (notificationSupport) { |
| 135 | if (Notification) { QV('notifyButton', Notification.permission != 'granted'); } |
| 136 | if (notification != null) { notification.close(); notification = null; } |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // Trap document key up events |
| 141 | document.onkeyup = function ondockeypress(e) { |
| 142 | if (notificationSupport) { |
| 143 | if (Notification) { QV('notifyButton', Notification.permission != 'granted'); } |
| 144 | if (notification != null) { notification.close(); notification = null; } |
| 145 | } |
| 146 | if (state == 2) { |
| 147 | if ((e.keyCode == 8) && (userInputFocus == 0)) { |
| 148 | // Backspace |
| 149 | var outtext = Q('xouttext').value; |
| 150 | if (outtext.length > 0) { Q('xouttext').value = outtext.substring(0, outtext.length - 1); } |
| 151 | updateLocalOutText(); |
| 152 | } |
| 153 | } |
| 154 | if (userInputFocus == 0) { haltEvent(e); return false; } |
| 155 | } |
| 156 | |
| 157 | // Trap document key presses |
| 158 | document.onkeypress = function ondockeypress(e) { |
| 159 | if (notificationSupport) { |
| 160 | if (Notification) { QV('notifyButton', Notification.permission != 'granted'); } |
| 161 | if (notification != null) { notification.close(); notification = null; } |
| 162 | } |
| 163 | if ((state == 2) || pushMessaging) { |
| 164 | if (e.keyCode == 13) { |
| 165 | // Return |
| 166 | xsend(e); |
| 167 | } else { |
| 168 | // Any other key |
| 169 | if ((userInputFocus == 0) && (e.key.length == 1)) { Q('xouttext').value = Q('xouttext').value + e.key; updateLocalOutText(); } |
| 170 | } |
| 171 | } |
| 172 | if (userInputFocus == 0) { haltEvent(e); return false; } |
| 173 | } |
| 174 | |
| 175 | function onUserInputFocus(x) { userInputFocus = x; } |
| 176 | function displayClear() { chatTextSession = new Date().toString() + '\r\n'; QH('xmsg', ''); cancelAllFileTransfers(); fileUploads = [], fileDownloads = {}; } |
| 177 | |
| 178 | // Polyfill FileReader if needed |
| 179 | if (!FileReader.prototype.readAsBinaryString) { |
| 180 | FileReader.prototype.readAsBinaryString = function (fileData) { |
| 181 | var binary = '', self = this, reader = new FileReader(); |
| 182 | reader.onload = function (e) { |
| 183 | var bytes = new Uint8Array(reader.result); |
| 184 | for (var i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } |
| 185 | self.onload({ target: { result: binary } }); |
| 186 | } |
| 187 | reader.readAsArrayBuffer(fileData); |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // Detect if microphone & camera are present |
| 192 | // 0 = nomedia, 1 = miconly, 2 = mic&cam |
| 193 | function getUserMediaSupport(func) { |
| 194 | try { |
| 195 | navigator.mediaDevices.enumerateDevices().then(function (devices) { |
| 196 | try { |
| 197 | var mic = 0, cam = 0; |
| 198 | devices.forEach(function (device) { |
| 199 | if (device.kind === 'audioinput') { mic = 1; } |
| 200 | if (device.kind === 'videoinput') { cam = 1; } |
| 201 | }); |
| 202 | if (mic == 0) { func(0); } |
| 203 | func(mic + cam); |
| 204 | } catch (ex) { } |
| 205 | }) |
| 206 | } catch (ex) { } |
| 207 | } |
| 208 | |
| 209 | // Display a control message |
| 210 | function displayControl(msg) { |
| 211 | chatTextSession += ((new Date()).toLocaleTimeString() + ' - ' + "Control" + '> ' + msg + '\r\n'); |
| 212 | QA('xmsg', '<div style="clear:both"><div style="color:gray;float:left;margin-bottom:2px">' + EscapeHtml(msg) + '</div><div></div></div>'); |
| 213 | Q('xmsgparent').scrollTop = Q('xmsgparent').scrollHeight;//Q('xmsg').scrollHeight; |
| 214 | } |
| 215 | |
| 216 | function displayLocalVideo(active) { QV('localVideo', active); adjustVideoWindows(); } |
| 217 | function displayRemoteVideo(active) { QV('remoteVideo', active); adjustVideoWindows(); } |
| 218 | function adjustVideoWindows() { |
| 219 | var lv = (QS('localVideo')['display'] != 'none'); |
| 220 | var rv = (QS('remoteVideo')['display'] != 'none'); |
| 221 | QV('remoteImage', (remoteImage == true) && (rv == false)) |
| 222 | if (rv) { QS('localVideo')['top'] = '320px'; } |
| 223 | else if (remoteImage) { QS('localVideo')['top'] = '320px'; } |
| 224 | else { QS('localVideo')['top'] = '45px'; } |
| 225 | resizeVideos(); |
| 226 | } |
| 227 | |
| 228 | // Display a message from the remote user |
| 229 | function displayRemote(msg) { |
| 230 | chatTextSession += ((new Date()).toLocaleTimeString() + ' - ' + "Remote" + '> ' + msg + '\r\n'); |
| 231 | QA('xmsg', '<div style="clear:both"><div class="remoteBubble">' + EscapeHtml(msg) + '</div><div></div></div>'); |
| 232 | Q('xmsgparent').scrollTop = Q('xmsgparent').scrollHeight; |
| 233 | |
| 234 | // If web notifications are granted, use it. |
| 235 | if (notificationSupport) { |
| 236 | if (Notification) { QV('notifyButton', Notification.permission != 'granted'); } |
| 237 | if (Notification && (document.visibilityState === 'hidden') && (Notification.permission == 'granted')) { |
| 238 | if (notification != null) { notification.close(); notification = null; } |
| 239 | notification = new Notification(Q('xtitle').innerHTML.split(' ').join(' '), { body: msg }); |
| 240 | notification.onclick = function(event) { |
| 241 | event.preventDefault(); |
| 242 | if (document.visibilityState === 'hidden') window.focus(); |
| 243 | notification.close(); |
| 244 | }; |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // Display and send a message from the local user |
| 250 | function xsend(event) { |
| 251 | if (notificationSupport) { |
| 252 | if (notification != null) { notification.close(); notification = null; } |
| 253 | if (Notification) { QV('notifyButton', Notification.permission != 'granted'); } |
| 254 | } |
| 255 | var outtext = Q('xouttext').value; |
| 256 | if (outtext.length > 0) { |
| 257 | chatTextSession += ((new Date()).toLocaleTimeString() + ' - ' + "Local" + '> ' + outtext + '\r\n'); |
| 258 | QA('xmsg', '<div style="clear:both"><div class="localBubble">' + EscapeHtml(outtext) + '</div><div></div></div>'); |
| 259 | Q('xmsgparent').scrollTop = Q('xmsgparent').scrollHeight; |
| 260 | if ((state == 2) || pushMessaging) { send({ action: 'chat', msg: outtext }); } |
| 261 | Q('xouttext').value = ''; |
| 262 | Q('xouttext').focus(); |
| 263 | localOutText = false; |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | function haltEvent(e) { if (e.preventDefault) e.preventDefault(); if (e.stopPropagation) e.stopPropagation(); return false; } |
| 268 | |
| 269 | // Update user controls |
| 270 | function updateControls() { |
| 271 | QE('sendButton', (state == 2) || pushMessaging); |
| 272 | QE('clearButton', (state == 2) || pushMessaging); |
| 273 | QE('xouttext', (state == 2) || pushMessaging); |
| 274 | QV('fileButton', state == 2); |
| 275 | QV('camButton', webchannel && webchannel.ok && !localStream && (userMediaSupport == 2)); |
| 276 | QV('micButton', webchannel && webchannel.ok && !localStream && (userMediaSupport > 0)); |
| 277 | QV('hangupButton', webchannel && webchannel.ok && localStream); |
| 278 | updateLocalOutText(); |
| 279 | } |
| 280 | |
| 281 | // This is the WebRTC setup |
| 282 | function startWebRTC(id, startDataChannel) { |
| 283 | if ((webrtcSessions[0] != null) && (multiWebRtc == false)) { return webrtcSessions[0]; }; |
| 284 | |
| 285 | // Setup the WebRTC object |
| 286 | var webrtc = null; |
| 287 | if (typeof RTCPeerConnection !== 'undefined') { webrtc = new RTCPeerConnection(webrtcconfiguration); } |
| 288 | else if (typeof webkitRTCPeerConnection !== 'undefined') { webrtc = new webkitRTCPeerConnection(webrtcconfiguration); } |
| 289 | if (webrtc == null) return null; // No WebRTC support. |
| 290 | |
| 291 | webrtc.id = id; |
| 292 | webrtc.onicecandidate = function (e) { try { if (e.candidate != null) { sendws({ action: 'webRtcIce', ice: e.candidate, id: this.id }); } } catch (ex) { } } |
| 293 | webrtc.oniceconnectionstatechange = function () { if (webrtc && webrtc.iceConnectionState == 'failed') { webrtc.close(); if (webrtcSessions[webrtc.id]) { delete webrtcSessions[webrtc.id]; } } } |
| 294 | webrtc.ondatachannel = function (ev) { |
| 295 | //console.log('ondatachannel'); |
| 296 | webchannel = ev.channel; |
| 297 | webchannel.onmessage = function (event) { processMessage(event.data, 2); }; |
| 298 | webchannel.onopen = function () { webchannel.ok = true; updateControls(); if (serverRecording == false) { sendws({ action: 'rtcSwitch', v: 0 }); } }; |
| 299 | webchannel.onclose = function (event) { if (webchannel && webchannel.ok) { disconnect(); } else { hangUpButtonClick(0); } } |
| 300 | } |
| 301 | webrtc.onnegotiationneeded = function (event) { |
| 302 | if (webrtc.holdTimer != null) return; |
| 303 | webrtc.holdTimer = setTimeout(function () { // This time is needed to keep Chrome from being to excited. Wait until we add all tracks before kicking this off. |
| 304 | //console.log('onnegotiationneeded', id); |
| 305 | webrtc.holdTimer = null; |
| 306 | webrtc.createOffer(function (offer) { /*console.log('offer', offer.sdp.length);*/ webrtc.setLocalDescription(offer, function () { sendws({ action: 'webRtcSdp', sdp: offer, id: id }); }, function () { hangUpButtonClick(id); }); }, function () { hangUpButtonClick(id); }); |
| 307 | }, 20); |
| 308 | } |
| 309 | webrtc.ontrack = function (event) { |
| 310 | //console.log('ontrack', id); |
| 311 | QV('remoteClickToView', false); |
| 312 | var video = Q('remoteVideoCanvas'); |
| 313 | video.srcObject = remoteStream = event.streams[0]; |
| 314 | video.onloadedmetadata = function (e) { |
| 315 | var promise = video.play(); |
| 316 | if (promise !== undefined) { |
| 317 | promise.then(function() { |
| 318 | // Video start ok |
| 319 | }).catch(function(err) { |
| 320 | // Video start error, display a play button |
| 321 | QV('remoteClickToView', true); |
| 322 | }) |
| 323 | } |
| 324 | }; |
| 325 | displayRemoteVideo(true); |
| 326 | } |
| 327 | //webrtc.onremovetrack = function (event) { console.log('onremovetrack'); } |
| 328 | //webrtc.onicegatheringstatechange = function (event) { console.log('onicegatheringstatechange', event); } |
| 329 | //webrtc.onsignalingstatechange = function (event) { console.log('onsignalingstatechange', event); } |
| 330 | |
| 331 | // Initiate the WebRTC offer or handle the offer from the peer. |
| 332 | if (startDataChannel == true) { |
| 333 | webchannel = webrtc.createDataChannel('DataChannel', {}); // { ordered: false, maxRetransmits: 2 } |
| 334 | webchannel.onmessage = function (event) { processMessage(event.data, 2); }; |
| 335 | webchannel.onopen = function () { webchannel.ok = true; updateControls(); if (serverRecording == false) { sendws({ action: 'rtcSwitch', v: 0 }); } }; |
| 336 | webchannel.onclose = function (event) { if (webchannel && webchannel.ok) { disconnect(); } else { hangUpButtonClick(0); } } |
| 337 | } |
| 338 | |
| 339 | webrtcSessions[id] = webrtc; |
| 340 | return webrtc; |
| 341 | } |
| 342 | |
| 343 | function remotePlay() { QV('remoteClickToView', false); Q('remoteVideoCanvas').play(); } |
| 344 | |
| 345 | function webRtcHandleOffer(id, description) { |
| 346 | //console.log('webRtcHandleOffer', description.sdp.length); |
| 347 | var webrtc = webrtcSessions[id]; |
| 348 | if (webrtc) { |
| 349 | webrtc.setRemoteDescription(new RTCSessionDescription(description), function () { |
| 350 | if (description.type == 'offer') { |
| 351 | webrtc.createAnswer(function (answer) { |
| 352 | webrtc.setLocalDescription(answer, function (a, b) { |
| 353 | try { sendws({ action: 'webRtcSdp', sdp: answer, id: id }); } catch (ex) { } |
| 354 | }, function () { hangUpButtonClick(id); }); |
| 355 | }, function () { hangUpButtonClick(id); }); |
| 356 | } |
| 357 | }, function () { hangUpButtonClick(id); }); |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // Indicate to peer that data traffic will no longer be sent over websocket and start holding traffic. |
| 362 | function performWebRtcSwitch() { |
| 363 | if (!serverRecording && webchannel && webchannel.ok) { sendws({ action: 'rtcSwitch', v: 1 }); webchannel.xoutBuffer = []; } |
| 364 | } |
| 365 | |
| 366 | // Disconnect everything |
| 367 | function disconnect() { |
| 368 | serverRecording = false; |
| 369 | QV('recordIcon', false); |
| 370 | if (state > 0) { displayControl("Connection closed."); } |
| 371 | if (state > 1) { setTimeout(start, 500); } |
| 372 | cancelAllFileTransfers(); |
| 373 | hangUpButtonClick(0, true); // Data channel |
| 374 | hangUpButtonClick(1, true); // Local audio/video |
| 375 | hangUpButtonClick(2, true); // Remote audio/video |
| 376 | if (socket != null) { socket.close(); socket = null; } |
| 377 | updateControls(); |
| 378 | state = 0; |
| 379 | remoteImage = false; |
| 380 | QV('remoteImage', false); |
| 381 | updateLocalOutText(); |
| 382 | updateRemoteOutText(); |
| 383 | } |
| 384 | |
| 385 | // Send data over the current transport (WebRTC first) |
| 386 | function send(data) { |
| 387 | if ((state != 2) && (pushMessaging == false)) return; // If not in connected state, ignore this. |
| 388 | if (typeof data == 'object') { data = JSON.stringify(data); } // If this is an object, convert it to a string. |
| 389 | if (!serverRecording && webchannel && webchannel.ok) { if (webchannel.xoutBuffer != null) { webchannel.xoutBuffer.push(data); } else { webchannel.send(data); } } // If WebRTC channel is possible, use it or hold until we can use it. |
| 390 | else { if (socket != null) { try { socket.send(data); } catch (ex) { } } } // If a websocket channel is present, use that. |
| 391 | } |
| 392 | |
| 393 | // Send data over the websocket transport (WebSocket only) |
| 394 | function sendws(data) { |
| 395 | if (state != 2) return; |
| 396 | //console.log('SEND', data); |
| 397 | if (typeof data == 'object') { data = JSON.stringify(data); } |
| 398 | if (socket != null) { socket.send(data); } |
| 399 | } |
| 400 | |
| 401 | // WebRTC id switcher (0 -> 0, 1 -> 2, 2 -> 1) |
| 402 | function webRtcIdSwitch(id) { if (id == 0) { return 0; } return 3 - id; } |
| 403 | |
| 404 | function drawRemoteImage(imageBase64) { |
| 405 | var canvas = Q('remoteImage'), context = canvas.getContext('2d'), img = new Image(); |
| 406 | img.onload = function () { context.drawImage(this, 0, 0, canvas.width, canvas.height); remoteImage = true; adjustVideoWindows(); } |
| 407 | img.src = imageBase64; |
| 408 | } |
| 409 | |
| 410 | // Process incoming messages |
| 411 | function processMessage(data, transport) { |
| 412 | if (typeof data == 'string') { |
| 413 | try { data = JSON.parse(data); } catch (ex) { console.log('Unable to parse', data); return; } |
| 414 | //console.log('RECV', data); |
| 415 | |
| 416 | // Handle control command |
| 417 | if (data.ctrlChannel == '102938') { |
| 418 | switch (data.type) { |
| 419 | case 'image': { drawRemoteImage(data.image); break; } |
| 420 | case 'ping': { send({ ctrlChannel: '102938', type: 'pong' }); break; } |
| 421 | case 'pong': { break; } |
| 422 | } |
| 423 | return; |
| 424 | } |
| 425 | |
| 426 | // Handle command |
| 427 | switch (data.action) { |
| 428 | case 'chat': { displayRemote(data.msg); updateRemoteOutText(false); break; } // Incoming chat message. |
| 429 | case 'outtext': { updateRemoteOutText(data.value); break; } |
| 430 | case 'ctrl': { |
| 431 | if (data.value == 1) { displayControl("Sent as push notification."); } |
| 432 | else if (data.value == 2) { displayControl("Push notification failed."); } |
| 433 | if (data.msg != null) { displayControl(msg); } |
| 434 | break; |
| 435 | } |
| 436 | case 'random': { if (random > data.random) { startWebRTC(0, true); } break; } // If we have a larger random value, we start WebRTC. |
| 437 | case 'webRtcSdp': { if (!webrtcSessions[webRtcIdSwitch(data.id)]) { startWebRTC(webRtcIdSwitch(data.id), false); } webRtcHandleOffer(webRtcIdSwitch(data.id), data.sdp); break; } // Remote WebRTC offer or answer. |
| 438 | case 'webRtcIce': { var webrtc = webrtcSessions[webRtcIdSwitch(data.id)]; if (webrtc) { try { webrtc.addIceCandidate(new RTCIceCandidate(data.ice)); } catch (ex) { } } break; } // Remote ICE candidate |
| 439 | case 'videoStop': { hangUpButtonClick(webRtcIdSwitch(data.id), true); break; } |
| 440 | case 'rtcSwitch': { // WebRTC switch over commands. |
| 441 | switch (data.v) { |
| 442 | case 0: { performWebRtcSwitch(); break; } // Other side is ready for switch over to WebRTC |
| 443 | case 1: { sendws({ action: 'rtcSwitch', v: 2 }); break; } // Other side no longer sending data on websocket, confirm we got the end marker |
| 444 | case 2: { for (var i in webchannel.xoutBuffer) { webchannel.send(webchannel.xoutBuffer[i]); } delete webchannel.xoutBuffer; break; } // Send any pending data over WebRTC and start using WebRTC with all traffic |
| 445 | default: { console.log('Unknown rtcSwitch value: ' + data.action); break; } // |
| 446 | } |
| 447 | break; |
| 448 | } |
| 449 | case 'file': { startFileDownload(data); break; } |
| 450 | case 'fileUploadCancel': { cancelFileTransfer(data.id); break; } |
| 451 | case 'fileUploadStart': { |
| 452 | if (fileDownloads[data.id]) { |
| 453 | currentFileDownload = fileDownloads[data.id]; |
| 454 | currentFileDownload.data = ''; |
| 455 | changeFileInfo(data.id, 2, 0); |
| 456 | continueFileDownload(data); |
| 457 | send({ action: 'fileUploadAck', id: data.id }); |
| 458 | } break; |
| 459 | } |
| 460 | case 'fileUploadEnd': { |
| 461 | if (currentFileDownload && (currentFileDownload.id == data.id)) { |
| 462 | changeFileInfo(data.id, 3, 200); |
| 463 | currentFileDownload.done = 1; |
| 464 | currentFileDownload = null; |
| 465 | send({ action: 'fileUploadAck', id: data.id }); |
| 466 | } |
| 467 | currentFileDownload = null; |
| 468 | break; |
| 469 | } |
| 470 | case 'fileUploadAck': { |
| 471 | continueFileUpload(); |
| 472 | break; |
| 473 | } |
| 474 | case 'fileData': { |
| 475 | if (currentFileDownload && (currentFileDownload.id == data.id)) { |
| 476 | currentFileDownload.data += data.data; |
| 477 | changeFileInfo(data.id, 2, (currentFileDownload.data.length * 200 / currentFileDownload.size)); |
| 478 | send({ action: 'fileUploadAck', id: data.id }); |
| 479 | } |
| 480 | break; |
| 481 | } |
| 482 | default: { console.log('Unhandled object data', data); break; } |
| 483 | } |
| 484 | } else { |
| 485 | console.log('Unhandled data', typeof data, data); |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | // File sharing button |
| 490 | function fileButtonClick() { |
| 491 | var chooser = Q('uploadFileInput'); |
| 492 | if (chooser.getAttribute('eventset') != 1) { |
| 493 | chooser.setAttribute('eventset', '1'); |
| 494 | chooser.addEventListener('change', fileSelect, false); |
| 495 | } |
| 496 | chooser.value = null; |
| 497 | chooser.click(); |
| 498 | } |
| 499 | |
| 500 | // User selected one or more files to upload to remote user. |
| 501 | function fileSelect() { |
| 502 | if (state != 2) return; |
| 503 | var x = Q('uploadFileInput'); |
| 504 | if (x.files.length > 10) { |
| 505 | displayControl("Limit of 10 file uploads at the same time."); |
| 506 | } else { |
| 507 | for (var i = 0; i < x.files.length; i++) { |
| 508 | if (x.files[i].size > 0) { |
| 509 | var reader = new FileReader(); |
| 510 | reader.onload = function (e) { this.xfile.data = e.target.result; startFileUpload(this.xfile); }; |
| 511 | reader.xfile = x.files[i]; |
| 512 | reader.readAsBinaryString(x.files[i]); |
| 513 | } |
| 514 | } |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | // User drag & droped one or more files to upload to remote user. |
| 519 | function fileDrop(e) { |
| 520 | haltEvent(e); |
| 521 | if ((state != 2) || (e.dataTransfer == null)) return; |
| 522 | if (e.dataTransfer.files.length > 10) { |
| 523 | displayControl("Limit of 10 file uploads at the same time."); |
| 524 | } else { |
| 525 | for (var i = 0; i < e.dataTransfer.files.length; i++) { |
| 526 | if (e.dataTransfer.files[i].size > 0) { |
| 527 | var reader = new FileReader(); |
| 528 | reader.onload = function (e) { this.xfile.data = e.target.result; startFileUpload(this.xfile); }; |
| 529 | reader.xfile = e.dataTransfer.files[i]; |
| 530 | reader.readAsBinaryString(e.dataTransfer.files[i]); |
| 531 | } |
| 532 | } |
| 533 | } |
| 534 | } |
| 535 | |
| 536 | function startFileUpload(file) { |
| 537 | if (state != 2) return; |
| 538 | file.id = Math.random(); |
| 539 | fileUploads.push(file); |
| 540 | chatTextSession += ((new Date()).toLocaleTimeString() + ' - ' + "Upload" + '> ' + file.name + ' (' + file.size + ' ' + "bytes" + ')\r\n'); |
| 541 | QA('xmsg', '<div style="clear:both"></div><div id="FILEUP-' + file.id + '" class="localBubble" style="font-size:14px;width:240px;cursor:pointer" onclick="cancelFileTransfer(\'' + file.id + '\')"><div id="FILEUP-ICON-' + file.id + '" class="fileicon" style="float:left;width:32px;height:32px"></div><div><div id="FILEUP-NAME-' + file.id + '" style="height:20px;overflow:hidden;white-space:nowrap;" title="' + file.name + '">' + file.name + '</div><div style="width:200px;background-color:lightgray;margin-left:32px;border-radius:3px;margin-top:3px;height:11px"><div id="FILEUP-PROGRESS-' + file.id + '" style="width:0px;background-color:green;border-radius:3px;height:11px"> </div></div></div></div>'); |
| 542 | Q('xmsgparent').scrollTop = Q('xmsgparent').scrollHeight; |
| 543 | send({ action: 'file', size: file.size, id: file.id, type: file.type, name: file.name }); |
| 544 | if (currentFileUpload == null) continueFileUpload(); |
| 545 | } |
| 546 | |
| 547 | function startFileDownload(file) { |
| 548 | if (state != 2) return; |
| 549 | fileDownloads[file.id] = file; |
| 550 | chatTextSession += ((new Date()).toLocaleTimeString() + ' - ' + "Download" + '> ' + file.name + ' (' + file.size + ' ' + "bytes" + ')\r\n'); |
| 551 | QA('xmsg', '<div style="clear:both"></div><div id="FILEUP-' + file.id + '" class="remoteBubble" style="font-size:14px;width:240px;cursor:pointer" onclick="saveFileTransfer(\'' + file.id + '\')"><div id="FILEUP-ICON-' + file.id + '" class="fileicon" style="float:left;width:32px;height:32px"></div><div><div id="FILEUP-NAME-' + file.id + '" style="height:20px;overflow:hidden;white-space:nowrap;" title="' + file.name + '">' + file.name + '</div><div style="width:200px;background-color:lightgray;margin-left:32px;border-radius:3px;margin-top:3px;height:11px"><div id="FILEUP-PROGRESS-' + file.id + '" style="width:0px;background-color:green;border-radius:3px;height:11px"> </div></div></div></div>'); |
| 552 | Q('xmsgparent').scrollTop = Q('xmsgparent').scrollHeight; |
| 553 | } |
| 554 | |
| 555 | // Change the file icon and progress |
| 556 | function changeFileInfo(id, icon, progress, progressColor) { |
| 557 | if (icon) { |
| 558 | Q('FILEUP-ICON-' + id).classList.remove('fileicon'); |
| 559 | Q('FILEUP-ICON-' + id).classList.remove('fileiconx'); |
| 560 | Q('FILEUP-ICON-' + id).classList.remove('fileicontransfer'); |
| 561 | Q('FILEUP-ICON-' + id).classList.remove('fileicondone'); |
| 562 | Q('FILEUP-ICON-' + id).classList.add(['fileicon', 'fileiconx', 'fileicontransfer', 'fileicondone'][icon]); |
| 563 | } |
| 564 | if (progress) { QS('FILEUP-PROGRESS-' + id)['width'] = progress + 'px'; } |
| 565 | if (progressColor) { QS('FILEUP-PROGRESS-' + id)['background-color'] = progressColor; } |
| 566 | } |
| 567 | |
| 568 | // Convert a string into a blob |
| 569 | function data2blob(data) { |
| 570 | var bytes = new Array(data.length); |
| 571 | for (var i = 0; i < data.length; i++) bytes[i] = data.charCodeAt(i); |
| 572 | return new Blob([new Uint8Array(bytes)]); |
| 573 | }; |
| 574 | |
| 575 | function saveFileTransfer(id) { |
| 576 | var f = fileDownloads[id]; |
| 577 | if (f && f.done == 1) { saveAs(data2blob(f.data), f.name); } |
| 578 | } |
| 579 | |
| 580 | function cancelFileTransfer(id) { |
| 581 | if ((currentFileUpload != null) && (currentFileUpload.id == id)) { currentFileUpload = null; } |
| 582 | if ((currentFileDownload != null) && (currentFileDownload.id == id)) { currentFileDownload = null; } |
| 583 | |
| 584 | var found = false; |
| 585 | if (fileDownloads[id] && (fileDownloads[id].done != 1)) { |
| 586 | delete fileDownloads[id]; |
| 587 | found = true; |
| 588 | } else { |
| 589 | for (var i in fileUploads) { |
| 590 | if (fileUploads[i].id == id) { |
| 591 | send({ action: 'fileUploadCancel', id: id }); |
| 592 | fileUploads.splice(i, 1); |
| 593 | found = true; |
| 594 | break; |
| 595 | } |
| 596 | } |
| 597 | } |
| 598 | if (found) { changeFileInfo(id, 1, 200, 'gray'); } // Only cancel a file if it was in the file queue. |
| 599 | } |
| 600 | |
| 601 | function cancelAllFileTransfers() { |
| 602 | for (var i in fileDownloads) { cancelFileTransfer(fileDownloads[i].id); } |
| 603 | for (var i in fileUploads) { cancelFileTransfer(fileUploads[i].id); } |
| 604 | } |
| 605 | |
| 606 | function continueFileUpload() { |
| 607 | if (currentFileUpload == null) { |
| 608 | // Select the next file to upload |
| 609 | if (fileUploads.length == 0) { return; } // Nothing to do |
| 610 | currentFileUpload = fileUploads[0]; |
| 611 | currentFileUpload.ptr = 0; |
| 612 | |
| 613 | // Indicate that we are sending this file |
| 614 | send({ action: 'fileUploadStart', size: currentFileUpload.size, id: currentFileUpload.id, type: currentFileUpload.type, name: currentFileUpload.name }); |
| 615 | } else { |
| 616 | if (currentFileUpload.size <= currentFileUpload.ptr) { |
| 617 | // If we are done, send the end marker |
| 618 | send({ action: 'fileUploadEnd', size: currentFileUpload.size, id: currentFileUpload.id, type: currentFileUpload.type, name: currentFileUpload.name }); |
| 619 | changeFileInfo(currentFileUpload.id, 3, 200); |
| 620 | fileUploads.splice(0, 1); |
| 621 | currentFileUpload = null; |
| 622 | continueFileUpload(); // Send the next file |
| 623 | } else { |
| 624 | // Send the next block |
| 625 | var nextBlockLen = Math.min(4000, currentFileUpload.data.length - currentFileUpload.ptr); |
| 626 | var data = currentFileUpload.data.substring(currentFileUpload.ptr, currentFileUpload.ptr + nextBlockLen); |
| 627 | send({ action: 'fileData', id: currentFileUpload.id, data: data }); |
| 628 | currentFileUpload.ptr += nextBlockLen; |
| 629 | changeFileInfo(currentFileUpload.id, 0, (currentFileUpload.ptr * 200 / currentFileUpload.size)); |
| 630 | } |
| 631 | } |
| 632 | } |
| 633 | |
| 634 | function continueFileDownload(msg) { |
| 635 | send({ action: 'fileUploadAck', id: msg.id }); |
| 636 | } |
| 637 | |
| 638 | // Toggle notification |
| 639 | function enableNotificationsButtonClick() { |
| 640 | if (notificationSupport) { |
| 641 | if (Notification) { Notification.requestPermission().then(function (permission) { QV('notifyButton', permission != 'granted'); }); } |
| 642 | } |
| 643 | return false; |
| 644 | } |
| 645 | |
| 646 | // Camera button |
| 647 | function camButtonClick() { |
| 648 | if (localStream == null) { startLocalStream({ video: true, audio: true }); } |
| 649 | } |
| 650 | |
| 651 | // Microphone |
| 652 | function micButtonClick() { |
| 653 | if (localStream == null) { startLocalStream({ video: false, audio: true }); } |
| 654 | } |
| 655 | |
| 656 | function hangUpButtonClick(id, fromRemote) { |
| 657 | //console.log('hangUpButtonClick', id); |
| 658 | var localVideo = Q('localVideoCanvas'); |
| 659 | var remoteVideo = Q('remoteVideoCanvas'); |
| 660 | var webrtc = webrtcSessions[(multiWebRtc == true)? id : 0]; |
| 661 | |
| 662 | if ((id == 0) && (webchannel != null)) { try { webchannel.close(); } catch (e) { } webchannel = null; } |
| 663 | |
| 664 | if (webrtc) { |
| 665 | if ((multiWebRtc == true) || (id == 0)) { |
| 666 | webrtc.ontrack = null; |
| 667 | webrtc.onremovetrack = null; |
| 668 | webrtc.onremovestream = null; |
| 669 | webrtc.onnicecandidate = null; |
| 670 | webrtc.oniceconnectionstatechange = null; |
| 671 | webrtc.onsignalingstatechange = null; |
| 672 | webrtc.onicegatheringstatechange = null; |
| 673 | webrtc.onnotificationneeded = null; |
| 674 | } |
| 675 | |
| 676 | if ((id == 1) && localStream) { var tracks = localStream.getTracks(); for (var i in tracks) { tracks[i].stop(); } localStream = null; } |
| 677 | if ((id == 2) && remoteStream) { var tracks = remoteStream.getTracks(); for (var i in tracks) { tracks[i].stop(); } remoteStream = null; } |
| 678 | |
| 679 | if ((multiWebRtc == true) || (id == 0)) { |
| 680 | webrtc.close(); |
| 681 | delete webrtcSessions[id]; |
| 682 | } |
| 683 | } |
| 684 | |
| 685 | if (id == 1) { |
| 686 | localVideo.removeAttribute('src'); |
| 687 | localVideo.removeAttribute('srcObject'); |
| 688 | if (localStream != null) { localStream = null; } |
| 689 | displayLocalVideo(false); |
| 690 | } else if (id == 2) { |
| 691 | remoteVideo.removeAttribute('src'); |
| 692 | remoteVideo.removeAttribute('srcObject'); |
| 693 | displayRemoteVideo(false); |
| 694 | } |
| 695 | |
| 696 | if (fromRemote != true) { send({ action: 'videoStop', id: id }); } |
| 697 | updateControls(); |
| 698 | } |
| 699 | |
| 700 | // Setup local audio/video |
| 701 | function startLocalStream(constraints) { |
| 702 | var channel = (multiWebRtc == true) ? 1 : 0; |
| 703 | if (localStream != null) return; |
| 704 | if ((multiWebRtc == true) && (webrtcSessions[1] != null)) return; |
| 705 | if (navigator.mediaDevices.getUserMedia) { |
| 706 | localStream = 1; |
| 707 | updateControls(); |
| 708 | navigator.mediaDevices.getUserMedia(constraints) |
| 709 | .then(function (stream) { |
| 710 | localStream = stream; |
| 711 | var tracks = localStream.getTracks(); |
| 712 | var webrtc = startWebRTC(channel); |
| 713 | if (constraints.video == true) { |
| 714 | var video = Q('localVideoCanvas'); |
| 715 | video.srcObject = stream; |
| 716 | video.onloadedmetadata = function (e) { video.play(); }; |
| 717 | displayLocalVideo(true); |
| 718 | } |
| 719 | for (var i in tracks) { webrtc.addTrack(tracks[i], localStream); } |
| 720 | }, function (err) { |
| 721 | displayControl(err.message + '.'); |
| 722 | hangUpButtonClick(1); |
| 723 | }); |
| 724 | } |
| 725 | } |
| 726 | |
| 727 | // This is the main start |
| 728 | function start() { |
| 729 | // Get started |
| 730 | updateControls(); |
| 731 | if ((typeof args.id == 'string') && (args.id.length > 0)) { |
| 732 | var url = window.location.protocol.replace('http', 'ws') + '//' + window.location.host + window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/')) + '/meshrelay.ashx?id=' + args.id; |
| 733 | if ((args.auth != null) && (args.auth != '')) { url += '&auth=' + args.auth; } |
| 734 | socket = new WebSocket(url); |
| 735 | socket.onopen = function () { state = 1; displayControl("Waiting for other user..."); } |
| 736 | socket.onerror = function (e) { /*console.error(e);*/ } |
| 737 | socket.onclose = function () { disconnect(); } |
| 738 | socket.onmessage = function (msg) { |
| 739 | if ((state < 2) && (typeof msg.data == 'string') && ((msg.data == 'c') || (msg.data == 'cr'))) { |
| 740 | serverRecording = (msg.data == 'cr'); |
| 741 | QV('recordIcon', serverRecording); |
| 742 | hangUpButtonClick(0, true); |
| 743 | hangUpButtonClick(1, true); |
| 744 | hangUpButtonClick(2, true); |
| 745 | displayControl("Connected."); |
| 746 | state = 2; |
| 747 | updateControls(); |
| 748 | sendws({ action: 'random', random: random }); // Send a random number. Higher number starts the WebRTC session. |
| 749 | updateLocalOutText(); |
| 750 | updateRemoteOutText(); |
| 751 | return; |
| 752 | } |
| 753 | if (msg.data[0] == '{') { processMessage(msg.data, 1); } |
| 754 | } |
| 755 | } else { |
| 756 | displayControl("Error: No connection key specified."); |
| 757 | } |
| 758 | } |
| 759 | |
| 760 | function saveChatSession() { |
| 761 | saveAs(data2blob(chatTextSession), "ChatSession"); |
| 762 | } |
| 763 | |
| 764 | start(); |
| 765 | |
| 766 | function onUnLoad() { |
| 767 | for (var i = 0; i < 3; i++) { if (webrtcSessions[i]) { try { webrtcSessions[i].close(); delete webrtcSessions[i]; } catch (ex) { } } } |
| 768 | if (webchannel != null) { try { webchannel.close(); } catch (ex) { } webchannel = null; } |
| 769 | if (socket != null) { try { socket.close(); } catch (ex) { } socket = null; } |
| 770 | } |
| 771 | |
| 772 | function isSafeString3(str) { return ((typeof str == 'string') && (str.indexOf('<') == -1) && (str.indexOf('>') == -1) && (str.indexOf('&') == -1) && (str.indexOf('"') == -1) && (str.indexOf('\'') == -1) && (str.indexOf('+') == -1) && (str.indexOf('(') == -1) && (str.indexOf(')') == -1) && (str.indexOf('#') == -1) && (str.indexOf(':') == -1)) }; |
| 773 | |
| 774 | // Parse URL arguments, only keep safe values |
| 775 | function XparseUriArgs() { |
| 776 | var href = window.document.location.href; |
| 777 | if (href.endsWith('#')) { href = href.substring(0, href.length - 1); } |
| 778 | var name, r = {}, parsedUri = href.split(/[\?&|]/); |
| 779 | parsedUri.splice(0, 1); |
| 780 | for (var j in parsedUri) { |
| 781 | var arg = parsedUri[j], i = arg.indexOf('='); |
| 782 | name = arg.substring(0, i); |
| 783 | r[name] = arg.substring(i + 1); |
| 784 | if (!isSafeString3(r[name])) { delete r[name]; } else { var x = parseInt(r[name]); if (x == r[name]) { r[name] = x; } } |
| 785 | } |
| 786 | return r; |
| 787 | } |
| 788 | |
| 789 | function updateLocalOutText() { |
| 790 | var l = Q('xouttext').value; |
| 791 | if (((state != 2) || (l == '')) && (localOutText == true)) { |
| 792 | localOutText = false; |
| 793 | send({ action: 'outtext', value: false }); |
| 794 | } else if ((state == 2) && ((l != '') && (localOutText == false))) { |
| 795 | localOutText = true; |
| 796 | send({ action: 'outtext', value: true }); |
| 797 | } |
| 798 | } |
| 799 | |
| 800 | function updateRemoteOutText(newState) { |
| 801 | //console.log('updateRemoteOutText', newState); |
| 802 | if (state != 2) { newState = false; } |
| 803 | if (remoteOutText != newState) { remoteOutText = newState; QV('typingIndicator', remoteOutText); } |
| 804 | } |
| 805 | |
| 806 | </script> |
| 807 | </body> |
| 808 | </html> |