Update TODO
[chachachat.git] / client / client.js
blob1dc619580c71bf1db1a88b593db8e0a03c2fd034
1 // -*- mode: javascript; tab-width: 4; indent-tabs-mode: nil; -*-
2 //------------------------------------------------------------------------------
3 // This is free and unencumbered software released into the public domain.
4 //
5 // Anyone is free to copy, modify, publish, use, compile, sell, or
6 // distribute this software, either in source code form or as a compiled
7 // binary, for any purpose, commercial or non-commercial, and by any
8 // means.
9 //
10 // In jurisdictions that recognize copyright laws, the author or authors
11 // of this software dedicate any and all copyright interest in the
12 // software to the public domain. We make this dedication for the benefit
13 // of the public at large and to the detriment of our heirs and
14 // successors. We intend this dedication to be an overt act of
15 // relinquishment in perpetuity of all present and future rights to this
16 // software under copyright law.
18 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19 // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20 // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
21 // IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
22 // OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
23 // ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24 // OTHER DEALINGS IN THE SOFTWARE.
26 // For more information, please refer to <https://unlicense.org/>
27 //------------------------------------------------------------------------------
29 import * as chacha from "./third_party/jschacha20.js"
30 import * as blake from "./third_party/blake2b.js"
32 import * as conv from  "./conversions.js"
33 import * as msg from  "./message.js"
36 //------------------------------------------------------------------------------
37 // "Random" user names.
38 //------------------------------------------------------------------------------
40 const DEFAULT_NAMES = [
41     "Callisto", "Ceres", "Earth", "Eris", "Europa", "Ganymede", "Io", "Jupiter",
42     "Mars", "Mercury", "Neptune", "Pluto", "Saturn", "Sedna", "Uranus", "Venus"];
44 const DEFAULT_EMOJIS = [
45     "ðŸĩ", "🐒", "ðŸĶ", "ðŸĶ§", "ðŸķ", "🐕", "ðŸĶŪ", "ðŸĐ", "🐚", "ðŸĶŠ", "ðŸĶ", "ðŸą", "🐈",
46     "ðŸĶ", "ðŸŊ", "🐅", "🐆", "ðŸī", "🐎", "ðŸĶ„", "ðŸĶ“", "ðŸŦ", "ðŸĶŒ", "ðŸŦŽ", "ðŸĶŽ", "ðŸŪ",
47     "🐂", "🐃", "🐄", "🐷", "🐖", "🐗", "ðŸ―", "🐏", "🐑", "🐐", "🐊", "ðŸŦ", "ðŸĶ™",
48     "ðŸĶ’", "🐘", "ðŸĶĢ", "ðŸĶ", "ðŸĶ›", "🐭", "🐁", "🐀", "ðŸđ", "🐰", "🐇", "ðŸĶŦ", "ðŸĶ”",
49     "ðŸĶ‡", "ðŸŧ", "ðŸĻ", "🐞", "ðŸĶĨ", "ðŸĶĶ", "ðŸĶĻ", "ðŸĶ˜", "ðŸĶĄ", "ðŸū", "ðŸĶƒ", "🐔", "🐓",
50     "ðŸĢ", "ðŸĪ", "ðŸĨ", "ðŸĶ", "🐧", "ðŸĶ…", "ðŸĶ†", "ðŸĶĒ", "ðŸŠŋ", "ðŸĶ‰", "ðŸĶĪ", "ðŸĶĐ", "ðŸĶš",
51     "ðŸĶœ", "ðŸļ", "🐊", "ðŸĒ", "ðŸĶŽ", "🐍", "ðŸē", "🐉", "ðŸĶ•", "ðŸĶ–", "ðŸģ", "🐋", "🐎",
52     "ðŸĶ­", "🐟", "🐠", "ðŸĄ", "ðŸĶˆ", "🐙", "🊞", "🐚", "ðŸĶ€", "ðŸĶž", "ðŸĶ", "ðŸĶ‘", "🐌",
53     "ðŸĶ‹", "🐛", "🐜", "🐝", "ðŸŠē", "🐞"];
55 function getRandomUserName() {
56     const nameRnd = getEntropy(4);
57     const r = ((nameRnd[0] & 0x7f) << 24) | (nameRnd[1] << 16) | (nameRnd[2] << 8) | nameRnd[3];
58     const emoji = DEFAULT_EMOJIS[r % DEFAULT_EMOJIS.length];
59     const name = DEFAULT_NAMES[r % DEFAULT_NAMES.length];
60     return `${emoji} ${name}${(r % 98) + 1}`;
64 //------------------------------------------------------------------------------
65 // Definition of a chat.
66 //------------------------------------------------------------------------------
68 class ChatSettings {
69     constructor(chatName, chatId, authKey, messageKey, serverUrl, serverKey) {
70         this.chatName = chatName;      // String
71         this.chatId = chatId;          // Uint8Array
72         this.authKey = authKey;        // Uint8Array
73         this.messageKey = messageKey;  // Uint8Array
74         this.serverUrl = serverUrl;    // String
75         this.serverKey = serverKey;    // Uint8Array
76     }
78     static default() {
79         return new ChatSettings(
80             "Public chat",
81             conv.b64ToBin("zkO7ICAR4lWhrUV0sZTNiA"),
82             conv.b64ToBin("WLPO0bGrm8hSDxjm7e_pS1_beHF9XDcFN4R7dcmJzN4"),
83             conv.b64ToBin("oGfg6ezeESTA0v8PO8ld9td7zJHhIpg93Epy15jvyEI"),
84             `${document.location.href}/server`,
85             conv.b64ToBin("GVFFyPAEh9ng1Cep30aDrJwgpOTTGjvsx9pejDDyiyE"),
86         );
87     }
89     static fromJsonString(jsonString) {
90         const settings = JSON.parse(jsonString);
91         return new ChatSettings(settings.chatName,
92                                 conv.b64ToBin(settings.chatId),
93                                 conv.b64ToBin(settings.authKey),
94                                 conv.b64ToBin(settings.messageKey),
95                                 settings.serverUrl,
96                                 conv.b64ToBin(settings.serverKey));
97     }
99     toJsonString() {
100         const settingsDict = {};
101         settingsDict["chatName"] = this.chatName;
102         settingsDict["chatId"] = conv.binToB64(this.chatId);
103         settingsDict["authKey"] = conv.binToB64(this.authKey);
104         settingsDict["messageKey"] = conv.binToB64(this.messageKey);
105         settingsDict["serverUrl"] = this.serverUrl;
106         settingsDict["serverKey"] = conv.binToB64(this.serverKey);
107         return JSON.stringify(settingsDict);
108     }
111 //------------------------------------------------------------------------------
112 // Global application state.
113 //------------------------------------------------------------------------------
115 let g_state = {
116     "showErrors": true,
118     "userName": null,
119     "userId": null,
121     "chats": [],
122     "activeChatNo": 0,
124     "appActive": true,
125     "currentView": null,
126     "currentMenu": null,
127     "db": null,
129     "protoMessages": {
130         "byId": [],
131         "byTime": [],
132         "loadIdx": 0
133     },
135     "updateListIntervalId": null,
136     "pendingListRequests": [],
137     "pendingReadRequests": [],
139     "map": null,
140     "mapYouMarker": null,
141     "mapYouCoord": null,
142     "navigatorWatchId": null,
143     "navigatorIntervalId": null,
145     "videoElement": null,
146     "cameraClick": false,
148     "fsElement": null,
149     "fsCaption": null,
151     "preferWebp": true,
154 function activeChat() {
155     return g_state.chats[g_state.activeChatNo];
158 function overwriteActiveChat(chatSettings) {
159     g_state.chats[g_state.activeChatNo] = chatSettings;
163 //------------------------------------------------------------------------------
164 // Debugging.
165 //------------------------------------------------------------------------------
167 function logMessagePolling(message) {
168     console.log(message);
171 function logSettings(message) {
172     console.log(message);
175 function logMisc(message) {
176     console.log(message);
179 function warn(message) {
180     console.warn(message);
181     if (g_state.showErrors) {
182         alert(message);
183     }
186 function error(message) {
187     console.error(message);
188     if (g_state.showErrors) {
189         alert(message);
190     }
194 //------------------------------------------------------------------------------
195 // Helpers.
196 //------------------------------------------------------------------------------
198 function getEntropy(numBytes) {
199     const result = new Uint8Array(numBytes);
200     self.crypto.getRandomValues(result);
201     return result;
204 function getTimestamp() {
205     return Date.now() * 1000;
208 function encrypt(plaintext) {
209     // Generate a random nonce and encrypt.
210     const nonce = getEntropy(12);
211     const key = activeChat().messageKey;
212     const ciphertext = new chacha.JSChaCha20(key, nonce).encrypt(plaintext);
214     // Concatenate the nonce and ciphertext.
215     let nonceAndChiphertext = new Uint8Array(nonce.length + ciphertext.length);
216     nonceAndChiphertext.set(nonce);
217     nonceAndChiphertext.set(ciphertext, nonce.length);
219     return nonceAndChiphertext;
222 function decrypt(nonceAndChiphertext) {
223     // Extract the nonce and decrypt.
224     const nonce = nonceAndChiphertext.subarray(0, 12);
225     const key = activeChat().messageKey;
226     const ciphertext = nonceAndChiphertext.subarray(12);
227     const message = new chacha.JSChaCha20(key, nonce).decrypt(ciphertext);
229     return message;
232 function deriveReadHash(messageId) {
233     const key = activeChat().authKey;
234     let input = messageId;
236     const NUM_ITERATIONS = 13;
237     for (let i = 0; i < NUM_ITERATIONS; ++i) {
238         input = blake.blake2b(input, key, 32);
239     }
241     return input;
244 function isPortraitMode() {
245     if (screen.orientation) {
246         return screen.orientation.type.includes("portrait");
247     }
248     return false;  // Assume landscape mode if we don't know.
252 //------------------------------------------------------------------------------
253 // Communication with the server.
254 //------------------------------------------------------------------------------
256 function urlForCommand(command) {
257     if (command === "list") {
258         return activeChat().serverUrl + "/list.php";
259     } else if (command === "read") {
260         return activeChat().serverUrl + "/read.php";
261     } else if (command === "publish") {
262         return activeChat().serverUrl + "/publish.php";
263     }
266 function sendRequest(command, bodyParts, requestHandler, meta={}, requestList=null) {
267     // Construct the body (make room for the message MAC).
268     const MAC_SIZE = 32;
269     let bodySize = MAC_SIZE;
270     for (const part of bodyParts) {
271         bodySize += part.length;
272     }
273     let body = new Uint8Array(bodySize);
274     let pos = MAC_SIZE;
275     for (const part of bodyParts) {
276         body.set(part, pos);
277         pos += part.length;
278     }
280     // Calculate the MAC (we use keyed BLAKE2b).
281     const MAX_NUM_BYTES_TO_HASH = 512;
282     const startOfBody = body.subarray(MAC_SIZE, MAC_SIZE + MAX_NUM_BYTES_TO_HASH);
283     const mac = blake.blake2b(startOfBody, activeChat().serverKey, MAC_SIZE);
284     body.set(mac, 0);
286     // Send request.
287     const req = new XMLHttpRequest();
288     req.addEventListener("load", requestHandler);
289     if (requestList) {
290         // Keep track of ongoing requests.
291         requestList.push(req);
292         req.addEventListener("loadend", (event) => {
293             logMessagePolling(`Request done: ${req.responseURL}`);
294             const idx = requestList.indexOf(req);
295             if (idx > -1) {
296                 requestList.splice(idx, 1);
297                 logMessagePolling("Removing request from list");
298             } else {
299                 error("An unknonw request just finished??");
300             }
301         });
302     }
303     req.open("POST", urlForCommand(command));
304     req.responseType = "arraybuffer";
305     req.chaChaChatMeta = meta;
306     req.send(body);
309 function responseTextOfReq(req) {
310     if (req.responseType == "arraybuffer") {
311         const textDecoder = new TextDecoder();
312         return textDecoder.decode(new Uint8Array(req.response));
313     }
314     return req.responseText;
318 //------------------------------------------------------------------------------
319 // Map logic.
320 //------------------------------------------------------------------------------
322 function updateGeolocationEvents() {
323     // Update map: Location of current user.
324     if (g_state.mapYouCoord !== null) {
325         const crd = g_state.mapYouCoord;
326         if (g_state.mapYouMarker === null) {
327             g_state.mapYouMarker = L.marker(crd, {"title": "You"}).addTo(g_state.map);
328             g_state.map.flyTo(crd, 17, {"duration": 2.0});
329         } else {
330             g_state.mapYouMarker.setLatLng(crd);
331             const follow = document.getElementById("map_cbox_follow").checked;
332             if (follow) {
333                 g_state.map.panTo(crd);
334             }
335         }
336     }
339 function enableGeolocation(enable) {
340     if (enable) {
341         if (g_state.navigatorWatchId === null) {
342             function navSuccess(pos) {
343                 const firstPosition = (g_state.mapYouCoord === null);
344                 g_state.mapYouCoord = [pos.coords.latitude, pos.coords.longitude];
345                 if (firstPosition) {
346                     updateGeolocationEvents();
347                     if (g_state.navigatorIntervalId == null) {
348                         g_state.navigatorIntervalId = setInterval(updateGeolocationEvents, 10000);
349                     }
350                 }
351             }
352             function navError(err) {
353                 error(`ERROR(${err.code}): ${err.message}`);
354             }
355             const navOptions = {
356                 enableHighAccuracy: true,
357                 timeout: 5000,
358                 maximumAge: 0,
359             };
360             navigator.geolocation.watchPosition(navSuccess, navError, navOptions);
361         }
362     } else {
363         if (g_state.navigatorIntervalId !== null) {
364             clearInterval(g_state.navigatorIntervalId);
365             g_state.navigatorIntervalId = null;
366         }
367         if (g_state.navigatorWatchId !== null) {
368             navigator.geolocation.clearWatch(g_state.navigatorWatchId);
369             g_state.navigatorWatchId = null;
370         }
371         if (g_state.mapYouMarker !== null) {
372             g_state.mapYouMarker.remove();
373             g_state.mapYouMarker = null;
374         }
375         g_state.mapYouCoord = null;
376     }
379 function eventHandler_map_cbox_follow(event) {
380     event.preventDefault();
381     event.stopPropagation();
383     const follow = document.getElementById("map_cbox_follow").checked;
384     if (follow) {
385         updateGeolocationEvents();
386     }
389 function eventHandler_map_btn_close(event) {
390     event.preventDefault();
391     event.stopPropagation();
392     history.back();
395 function eventHandler_chat_main_map() {
396     showView("map");
399 function showMapView() {
400     document.getElementById("map_view").style.display = "block";
402     if (!g_state.map) {
403         g_state.map = L.map('map').setView([49.0, 12.0], 2);
404         document.getElementsByClassName('leaflet-control-attribution')[0].style.display = 'none';
405         L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
406             maxZoom: 19
407         }).addTo(g_state.map);
408     }
410     enableGeolocation(true);
413 function hideMapView() {
414     enableGeolocation(false);
416     document.getElementById("map_view").style.display = "none";
420 //------------------------------------------------------------------------------
421 // Camera logic.
422 //------------------------------------------------------------------------------
424 function downscalePhoto(sourceCanvas, targetWidth) {
425     let res = {
426         width: sourceCanvas.width,
427         height: sourceCanvas.height
428     }
429     const targetHeight = Math.round(targetWidth * sourceCanvas.height / sourceCanvas.width);
431     // Successive down-scaling by a factor of 0.5.
432     if (sourceCanvas.width * 0.5 > targetWidth) {
433         let tmpCanvas = document.createElement("canvas");
434         let tmpCtx = tmpCanvas.getContext("2d");
436         res = {
437             width: Math.floor(sourceCanvas.width * 0.5),
438             height: Math.floor(sourceCanvas.height * 0.5)
439         }
440         tmpCanvas.width = res.width;
441         tmpCanvas.height = res.height;
443         tmpCtx.drawImage(sourceCanvas, 0, 0, res.width, res.height);
444         while (res.width * 0.5 > targetWidth) {
445             res = {
446                 width: Math.floor(res.width * 0.5),
447                 height: Math.floor(res.height * 0.5)
448             };
449             tmpCtx.drawImage(tmpCanvas, 0, 0, res.width * 2, res.height * 2, 0, 0, res.width, res.height);
450         }
452         sourceCanvas = tmpCanvas;
453     }
455     // Final scaling to the target size.
456     let canvas = document.createElement("canvas");
457     let ctx = canvas.getContext("2d");
458     canvas.width = targetWidth;
459     canvas.height = targetHeight;
460     ctx.drawImage(sourceCanvas, 0, 0, res.width, res.height, 0, 0, canvas.width, canvas.height);
462     logMisc(`Downscaled from ${sourceCanvas.width}x${sourceCanvas.height} to ${canvas.width}x${canvas.height}`);
464     return canvas;
467 function encodePhotoAsDataUrl(canvasElement) {
468     let dataUrl;
469     if (g_state.preferWebp) {
470         // We use WebP, quality 0.60, which usually gives decent quality and requires
471         // ~250KiB (ish) for detailed 1080x1920 resolution images.
472         dataUrl = canvasElement.toDataURL("image/webp", 0.60);
473         if (!dataUrl.startsWith("data:image/webp")) {
474             // Not all browsers support WebP (e.g. iOS Safari).
475             g_state.preferWebp = false;
476         }
477     }
478     if (!g_state.preferWebp) {
479         // If we can't use WebP, use JPEG instead.
480         dataUrl = canvasElement.toDataURL("image/jpeg", 0.50);
481     }
482     return dataUrl;
485 function getMimeTypeFromDataUrl(dataUrl) {
486     const endPos = dataUrl.search(/(;|,)/g);
487     if ((!dataUrl.startsWith("data:")) || (endPos < 0)) {
488         return "";
489     }
490     return dataUrl.slice(5, endPos);
493 function publishCameraPhoto() {
494     // Downscale the photo if necessary.
495     const MAX_WIDTH = 1080;
496     let canvasElement = document.getElementById("camera_canvas");
497     if (canvasElement.width > MAX_WIDTH) {
498         canvasElement = downscalePhoto(canvasElement, MAX_WIDTH);
499     }
501     // Compress the photo to a data URL.
502     const dataUrl = encodePhotoAsDataUrl(canvasElement);
503     const mimeType = getMimeTypeFromDataUrl(dataUrl);
505     // Convert the data URL to a typed array.
506     const binaryString = atob(dataUrl.slice(dataUrl.indexOf(",") + 1));
507     let data = new Uint8Array(binaryString.length);
508     for (let i = 0; i < binaryString.length; ++i) {
509         data[i] = binaryString.charCodeAt(i);
510     }
512     logMisc(`Final image size: ${data.length} bytes`);
514     // Publish the message.
515     publishMessage(mimeType, data);
518 function startCameraCapture(preferredWidth, frameHandler, errorHandler) {
519     if (!navigator.mediaDevices) {
520         errorHandler("No mediaDevices");
521         return;
522     }
524     // Get the screen aspect ratio.
525     // TODO: Use AR of the screen or the DOM element.
526     const preferredAspectRatio = 16 / 9;
528     // Open a camera video stream. Try to get the front facing camera, and select a suitable
529     // resolution.
530     let constraints = {
531         video: {
532             aspectRatio: { ideal: preferredAspectRatio },
533             facingMode: "environment",
534         },
535     };
536     if (isPortraitMode()) {
537         constraints.video.height = { ideal: preferredWidth };
538     } else {
539         constraints.video.width = { ideal: preferredWidth };
540     }
541     navigator.mediaDevices.getUserMedia(constraints).then((stream) => {
542         g_state.videoElement.srcObject = stream;
543         g_state.videoElement.setAttribute("playsinline", true);  // We don't want fullscreen.
544         g_state.videoElement.play();
545         requestAnimationFrame(frameHandler);
546     })
547     .catch((err) => {
548         error("Could not get video stream");
549         errorHandler(err);
550     });
553 function stopCameraCapture() {
554     if (g_state.videoElement) {
555         if (g_state.videoElement.srcObject) {
556             g_state.videoElement.srcObject.getTracks().forEach(track => track.stop());
557         }
558         g_state.videoElement.remove();
559         g_state.videoElement = null;
560     }
562     g_state.cameraClick = false;
565 function eventHandler_camera_btn_click(event) {
566     event.preventDefault();
567     event.stopPropagation();
568     g_state.cameraClick = true;
571 function eventHandler_camera_btn_accept(event) {
572     event.preventDefault();
573     event.stopPropagation();
574     publishCameraPhoto();
575     history.back();
578 function eventHandler_camera_btn_discard(event) {
579     event.preventDefault();
580     event.stopPropagation();
581     resumeCameraView(false);
584 function resumeCameraView(clearCanvas=true) {
585     document.getElementById("camera_controls").style.display = "block";
586     document.getElementById("camera_accept").style.display = "none";
588     // Initially disable the "take photo" button.
589     document.getElementById("camera_btn_click").classList.add("button_disabled");
591     g_state.cameraClick = false;
593     // Get the canvas context.
594     if (!g_state.videoElement) {
595         g_state.videoElement = document.createElement("video");
596     }
597     let canvasElement = document.getElementById("camera_canvas");
598     let ctx = canvasElement.getContext("2d");
600     // Start by filling the canvas with some blank content (clear any previous image).
601     if (clearCanvas) {
602         ctx.fillStyle = "rgb(50, 50, 60)";
603         ctx.fillRect(0, 0, canvasElement.width, canvasElement.height);
604     }
606     // Frame grabber & camera image drawing function.
607     function handleNextVideoFrame() {
608         // Break animation & capture if we're no longer active.
609         if (g_state.currentView !== "camera") {
610             return;
611         }
613         if (g_state.videoElement.readyState === g_state.videoElement.HAVE_ENOUGH_DATA) {
614             // Draw the current video frame to the canvas (i.e. capture it and show it).
615             if (canvasElement.width !== g_state.videoElement.videoWidth ||
616                 canvasElement.height !== g_state.videoElement.videoHeight) {
617                 logMisc(`Camera resolution: ${g_state.videoElement.videoWidth} x ${g_state.videoElement.videoHeight}`);
618             }
620             canvasElement.width = g_state.videoElement.videoWidth;
621             canvasElement.height = g_state.videoElement.videoHeight;
622             ctx.drawImage(g_state.videoElement, 0, 0, canvasElement.width, canvasElement.height);
624             // Enable the "take photo" button.
625             document.getElementById("camera_btn_click").classList.remove("button_disabled");
626         }
628         if (g_state.cameraClick) {
629             stopCameraCapture();
631             // Show the yay/nay option buttons.
632             document.getElementById("camera_controls").style.display = "none";
633             document.getElementById("camera_accept").style.display = "block";
634         } else {
635             requestAnimationFrame(handleNextVideoFrame);
636         }
637     }
639     // Start capturing camera frames.
640     startCameraCapture(1080, handleNextVideoFrame, (err) => {
641         // TODO: Print some error message to the canvas.
642     });
645 function showCameraView() {
646     document.getElementById("camera_view").style.display = "block";
647     resumeCameraView(true);
650 function hideCameraView() {
651     stopCameraCapture();
652     document.getElementById("camera_view").style.display = "none";
656 //------------------------------------------------------------------------------
657 // Message display logic.
658 //------------------------------------------------------------------------------
660 function isBigText(text) {
661     // Skip analysis if this is a long text, in which case we say that the text should be small.
662     if (text.length > 50) {
663         return false;
664     }
666     // Analyze the text contents.
667     let actualLength = 0;
668     let numEmojis = 0;
669     let numNonEmojis = 0;
670     for (let i = 0; i < text.length; ++i) {
671         const c = text.codePointAt(i);
672         if (c >= 0xdc00 && c <= 0xdfff) {
673             // Lower part of a surrogate pair: Ignore.
674             continue;
675         }
676         ++actualLength;
678         // Emoji?
679         // TODO: Improve these heuristics.
680         if ((c >= 0x2300 && c <= 0x27ff) || (c >= 0x1f000)) {
681             ++numEmojis;
682             continue;
683         }
685         // White-space?
686         if (c == 32) {
687             continue;
688         }
690         // Anything else is considered non-emoji.
691         ++numNonEmojis;
692     }
694     // This is a "big" text if it is emoji-like.
695     return (numEmojis >= 1) && ((actualLength <= 3) ||
696                                 (numNonEmojis <= 2 && actualLength <= 6));
699 function timeToStr(time) {
700     // Time is in microseconds, so convert it to milliseconds first.
701     const milliseconds = time / 1000;
702     const d = new Date(milliseconds);
704     let result;
705     try {
706         // Get current locale.
707         const locale = Intl.DateTimeFormat().resolvedOptions().locale;
709         // Get time (hours & minutes).
710         result = d.toLocaleTimeString(locale, { timeStyle: "short"});
712         // Get day of week (ignore if same as today).
713         const dateTimeFmr = new Intl.DateTimeFormat(locale, { weekday: "short" });
714         const dayStr = dateTimeFmr.format(d);
715         if (dayStr !== dateTimeFmr.format(new Date())) {
716             result = `${dayStr} ${result}`;
717         }
718     } catch {
719         // Fall back to using a shortened version of Date.toString().
720         result = d.toString();
721         const gmtPos = result.indexOf(" GMT");
722         if (gmtPos > 0) {
723             result = result.substring(0, gmtPos);
724         }
725     }
727     return result;
730 function hsvToRgb(h, s, v) {
731     const hh = (Math.abs(h) % 360.0) / 60.0;
732     const i = hh | 0;
733     const ff = hh - i;
734     const p = v * (1.0 - s);
735     const q = v * (1.0 - (s * ff));
736     const t = v * (1.0 - (s * (1.0 - ff)));
738     switch (i) {
739         default:
740         case 0: return [v, t, p];
741         case 1: return [q, v, p];
742         case 2: return [p, v, t];
743         case 3: return [p, q, v];
744         case 4: return [t, p, v];
745         case 5: return [v, p, p];
746     }
749 function binToColor(uint8Array) {
750     // Generate a 24-bit "checksum" of the ID.
751     let num24 = 0x555555;
752     for (const b of uint8Array) {
753         num24 = (((num24 << 14) | (num24 >> 10)) ^ b) & 0xffffff;
754     }
756     // Extract H, S, V.
757     let h = ((num24 >> 16) & 0xff) * (360.0 / 255.0);
758     let s = ((num24 >> 8) & 0xff) * (1.0 / 255.0);
759     let v = ((num24 >> 0) & 0xff) * (1.0 / 255.0);
761     // Tune HSV into a "pleasing" color.
762     s = 0.05 + 0.10 * s;
763     v = 0.92 + 0.07 * v;
765     // Convert to a color that can be used in a style.
766     const [r, g, b] = hsvToRgb(h, s, v);
767     function to8bit(col) {
768         return Math.max(0, Math.min(255, (col * 255.0) | 0));
769     }
770     return `rgb(${to8bit(r)} ${to8bit(g)} ${to8bit(b)})`;
773 function createMessageDiv(message, klass) {
774     // Create the message container div.
775     let chatDiv = document.getElementById("chat");
776     let containerDiv = document.createElement("div");
777     containerDiv.classList.add("msg_container");
778     containerDiv.setAttribute("data-id", conv.binToHex(message.messageId));
779     containerDiv.setAttribute("data-time", message.time.toString());
781     // Insert the div at the correct time (sorted).
782     let afterNode = null;
783     for (let i = chatDiv.childElementCount - 1; i >= 0; --i) {
784         const e = chatDiv.children[i];
785         const t = parseInt(e.getAttribute("data-time"));
786         if (t < message.time) {
787             afterNode = e;
788             break;
789         }
790     }
791     if (afterNode) {
792         chatDiv.insertBefore(containerDiv, afterNode.nextSibling);
793     } else {
794         chatDiv.appendChild(containerDiv);
795     }
797     // Create the caption div.
798     let captionDiv = document.createElement("div");
799     captionDiv.classList.add(`caption_${klass}`);
800     captionDiv.innerText = `${message.senderName} - ${timeToStr(message.time)}`;
801     containerDiv.appendChild(captionDiv);
803     // Create the message div.
804     let messageDiv = document.createElement("div");
805     messageDiv.classList.add(klass);
806     messageDiv.style.backgroundColor = binToColor(message.senderId);
807     containerDiv.appendChild(messageDiv);
809     return messageDiv;
812 function showMessage(message, afterNode = null) {
813     // Did me or someone else send this message?
814     const weSentIt = conv.u8ArraysEqual(message.senderId, g_state.userId);
815     const klass = weSentIt ? "out" : "in";
817     // Create the message box.
818     let notificationText = message.senderName;
819     let messageDiv = createMessageDiv(message, klass);
820     if (message.mimeType === "text/plain") {
821         const text = conv.binToStr(message.body);
822         if (isBigText(text)) {
823             messageDiv.classList.add("big");
824         }
825         messageDiv.innerText = text;
827         // Include a short version of the text in the notification.
828         const MAX_SHORT_TEXT_LEN = 30;
829         const shortText = text.length < MAX_SHORT_TEXT_LEN ?
830                 text :
831                 text.substring(0, MAX_SHORT_TEXT_LEN) + "...";
832         notificationText += `: ${shortText}`;
833     } else if (message.mimeType.startsWith("image/")) {
834         let img = document.createElement("img");
835         img.src = conv.binToDataUri(message.body, message.mimeType);
836         img.classList.add("preview");
838         img.addEventListener("click", (event) => {
839             event.preventDefault();
840             event.stopPropagation();
841             let fsImg = document.createElement("img");
842             fsImg.src = img.src;
843             const imageCaption = `${message.senderName} - ${timeToStr(message.time)}`;
844             showImageAsFullscreen(fsImg, imageCaption);
845         });
847         messageDiv.appendChild(img);
849         notificationText += " sent an image";
850     } else {
851         error(`Unsupported message mime type: ${message.mimeType}`);
852         notificationText = "";
853     }
855     // Show a notification?
856     if ((!g_state.appActive) && (!weSentIt) && notificationText) {
857         showNotification(notificationText);
858     }
860     // Scroll to the end of the list.
861     scrollLastMessageIntoView();
864 function scrollLastMessageIntoView() {
865     // Scroll to the end of the list.
866     const scrollableDiv = document.getElementById("chat");
867     if (scrollableDiv.lastChild) {
868         scrollableDiv.lastChild.scrollIntoView(false);
869     }
873 //------------------------------------------------------------------------------
874 // Chat list update logic.
875 //------------------------------------------------------------------------------
877 function reqHandler_Read() {
878     try {
879         // Extract request meta data (not part of the encrypted message).
880         if (!("chaChaChatMeta" in this)) {
881             error(`Missing message meta in request`);
882             return;
883         }
884         let protoMessage = this.chaChaChatMeta;
886         // Did we get an error response for this message?
887         if (this.status != 200) {
888             protoMessage.status = "error";
889             error(`Error ${this.status}: ${responseTextOfReq(this)}`);
890             return;
891         }
893         // Decrypt the message body.
894         const binMessage = decrypt(new Uint8Array(this.response));
895         const message = msg.Message.deserialize(protoMessage.time, protoMessage.messageId, binMessage);
896         if (!message) {
897             protoMessage.status = "error";
898             error(`Unable to decode message ${protoMessage.messageId}`);
899             return;
900         }
902         // The message is now loaded.
903         protoMessage.status = "loaded";
905         // If this was a visual message, show it.
906         if (message.isVisible()) {
907             showMessage(message);
908         }
909     } finally {
910         // Send of a new read request, if there are any pending messages to load.
911         readNextPendingMessage();
912     }
915 function readMessage(protoMessage) {
916     // Prepare request arguments.
917     const readHash = deriveReadHash(protoMessage.messageId);
919     // Send the request.
920     const bodyParts = [
921         activeChat().chatId,     // Chat ID
922         protoMessage.messageId,  // Message ID
923         readHash                 // Read hash
924     ];
925     sendRequest("read", bodyParts, reqHandler_Read, protoMessage, g_state.pendingReadRequests);
928 function readNextPendingMessage() {
929     if (g_state.protoMessages.loadIdx < g_state.protoMessages.byTime.length) {
930         let protoMessage = g_state.protoMessages.byTime[g_state.protoMessages.loadIdx];
931         ++g_state.protoMessages.loadIdx;
932         if (protoMessage.status === "pending") {
933             readMessage(protoMessage);
934         }
935     }
938 function reqHandler_List() {
939     try {
940         // The server may send a 408 (timout) if no new message has arrived, which is expected.
941         if (this.status != 200) {
942             if (this.status != 408) {
943                 error(`Error ${this.status}: ${responseTextOfReq(this)}`);
944             }
945             logMessagePolling(`List timeout: ${this.responseURL}`);
946             return;
947         }
948         logMessagePolling(`Got list response: ${this.responseURL}`);
950         // Collect and sort all the message IDs that the server listed.
951         const messages = JSON.parse(responseTextOfReq(this));
952         let newProtoMessages = [];
953         for (const obj of messages) {
954             const protoMessage = {
955                 time: obj.time,
956                 messageId: conv.hexToBin(obj.messageId),
957                 status: "pending"
958             };
959             newProtoMessages.push(protoMessage);
960         }
961         newProtoMessages.sort(function(a, b) { return a.time - b.time; });
963         // Append the new messages to our proto message queue.
964         for (const protoMessage of newProtoMessages) {
965             const messageIdHex = conv.binToHex(protoMessage.messageId);
966             if (!(messageIdHex in g_state.protoMessages.byId)) {
967                 g_state.protoMessages.byId[messageIdHex] = protoMessage;
968                 g_state.protoMessages.byTime.push(protoMessage);
969             }
970         }
971     } finally {
972         // Start loading pending messages. Eventially schedules new message polling.
973         readNextPendingMessage();
974     }
977 function updateList() {
978     if (g_state.pendingListRequests.length > 0) {
979         logMessagePolling(`There are ${g_state.pendingListRequests.length} pending list request. Not renewing.`);
980         return;
981     }
982     if (g_state.pendingReadRequests.length > 0) {
983         logMessagePolling(`There are ${g_state.pendingReadRequests.length} pending read request. Not renewing.`);
984         return;
985     }
987     logMessagePolling("Checking for new messages...");
989     // Get the latest timestamp.
990     let latestTime = 0;
991     if (g_state.protoMessages.byTime.length > 0) {
992         latestTime = g_state.protoMessages.byTime[g_state.protoMessages.byTime.length - 1].time;
993     }
995     // Send the request.
996     const bodyParts = [
997         activeChat().chatId,         // Chat ID
998         conv.int64ToBin(latestTime)  // Timestamp
999     ];
1000     sendRequest("list", bodyParts, reqHandler_List, {}, g_state.pendingListRequests);
1003 function startMessagePolling() {
1004     // Stop polling now, in case there are any pending polling requests, to ensure that we start
1005     // polling immediately.
1006     stopMessagePolling();
1008     // Do an initial poll immediately.
1009     updateList();
1011     // Start the interval timer.
1012     // TODO: Use longer interval when app is inactive?
1013     const TIME_BETWEEN_UPDATES = 1000;
1014     g_state.updateListIntervalId = setInterval(() => {
1015         updateList();
1016     }, TIME_BETWEEN_UPDATES);
1019 function stopMessagePolling() {
1020     // Abort any pending timer events.
1021     if (g_state.updateListIntervalId) {
1022         clearInterval(g_state.updateListIntervalId);
1023         g_state.updateListIntervalId = null;
1024     }
1026     // Abort any pending requests.
1027     g_state.pendingListRequests.forEach(req => {
1028         req.abort();
1029     });
1030     g_state.pendingListRequests = [];
1031     g_state.pendingReadRequests.forEach(req => {
1032         req.abort();
1033     });
1034     g_state.pendingReadRequests = [];
1037 function clearChat() {
1038     // Clear all the messages.
1039     g_state.protoMessages.byId = {};
1040     g_state.protoMessages.byTime = [];
1041     g_state.protoMessages.loadIdx = 0;
1043     // Clear the chat window.
1044     let chatDiv = document.getElementById("chat");
1045     while (chatDiv.hasChildNodes()) {
1046         chatDiv.removeChild(chatDiv.lastChild);
1047     }
1049     // Set the chat title.
1050     document.getElementById("chat_name").innerText = activeChat().chatName;
1054 //------------------------------------------------------------------------------
1055 // Chat message publication logic.
1056 //------------------------------------------------------------------------------
1058 function reqHandler_Publish() {
1059     if (this.status != 200) {
1060         error(`Error ${this.status}: ${responseTextOfReq(this)}`);
1061         return;
1062     }
1065 function publishMessage(mimeType, body) {
1066     // Get and encrypt the message.
1067     const time = 0;  // (Placeholder, re-defined by server)
1068     const messageId = getEntropy(32);
1069     const senderTime = getTimestamp();
1070     const senderId = g_state.userId;
1071     const senderName = g_state.userName;
1073     const encryptedMessage = encrypt((new msg.Message(
1074         time,
1075         messageId,
1076         senderTime,
1077         senderId,
1078         senderName,
1079         mimeType,
1080         body
1081     )).serialize());
1083     // Prepare the secret read hash for this message.
1084     const readHash = deriveReadHash(messageId);
1086     // Send the request.
1087     const bodyParts = [
1088         activeChat().chatId,  // Chat ID
1089         messageId,            // Message ID
1090         readHash,             // Read hash (for read requests)
1091         encryptedMessage      // The actual message
1092     ];
1093     sendRequest("publish", bodyParts, reqHandler_Publish);
1096 function publishFileMessage(file) {
1097     // Get and check the file mime type.
1098     const mimeType = file.type;
1099     if (!conv.isSupportedMessageFileType(mimeType)) {
1100         alert(`Unsupported file format: ${mimeType}`);
1101         return;
1102     }
1104     // Load the file into a binary array, and publish it.
1105     let reader = new FileReader();
1106     reader.onload = function(event) {
1107         const data = new Uint8Array(reader.result);
1108         publishMessage(mimeType, data);
1109     };
1110     reader.readAsArrayBuffer(file);
1113 function eventHandler_chat_main_menu_refresh(event) {
1114     clearChat();
1115     startMessagePolling();
1118 function eventHandler_chat_btn_main_menu(event) {
1119     event.preventDefault();
1120     event.stopPropagation();
1121     showMenu("chat_main");
1124 function eventHandler_chat_btn_chat_menu(event) {
1125     event.preventDefault();
1126     event.stopPropagation();
1127     showMenu("chat_chat");
1130 function eventHandler_out(event) {
1131     event.preventDefault();
1132     event.stopPropagation();
1134     // Get the message text from the UI text box.
1135     let messageInput = document.getElementById("out_message");
1136     const message = messageInput.value;
1137     messageInput.value = "";
1139     // Send the message.
1140     if (message.length > 0) {
1141         publishMessage("text/plain", conv.strToBin(message));
1142     }
1145 function eventHandler_btn_camera(event) {
1146     event.preventDefault();
1147     event.stopPropagation();
1148     showView("camera");
1151 function eventHandler_main_drop(event) {
1152     event.stopPropagation();
1153     event.preventDefault();
1155     // Get the dropped file
1156     const files = event.dataTransfer.files;
1157     if (files.length != 1) {
1158       alert("Only drop one file at a time.");
1159       return;
1160     }
1161     const file = files[0];
1163     // Publish the file contents.
1164     publishFileMessage(file);
1168 //------------------------------------------------------------------------------
1169 // Fullscreen view logic.
1170 //------------------------------------------------------------------------------
1172 function eventHandler_fullscreen_view_click(event) {
1173     event.preventDefault();
1174     event.stopPropagation();
1175     history.back();
1178 function showFullscreenView() {
1179     document.getElementById("fullscreen_view").style.display = "block";
1181     let fullscreenTop = document.getElementById("fullscreen_view");
1182     if (g_state.fsCaption) {
1183         let captionElement = document.createElement("div");
1184         captionElement.classList.add("fullscreen_caption");
1185         captionElement.innerText = g_state.fsCaption;
1186         fullscreenTop.appendChild(captionElement);
1187     }
1188     if (g_state.fsElement) {
1189         fullscreenTop.appendChild(g_state.fsElement);
1190     }
1192     // Enable zooming.
1193     document.getElementById("viewport").setAttribute(
1194         "content", "width=device-width, maximum-scale=4, minimum-scale=1, target-densitydpi=device-dpi");
1197 function hideFullscreenView() {
1198     let fullscreenTop = document.getElementById("fullscreen_view");
1199     while (fullscreenTop.hasChildNodes()) {
1200         let child = fullscreenTop.lastChild;
1201         fullscreenTop.removeChild(child);
1202         child.remove();
1203     }
1204     g_state.fsElement = null;
1205     g_state.fsCaption = null;
1207     // Restore zooming settings.
1208     document.getElementById("viewport").setAttribute(
1209         "content", "width=device-width, maximum-scale=1, minimum-scale=1, target-densitydpi=device-dpi");
1211     document.getElementById("fullscreen_view").style.display = "none";
1214 function showImageAsFullscreen(img, caption) {
1215     g_state.fsElement = img;
1216     g_state.fsCaption = caption;
1217     showView("fullscreen");
1221 //------------------------------------------------------------------------------
1222 // Chat view logic.
1223 //------------------------------------------------------------------------------
1225 function showChatView() {
1226     document.getElementById("chat_view").style.display = "block";
1228     startMessagePolling();
1229     scrollLastMessageIntoView();
1232 function hideChatView() {
1233     stopMessagePolling();
1235     document.getElementById("chat_view").style.display = "none";
1239 //------------------------------------------------------------------------------
1240 // QR code scanner logic.
1241 //------------------------------------------------------------------------------
1243 function applyChatSettingsFromQRCode(settingsStr) {
1244     let settings;
1245     try {
1246         settings = ChatSettings.fromJsonString(settingsStr);
1247     } catch {
1248         alert("Invalid chat configuration QR code");
1249         return false;
1250     }
1251     if (window.confirm(`Join the chat "${settings.chatName}"?`)) {
1252         try {
1253             // TODO: Append this a new chat instead of overwriting.
1254             overwriteActiveChat(settings);
1256             // Apply the settings.
1257             saveSettings();
1258             clearChat();
1259             return true;
1260         } catch {
1261             error("Unable to apply new chat settings");
1262         }
1263     }
1264     return false;
1267 function showQRScannerView() {
1268     document.getElementById("qrscanner_view").style.display = "block";
1270     if (!g_state.videoElement) {
1271         g_state.videoElement = document.createElement("video");
1272     }
1273     let canvasElement = document.getElementById("qr_scanner_canvas");
1274     let ctx = canvasElement.getContext("2d");
1275     let identicalResultCount = 0;
1276     let finalResult = "";
1278     function drawLine(begin, end, color) {
1279       ctx.beginPath();
1280       ctx.moveTo(begin.x, begin.y);
1281       ctx.lineTo(end.x, end.y);
1282       ctx.lineWidth = 4;
1283       ctx.strokeStyle = color;
1284       ctx.stroke();
1285     }
1287     // Start by filling the canvas with some blank content (clear any previous image).
1288     ctx.fillStyle = "rgb(50, 50, 60)";
1289     ctx.fillRect(0, 0, canvasElement.width, canvasElement.height);
1290     
1291     // Frame grabber & camera image drawing function.
1292     function handleNextVideoFrame() {
1293         // Break animation & capture if we're no longer active.
1294         if (g_state.currentView !== "qrscanner") {
1295             return;
1296         }
1298         if (g_state.videoElement.readyState === g_state.videoElement.HAVE_ENOUGH_DATA) {
1299             canvasElement.height = g_state.videoElement.videoHeight;
1300             canvasElement.width = g_state.videoElement.videoWidth;
1301             ctx.drawImage(g_state.videoElement, 0, 0, canvasElement.width, canvasElement.height);
1302             const imageData = ctx.getImageData(0, 0, canvasElement.width, canvasElement.height);
1303             let code = jsQR(imageData.data, imageData.width, imageData.height, {
1304                 inversionAttempts: "dontInvert",
1305             });
1306             if (code) {
1307                 drawLine(code.location.topLeftCorner, code.location.topRightCorner, "#FF3B58");
1308                 drawLine(code.location.topRightCorner, code.location.bottomRightCorner, "#FF3B58");
1309                 drawLine(code.location.bottomRightCorner, code.location.bottomLeftCorner, "#FF3B58");
1310                 drawLine(code.location.bottomLeftCorner, code.location.topLeftCorner, "#FF3B58");
1312                 // We have a result:
1313                 let result = code.data;
1314                 if (result == finalResult) {
1315                     identicalResultCount++;
1316                 } else {
1317                     finalResult = result;
1318                     identicalResultCount = 0;
1319                 }
1320             }
1321         }
1323         // Did we get a reliable result?
1324         let didApply = false;
1325         if (identicalResultCount >= 3) {
1326             didApply = applyChatSettingsFromQRCode(finalResult);
1327             if (!didApply) {
1328                 identicalResultCount = 0;
1329             }
1330         }
1331         if (didApply) {
1332             history.back();
1333         } else {
1334             requestAnimationFrame(handleNextVideoFrame);
1335         }
1336     }
1338     // Start capturing camera frames.
1339     startCameraCapture(480, handleNextVideoFrame, (err) => {
1340         // TODO: Print some error message to the canvas.
1341     });
1344 function hideQRScannerView() {
1345     if (g_state.videoElement) {
1346         if (g_state.videoElement.srcObject) {
1347             g_state.videoElement.srcObject.getTracks().forEach(track => track.stop());
1348         }
1349         g_state.videoElement.remove();
1350         g_state.videoElement = null;
1351     }
1353     document.getElementById("qrscanner_view").style.display = "none";
1357 //------------------------------------------------------------------------------
1358 // Settings logic.
1359 //------------------------------------------------------------------------------
1361 function showChatQrCodeAsFullscreen() {
1362     // Construct the chat configuration string.
1363     const settings_str = activeChat().toJsonString();
1365     // Create QR code image.
1366     new AwesomeQR.AwesomeQR({
1367         text: settings_str,
1368         size: 512,
1369     }).draw().then((dataURL) => {
1370         let img = document.createElement("img");
1371         img.src = dataURL;
1372         showImageAsFullscreen(img, activeChat().chatName);
1373     });
1376 function eventHandler_settings_new_user(event) {
1377     event.preventDefault();
1378     event.stopPropagation();
1379     let newUserName = window.prompt("What is your name?")
1380     if (newUserName) {
1381         document.getElementById("settings_user_name").value = newUserName;
1382         document.getElementById("settings_user_id").value = conv.binToB64(getEntropy(16));
1383     }
1386 function eventHandler_settings_btn_apply(event) {
1387     event.preventDefault();
1388     event.stopPropagation();
1390     // Update configuration from the UI.
1391     g_state.userName = document.getElementById("settings_user_name").value;
1392     g_state.userId = conv.b64ToBin(document.getElementById("settings_user_id").value);
1394     overwriteActiveChat(new ChatSettings(
1395             document.getElementById("settings_chat_name").value,
1396             conv.b64ToBin(document.getElementById("settings_chat_id").value),
1397             conv.b64ToBin(document.getElementById("settings_auth_key").value),
1398             conv.b64ToBin(document.getElementById("settings_message_key").value),
1399             document.getElementById("settings_server_url").value,
1400             conv.b64ToBin(document.getElementById("settings_server_key").value)));
1402     // Apply other settings.
1403     // TODO: Store this in the Indexed DB settings.
1404     g_state.showErrors = document.getElementById("settings_show_errors").checked;
1406     // Apply the changes.
1407     saveSettings();
1408     clearChat();
1410     history.back();
1413 function eventHandler_settings_btn_cacnel(event) {
1414     event.preventDefault();
1415     event.stopPropagation();
1416     history.back();
1419 function eventHandler_chat_main_settings() {
1420     // Populate the UI with the current settings.
1421     document.getElementById("settings_user_name").value = g_state.userName;
1422     document.getElementById("settings_user_id").value = conv.binToB64(g_state.userId);
1424     document.getElementById("settings_chat_name").value = activeChat().chatName;
1425     document.getElementById("settings_chat_id").value = conv.binToB64(activeChat().chatId);
1426     document.getElementById("settings_auth_key").value = conv.binToB64(activeChat().authKey);
1427     document.getElementById("settings_message_key").value = conv.binToB64(activeChat().messageKey);
1428     document.getElementById("settings_server_url").value = activeChat().serverUrl;
1429     document.getElementById("settings_server_key").value = conv.binToB64(activeChat().serverKey);
1431     document.getElementById("settings_show_errors").checked = g_state.showErrors;
1433     showView("settings");
1436 function eventHandler_chat_main_menu_reload() {
1437     location.reload();
1440 function showSettingsView() {
1441     document.getElementById("settings_view").style.display = "block";
1444 function hideSettingsView() {
1445     document.getElementById("settings_view").style.display = "none";
1449 //------------------------------------------------------------------------------
1450 // About view logic.
1451 //------------------------------------------------------------------------------
1453 function eventHandler_about_btn_close(event) {
1454     event.preventDefault();
1455     event.stopPropagation();
1456     history.back();
1459 function eventHandler_chat_main_about() {
1460     showView("about");
1463 function showAboutView() {
1464     document.getElementById("about_view").style.display = "block";
1467 function hideAboutView() {
1468     document.getElementById("about_view").style.display = "none";
1472 //------------------------------------------------------------------------------
1473 // Application & UI logic.
1474 //------------------------------------------------------------------------------
1476 function enableNotifications() {
1477     // Check if the browser supports notifications.
1478     if (!("Notification" in window)) {
1479         return;
1480     } else if (Notification.permission === "granted") {
1481         return;
1482     } else if (Notification.permission !== "denied") {
1483         // We need to ask the user for permission
1484         Notification.requestPermission().then((permission) => {
1485             if (permission !== "granted") {
1486                 warn("Notifications were denied");
1487             }
1488         });
1489     }
1492 function showNotification(text) {
1493     if (("Notification" in window) && (Notification.permission === "granted")) {
1494         const notification = new Notification(text);
1495     }
1498 function showView(view, pushState=true) {
1499     // Try to enable notifications as a response to user interaction.
1500     enableNotifications();
1502     if (g_state.currentMenu) {
1503         document.getElementById("body_container").removeChild(g_state.currentMenu);
1504         g_state.currentMenu = null;
1505     }
1507     if (view == g_state.currentView) {
1508         return;
1509     }
1511     // Hide the current view.
1512     let firstPageLoad = false;
1513     switch (g_state.currentView) {
1514         case "about":
1515             hideAboutView();
1516             break;
1517         case "chat":
1518             hideChatView();
1519             break;
1520         case "map":
1521             hideMapView();
1522             break;
1523         case "camera":
1524             hideCameraView();
1525             break;
1526         case "settings":
1527             hideSettingsView();
1528             break;
1529         case "qrscanner":
1530             hideQRScannerView();
1531             break;
1532         case "fullscreen":
1533             hideFullscreenView();
1534             break;
1535         default:
1536             // If no current view was defined, this was the first page load.
1537             firstPageLoad = true;
1538             break;
1539     }
1541     // Show the new view.
1542     switch (view) {
1543         case "about":
1544             showAboutView();
1545             break;
1546         case "chat":
1547             showChatView();
1548             break;
1549         case "map":
1550             showMapView();
1551             break;
1552         case "camera":
1553             showCameraView();
1554             break;
1555         case "settings":
1556             showSettingsView();
1557             break;
1558         case "qrscanner":
1559             showQRScannerView();
1560             break;
1561         case "fullscreen":
1562             showFullscreenView();
1563             break;
1564         default:
1565             error(`Unsupported view: ${view}`);
1566     }
1567     g_state.currentView = view;
1569     // Store the new view in the browser history.
1570     if (pushState) {
1571         if (firstPageLoad) {
1572             history.replaceState({ view: view }, "", document.location.href);
1573         } else {
1574             history.pushState({ view: view }, "", document.location.href);
1575         }
1576     }
1579 function eventHandler_chat_chat_edit_user() {
1580     const newUserName = window.prompt("Choose your name", g_state.userName);
1581     if (newUserName) {
1582         // Apply the settings.
1583         g_state.userName = newUserName;
1584         saveSettings();
1585     }
1588 function eventHandler_chat_chat_create_chat() {
1589     const newChatName = window.prompt("What do you want to call the chat?");
1590     if (newChatName) {
1591         // Create a new chat with the given name and new unique ID & keys.
1592         const newChat = new ChatSettings(
1593                 newChatName,
1594                 getEntropy(16),
1595                 getEntropy(32),
1596                 getEntropy(32),
1597                 activeChat().serverUrl,
1598                 activeChat().serverKey);
1599         // TODO: Append this a new chat instead of overwriting.
1600         overwriteActiveChat(newChat);
1602         // Apply the settings.
1603         saveSettings();
1604         stopMessagePolling();
1605         clearChat();
1606         startMessagePolling();
1607     }
1610 function eventHandler_chat_chat_join_chat() {
1611     showView("qrscanner");
1614 function eventHandler_chat_chat_share_chat() {
1615     showChatQrCodeAsFullscreen();
1618 function eventHandler_chat_chat_reset_chat() {
1619     if (window.confirm("Really reset to default chat?")) {
1620         overwriteActiveChat(ChatSettings.default());
1622         // Apply the settings.
1623         saveSettings();
1624         stopMessagePolling();
1625         clearChat();
1626         startMessagePolling();
1627     }
1630 function makeChatChatMenu() {
1631     return {
1632         position: "left",
1633         items: [
1634             { icon: "atlasicons/account.svg", caption: "Set my name", handler: eventHandler_chat_chat_edit_user },
1635             { icon: "atlasicons/qr-code.svg", caption: "Join chat", handler: eventHandler_chat_chat_join_chat },
1636             { icon: "atlasicons/user-plus.svg", caption: "Invite user", handler: eventHandler_chat_chat_share_chat },
1637             { icon: "atlasicons/add-chats.svg", caption: "Create chat", handler: eventHandler_chat_chat_create_chat },
1638             { icon: "atlasicons/trash.svg", caption: "Default chat", handler: eventHandler_chat_chat_reset_chat },
1639         ]
1640     };
1643 function makeChatMainMenu() {
1644     return {
1645         position: "right",
1646         items: [
1647             { icon: "atlasicons/map-navigation.svg", caption: "Map", handler: eventHandler_chat_main_map },
1648             { icon: "atlasicons/refresh-chat.svg", caption: "Refresh chat", handler: eventHandler_chat_main_menu_refresh },
1649             { icon: "atlasicons/gear.svg", caption: "Settings", handler: eventHandler_chat_main_settings },
1650             { icon: "atlasicons/rotate-arrow-right.svg", caption: "Reload app", handler: eventHandler_chat_main_menu_reload },
1651             { icon: "atlasicons/info-circle.svg", caption: "About", handler: eventHandler_chat_main_about },
1652         ]
1653     };
1656 function showMenu(menu, pushState=true) {
1657     if (g_state.currentMenu) {
1658         document.getElementById("body_container").removeChild(g_state.currentMenu);
1659         g_state.currentMenu = null;
1660     }
1662     let menuDefinition;
1663     if (menu === "chat_main") {
1664         menuDefinition = makeChatMainMenu();
1665     } else if (menu === "chat_chat") {
1666         menuDefinition = makeChatChatMenu();
1667     } else {
1668         log.error(`Bad menu ${menu}`);
1669         return;
1670     }
1672     // Create the menu.
1673     const overlayDiv = document.createElement("div");
1674     overlayDiv.classList.add("menu_overlay");
1675     overlayDiv.addEventListener("click", (event) => {
1676         event.preventDefault();
1677         event.stopPropagation();
1678         history.back();
1679     });
1681     const menuDiv = document.createElement("div");
1682     menuDiv.classList.add(`menu_${menuDefinition.position}`);
1683     overlayDiv.appendChild(menuDiv);
1685     const menuTitle = document.createElement("div");
1686     menuTitle.classList.add("menu_title");
1687     menuTitle.innerText = "✕";
1688     menuTitle.addEventListener("click", (event) => {
1689         event.preventDefault();
1690         event.stopPropagation();
1691         history.back();
1692     });
1693     menuDiv.appendChild(menuTitle);
1695     for (const item of menuDefinition.items) {
1696         const menuItem = document.createElement("div");
1697         menuItem.classList.add("menu_item");
1698         const menuItemIcon = document.createElement("img");
1699         menuItemIcon.src = `client/icons/${item.icon}`;
1700         menuItem.appendChild(menuItemIcon);
1701         const menuItemText = document.createElement("span");
1702         menuItemText.innerText = item.caption;
1703         menuItem.appendChild(menuItemText);
1704         menuItem.addEventListener("click", (event) => {
1705             event.preventDefault();
1706             event.stopPropagation();
1708             // TODO: This seems fragile. Do we need to guaratee that the history.back operation
1709             // has completed before running the handler (which may navigate to a new view, for
1710             // instance)?
1711             history.back();
1712             setTimeout(item.handler, 200);
1713         });
1714         menuDiv.appendChild(menuItem);
1715     }
1717     document.getElementById("body_container").appendChild(overlayDiv);
1718     g_state.currentMenu = overlayDiv;
1720     // Store the menu in the browser history.
1721     if (pushState) {
1722         history.pushState({ menu: menu }, "", document.location.href);
1723     }
1726 function openIndexedDb() {
1727     if (g_state.db) {
1728         return true;
1729     }
1730     if (!("indexedDB" in window)) {
1731         error("IndexedDB is not supported.");
1732         return false;
1733     }
1735     const request = window.indexedDB.open("chachachat", 1);
1737     request.addEventListener("upgradeneeded", (event) => {
1738         const db = event.target.result;
1739         const settingsStore = db.createObjectStore("settings", { keyPath: "id" });
1740         logSettings("Settings database upgraded");
1741     });
1743     request.addEventListener("success", (event) => {
1744         logSettings("Settings database created");
1745         g_state.db = request.result;
1746         g_state.db.onerror = (event) => {
1747             error(`Database error: ${event.target.errorCode}`);
1748         };
1749         init();
1750     });
1752     request.addEventListener("error", (event) => {
1753         // We init anyway, without the database.
1754         error(`Unable to open IndexedDB: ${request.errorCode}`);
1755         init();
1756     });
1758     return true;
1761 function setRandomUserSettings() {
1762     // Generate a random name and ID.
1763     g_state.userName = getRandomUserName();
1764     g_state.userId = getEntropy(16);
1767 function setDefaultChatSettings() {
1768     overwriteActiveChat(ChatSettings.default());
1771 function loadSettings(handler) {
1772     logSettings("Loading settings from the settings DB...");
1774     // Start a transaction against the settings DB.
1775     const transaction = g_state.db.transaction("settings", "readonly");
1776     transaction.addEventListener("complete", () => {
1777         logSettings("Load settings - completed");
1778     });
1779     transaction.addEventListener("error", () => {
1780         error("Load settings: Transaction error");
1781         if (handler) {
1782             handler(false);
1783         }
1784     });
1786     const objectStore = transaction.objectStore("settings");
1788     let userSettingsLoaded = false;
1789     let chatSettingsLoaded = false;
1790     const cursor = objectStore.openCursor()
1791     cursor.addEventListener("success", (event) => {
1792         const item = event.target.result;
1793         if (item) {
1794             if (item.value.id === "user") {
1795                 // Load user settings.
1796                 g_state.userName = item.value.userName;
1797                 g_state.userId = item.value.userId;
1798                 userSettingsLoaded = true;
1799                 logSettings("User settings loaded");
1800             } else if (item.value.id === "chat") {
1801                 // Load chat settings.
1802                 overwriteActiveChat(new ChatSettings(
1803                         item.value.chatName,
1804                         item.value.chatId,
1805                         item.value.authKey,
1806                         item.value.messageKey,
1807                         item.value.serverUrl,
1808                         item.value.serverKey));
1809                 chatSettingsLoaded = true;
1810                 logSettings("Chat settings loaded");
1811             }
1812             item.continue();
1813         } else {
1814             logSettings("Loaded all settings");
1815             if (handler) {
1816                 const firstRun = !(userSettingsLoaded && chatSettingsLoaded);
1817                 handler(firstRun);
1818             }
1819         }
1820     });
1821     cursor.addEventListener("error", (event) => {
1822         error("Failed to load settings");
1823         if (handler) {
1824             handler(true);
1825         }
1826     });
1829 function saveSettings() {
1830     logSettings("Saving settings to the settings DB...");
1832     // Start a transaction against the settings DB.
1833     const transaction = g_state.db.transaction("settings", "readwrite");
1834     transaction.addEventListener("complete", () => {
1835         logSettings("Settings saved - completed");
1836     });
1837     transaction.addEventListener("error", () => {
1838         error("Save settings: Transaction error");
1839     });
1841     const objectStore = transaction.objectStore("settings");
1843     // Save user settings.
1844     const userSettings = {
1845         id: "user",
1846         userName: g_state.userName,
1847         userId: g_state.userId,
1848     };
1849     const userSettingsQuery = objectStore.put(userSettings);
1850     userSettingsQuery.addEventListener("success", () => {
1851         logSettings("User settings saved - successful");
1852     });
1854     // Save chat settings.
1855     const chatSettings = {
1856         id: "chat",
1857         chatName: activeChat().chatName,
1858         chatId: activeChat().chatId,
1859         authKey: activeChat().authKey,
1860         messageKey: activeChat().messageKey,
1861         serverUrl: activeChat().serverUrl,
1862         serverKey: activeChat().serverKey,
1863     };
1864     const chatSettingsQuery = objectStore.put(chatSettings);
1865     chatSettingsQuery.addEventListener("success", () => {
1866         logSettings("Chat settings saved - successful");
1867     });
1870 function globalErrorHandler(event) {
1871     error(`${event.type}: ${event.message}`);
1874 function init() {
1875     // Install a global error handler.
1876     window.addEventListener("error", globalErrorHandler);
1878     // Start with default settings in case there are no previously saved settings.
1879     setRandomUserSettings();
1880     setDefaultChatSettings();
1882     // Load settings.
1883     loadSettings((firstRun) => {
1884         // Default UI state.
1885         document.getElementById("map_cbox_follow").checked = false;
1887         // Add event handlers.
1888         window.onfocus = (event) => { g_state.appActive = true; };
1889         window.onblur = (event) => { g_state.appActive = false; };
1891         document.getElementById("fullscreen_view").addEventListener("click", eventHandler_fullscreen_view_click, false);
1893         document.getElementById("chat_container").addEventListener("dragover", (event) => { event.preventDefault(); }, false);
1894         document.getElementById("chat_container").addEventListener("drop", eventHandler_main_drop, false);
1895         document.getElementById("chat_btn_chat_menu").addEventListener("click", eventHandler_chat_btn_chat_menu, false);
1896         document.getElementById("chat_btn_main_menu").addEventListener("click", eventHandler_chat_btn_main_menu, false);
1897         document.getElementById("out").addEventListener("submit", eventHandler_out, false);
1898         document.getElementById("btn_camera").addEventListener("click", eventHandler_btn_camera, false);
1899         document.getElementById("camera_btn_click").addEventListener("click", eventHandler_camera_btn_click, false);
1900         document.getElementById("camera_btn_accept").addEventListener("click", eventHandler_camera_btn_accept, false);
1901         document.getElementById("camera_btn_discard").addEventListener("click", eventHandler_camera_btn_discard, false);
1902         document.getElementById("about_btn_close").addEventListener("click", eventHandler_about_btn_close, false);
1903         document.getElementById("map_cbox_follow").addEventListener("change", eventHandler_map_cbox_follow, false);
1904         document.getElementById("map_btn_close").addEventListener("click", eventHandler_map_btn_close, false);
1905         document.getElementById("settings_new_user").addEventListener("click", eventHandler_settings_new_user, false);
1906         document.getElementById("settings_btn_apply").addEventListener("click", eventHandler_settings_btn_apply, false);
1907         document.getElementById("settings_btn_cancel").addEventListener("click", eventHandler_settings_btn_cacnel, false);
1909         // Configure handling of Enter for the message textarea.
1910         document.getElementById("out_message").addEventListener("keypress", function (event) {
1911             if(event.key === "Enter" && !event.shiftKey) {
1912                 eventHandler_out(event);
1913             }
1914         }, false);
1916         // Install the history navigation handler.
1917         window.addEventListener("popstate", (event) => {
1918             if (event.state && event.state.view) {
1919                 showView(event.state.view, false);
1920             } else if (event.state && event.state.menu) {
1921                 showMenu(event.state.menu, false);
1922             } else {
1923                 warn("Unhandled history popstate");
1924             }
1925         });
1927         // The root view is the chat (this may change in the future).
1928         clearChat();
1929         showView("chat");
1931         // Was this the first run?
1932         if (firstRun) {
1933             // Allow the user to select a name (and save it).
1934             setTimeout(eventHandler_chat_chat_edit_user, 200);
1935         }
1936     });
1939 if (!openIndexedDb()) {
1940     init();