1 // -*- mode: javascript; tab-width: 4; indent-tabs-mode: nil; -*-
2 //------------------------------------------------------------------------------
3 // This is free and unencumbered software released into the public domain.
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
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 //------------------------------------------------------------------------------
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
79 return new ChatSettings(
81 conv.b64ToBin("zkO7ICAR4lWhrUV0sZTNiA"),
82 conv.b64ToBin("WLPO0bGrm8hSDxjm7e_pS1_beHF9XDcFN4R7dcmJzN4"),
83 conv.b64ToBin("oGfg6ezeESTA0v8PO8ld9td7zJHhIpg93Epy15jvyEI"),
84 `${document.location.href}/server`,
85 conv.b64ToBin("GVFFyPAEh9ng1Cep30aDrJwgpOTTGjvsx9pejDDyiyE"),
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),
96 conv.b64ToBin(settings.serverKey));
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);
111 //------------------------------------------------------------------------------
112 // Global application state.
113 //------------------------------------------------------------------------------
135 "updateListIntervalId": null,
136 "pendingListRequests": [],
137 "pendingReadRequests": [],
140 "mapYouMarker": null,
142 "navigatorWatchId": null,
143 "navigatorIntervalId": null,
145 "videoElement": null,
146 "cameraClick": false,
154 function activeChat() {
155 return g_state.chats[g_state.activeChatNo];
158 function overwriteActiveChat(chatSettings) {
159 g_state.chats[g_state.activeChatNo] = chatSettings;
163 //------------------------------------------------------------------------------
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) {
186 function error(message) {
187 console.error(message);
188 if (g_state.showErrors) {
194 //------------------------------------------------------------------------------
196 //------------------------------------------------------------------------------
198 function getEntropy(numBytes) {
199 const result = new Uint8Array(numBytes);
200 self.crypto.getRandomValues(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);
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);
244 function isPortraitMode() {
245 if (screen.orientation) {
246 return screen.orientation.type.includes("portrait");
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";
266 function sendRequest(command, bodyParts, requestHandler, meta={}, requestList=null) {
267 // Construct the body (make room for the message MAC).
269 let bodySize = MAC_SIZE;
270 for (const part of bodyParts) {
271 bodySize += part.length;
273 let body = new Uint8Array(bodySize);
275 for (const part of bodyParts) {
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);
287 const req = new XMLHttpRequest();
288 req.addEventListener("load", requestHandler);
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);
296 requestList.splice(idx, 1);
297 logMessagePolling("Removing request from list");
299 error("An unknonw request just finished??");
303 req.open("POST", urlForCommand(command));
304 req.responseType = "arraybuffer";
305 req.chaChaChatMeta = meta;
309 function responseTextOfReq(req) {
310 if (req.responseType == "arraybuffer") {
311 const textDecoder = new TextDecoder();
312 return textDecoder.decode(new Uint8Array(req.response));
314 return req.responseText;
318 //------------------------------------------------------------------------------
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});
330 g_state.mapYouMarker.setLatLng(crd);
331 const follow = document.getElementById("map_cbox_follow").checked;
333 g_state.map.panTo(crd);
339 function enableGeolocation(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];
346 updateGeolocationEvents();
347 if (g_state.navigatorIntervalId == null) {
348 g_state.navigatorIntervalId = setInterval(updateGeolocationEvents, 10000);
352 function navError(err) {
353 error(`ERROR(${err.code}): ${err.message}`);
356 enableHighAccuracy: true,
360 navigator.geolocation.watchPosition(navSuccess, navError, navOptions);
363 if (g_state.navigatorIntervalId !== null) {
364 clearInterval(g_state.navigatorIntervalId);
365 g_state.navigatorIntervalId = null;
367 if (g_state.navigatorWatchId !== null) {
368 navigator.geolocation.clearWatch(g_state.navigatorWatchId);
369 g_state.navigatorWatchId = null;
371 if (g_state.mapYouMarker !== null) {
372 g_state.mapYouMarker.remove();
373 g_state.mapYouMarker = null;
375 g_state.mapYouCoord = null;
379 function eventHandler_map_cbox_follow(event) {
380 event.preventDefault();
381 event.stopPropagation();
383 const follow = document.getElementById("map_cbox_follow").checked;
385 updateGeolocationEvents();
389 function eventHandler_map_btn_close(event) {
390 event.preventDefault();
391 event.stopPropagation();
395 function eventHandler_chat_main_map() {
399 function showMapView() {
400 document.getElementById("map_view").style.display = "block";
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', {
407 }).addTo(g_state.map);
410 enableGeolocation(true);
413 function hideMapView() {
414 enableGeolocation(false);
416 document.getElementById("map_view").style.display = "none";
420 //------------------------------------------------------------------------------
422 //------------------------------------------------------------------------------
424 function downscalePhoto(sourceCanvas, targetWidth) {
426 width: sourceCanvas.width,
427 height: sourceCanvas.height
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");
437 width: Math.floor(sourceCanvas.width * 0.5),
438 height: Math.floor(sourceCanvas.height * 0.5)
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) {
446 width: Math.floor(res.width * 0.5),
447 height: Math.floor(res.height * 0.5)
449 tmpCtx.drawImage(tmpCanvas, 0, 0, res.width * 2, res.height * 2, 0, 0, res.width, res.height);
452 sourceCanvas = tmpCanvas;
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}`);
467 function encodePhotoAsDataUrl(canvasElement) {
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;
478 if (!g_state.preferWebp) {
479 // If we can't use WebP, use JPEG instead.
480 dataUrl = canvasElement.toDataURL("image/jpeg", 0.50);
485 function getMimeTypeFromDataUrl(dataUrl) {
486 const endPos = dataUrl.search(/(;|,)/g);
487 if ((!dataUrl.startsWith("data:")) || (endPos < 0)) {
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);
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);
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");
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
532 aspectRatio: { ideal: preferredAspectRatio },
533 facingMode: "environment",
536 if (isPortraitMode()) {
537 constraints.video.height = { ideal: preferredWidth };
539 constraints.video.width = { ideal: preferredWidth };
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);
548 error("Could not get video stream");
553 function stopCameraCapture() {
554 if (g_state.videoElement) {
555 if (g_state.videoElement.srcObject) {
556 g_state.videoElement.srcObject.getTracks().forEach(track => track.stop());
558 g_state.videoElement.remove();
559 g_state.videoElement = null;
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();
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");
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).
602 ctx.fillStyle = "rgb(50, 50, 60)";
603 ctx.fillRect(0, 0, canvasElement.width, canvasElement.height);
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") {
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}`);
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");
628 if (g_state.cameraClick) {
631 // Show the yay/nay option buttons.
632 document.getElementById("camera_controls").style.display = "none";
633 document.getElementById("camera_accept").style.display = "block";
635 requestAnimationFrame(handleNextVideoFrame);
639 // Start capturing camera frames.
640 startCameraCapture(1080, handleNextVideoFrame, (err) => {
641 // TODO: Print some error message to the canvas.
645 function showCameraView() {
646 document.getElementById("camera_view").style.display = "block";
647 resumeCameraView(true);
650 function hideCameraView() {
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) {
666 // Analyze the text contents.
667 let actualLength = 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.
679 // TODO: Improve these heuristics.
680 if ((c >= 0x2300 && c <= 0x27ff) || (c >= 0x1f000)) {
690 // Anything else is considered non-emoji.
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);
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}`;
719 // Fall back to using a shortened version of Date.toString().
720 result = d.toString();
721 const gmtPos = result.indexOf(" GMT");
723 result = result.substring(0, gmtPos);
730 function hsvToRgb(h, s, v) {
731 const hh = (Math.abs(h) % 360.0) / 60.0;
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)));
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];
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;
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.
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));
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) {
792 chatDiv.insertBefore(containerDiv, afterNode.nextSibling);
794 chatDiv.appendChild(containerDiv);
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);
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");
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 ?
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");
843 const imageCaption = `${message.senderName} - ${timeToStr(message.time)}`;
844 showImageAsFullscreen(fsImg, imageCaption);
847 messageDiv.appendChild(img);
849 notificationText += " sent an image";
851 error(`Unsupported message mime type: ${message.mimeType}`);
852 notificationText = "";
855 // Show a notification?
856 if ((!g_state.appActive) && (!weSentIt) && notificationText) {
857 showNotification(notificationText);
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);
873 //------------------------------------------------------------------------------
874 // Chat list update logic.
875 //------------------------------------------------------------------------------
877 function reqHandler_Read() {
879 // Extract request meta data (not part of the encrypted message).
880 if (!("chaChaChatMeta" in this)) {
881 error(`Missing message meta in request`);
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)}`);
893 // Decrypt the message body.
894 const binMessage = decrypt(new Uint8Array(this.response));
895 const message = msg.Message.deserialize(protoMessage.time, protoMessage.messageId, binMessage);
897 protoMessage.status = "error";
898 error(`Unable to decode message ${protoMessage.messageId}`);
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);
910 // Send of a new read request, if there are any pending messages to load.
911 readNextPendingMessage();
915 function readMessage(protoMessage) {
916 // Prepare request arguments.
917 const readHash = deriveReadHash(protoMessage.messageId);
921 activeChat().chatId, // Chat ID
922 protoMessage.messageId, // Message ID
923 readHash // Read hash
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);
938 function reqHandler_List() {
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)}`);
945 logMessagePolling(`List timeout: ${this.responseURL}`);
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 = {
956 messageId: conv.hexToBin(obj.messageId),
959 newProtoMessages.push(protoMessage);
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);
972 // Start loading pending messages. Eventially schedules new message polling.
973 readNextPendingMessage();
977 function updateList() {
978 if (g_state.pendingListRequests.length > 0) {
979 logMessagePolling(`There are ${g_state.pendingListRequests.length} pending list request. Not renewing.`);
982 if (g_state.pendingReadRequests.length > 0) {
983 logMessagePolling(`There are ${g_state.pendingReadRequests.length} pending read request. Not renewing.`);
987 logMessagePolling("Checking for new messages...");
989 // Get the latest timestamp.
991 if (g_state.protoMessages.byTime.length > 0) {
992 latestTime = g_state.protoMessages.byTime[g_state.protoMessages.byTime.length - 1].time;
997 activeChat().chatId, // Chat ID
998 conv.int64ToBin(latestTime) // Timestamp
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.
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(() => {
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;
1026 // Abort any pending requests.
1027 g_state.pendingListRequests.forEach(req => {
1030 g_state.pendingListRequests = [];
1031 g_state.pendingReadRequests.forEach(req => {
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);
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)}`);
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(
1083 // Prepare the secret read hash for this message.
1084 const readHash = deriveReadHash(messageId);
1086 // Send the request.
1088 activeChat().chatId, // Chat ID
1089 messageId, // Message ID
1090 readHash, // Read hash (for read requests)
1091 encryptedMessage // The actual message
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}`);
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);
1110 reader.readAsArrayBuffer(file);
1113 function eventHandler_chat_main_menu_refresh(event) {
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));
1145 function eventHandler_btn_camera(event) {
1146 event.preventDefault();
1147 event.stopPropagation();
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.");
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();
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);
1188 if (g_state.fsElement) {
1189 fullscreenTop.appendChild(g_state.fsElement);
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);
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 //------------------------------------------------------------------------------
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) {
1246 settings = ChatSettings.fromJsonString(settingsStr);
1248 alert("Invalid chat configuration QR code");
1251 if (window.confirm(`Join the chat "${settings.chatName}"?`)) {
1253 // TODO: Append this a new chat instead of overwriting.
1254 overwriteActiveChat(settings);
1256 // Apply the settings.
1261 error("Unable to apply new chat settings");
1267 function showQRScannerView() {
1268 document.getElementById("qrscanner_view").style.display = "block";
1270 if (!g_state.videoElement) {
1271 g_state.videoElement = document.createElement("video");
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) {
1280 ctx.moveTo(begin.x, begin.y);
1281 ctx.lineTo(end.x, end.y);
1283 ctx.strokeStyle = color;
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);
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") {
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",
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++;
1317 finalResult = result;
1318 identicalResultCount = 0;
1323 // Did we get a reliable result?
1324 let didApply = false;
1325 if (identicalResultCount >= 3) {
1326 didApply = applyChatSettingsFromQRCode(finalResult);
1328 identicalResultCount = 0;
1334 requestAnimationFrame(handleNextVideoFrame);
1338 // Start capturing camera frames.
1339 startCameraCapture(480, handleNextVideoFrame, (err) => {
1340 // TODO: Print some error message to the canvas.
1344 function hideQRScannerView() {
1345 if (g_state.videoElement) {
1346 if (g_state.videoElement.srcObject) {
1347 g_state.videoElement.srcObject.getTracks().forEach(track => track.stop());
1349 g_state.videoElement.remove();
1350 g_state.videoElement = null;
1353 document.getElementById("qrscanner_view").style.display = "none";
1357 //------------------------------------------------------------------------------
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({
1369 }).draw().then((dataURL) => {
1370 let img = document.createElement("img");
1372 showImageAsFullscreen(img, activeChat().chatName);
1376 function eventHandler_settings_new_user(event) {
1377 event.preventDefault();
1378 event.stopPropagation();
1379 let newUserName = window.prompt("What is your name?")
1381 document.getElementById("settings_user_name").value = newUserName;
1382 document.getElementById("settings_user_id").value = conv.binToB64(getEntropy(16));
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.
1413 function eventHandler_settings_btn_cacnel(event) {
1414 event.preventDefault();
1415 event.stopPropagation();
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() {
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();
1459 function eventHandler_chat_main_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)) {
1480 } else if (Notification.permission === "granted") {
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");
1492 function showNotification(text) {
1493 if (("Notification" in window) && (Notification.permission === "granted")) {
1494 const notification = new Notification(text);
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;
1507 if (view == g_state.currentView) {
1511 // Hide the current view.
1512 let firstPageLoad = false;
1513 switch (g_state.currentView) {
1530 hideQRScannerView();
1533 hideFullscreenView();
1536 // If no current view was defined, this was the first page load.
1537 firstPageLoad = true;
1541 // Show the new view.
1559 showQRScannerView();
1562 showFullscreenView();
1565 error(`Unsupported view: ${view}`);
1567 g_state.currentView = view;
1569 // Store the new view in the browser history.
1571 if (firstPageLoad) {
1572 history.replaceState({ view: view }, "", document.location.href);
1574 history.pushState({ view: view }, "", document.location.href);
1579 function eventHandler_chat_chat_edit_user() {
1580 const newUserName = window.prompt("Choose your name", g_state.userName);
1582 // Apply the settings.
1583 g_state.userName = newUserName;
1588 function eventHandler_chat_chat_create_chat() {
1589 const newChatName = window.prompt("What do you want to call the chat?");
1591 // Create a new chat with the given name and new unique ID & keys.
1592 const newChat = new ChatSettings(
1597 activeChat().serverUrl,
1598 activeChat().serverKey);
1599 // TODO: Append this a new chat instead of overwriting.
1600 overwriteActiveChat(newChat);
1602 // Apply the settings.
1604 stopMessagePolling();
1606 startMessagePolling();
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.
1624 stopMessagePolling();
1626 startMessagePolling();
1630 function makeChatChatMenu() {
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 },
1643 function makeChatMainMenu() {
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 },
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;
1663 if (menu === "chat_main") {
1664 menuDefinition = makeChatMainMenu();
1665 } else if (menu === "chat_chat") {
1666 menuDefinition = makeChatChatMenu();
1668 log.error(`Bad menu ${menu}`);
1673 const overlayDiv = document.createElement("div");
1674 overlayDiv.classList.add("menu_overlay");
1675 overlayDiv.addEventListener("click", (event) => {
1676 event.preventDefault();
1677 event.stopPropagation();
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();
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
1712 setTimeout(item.handler, 200);
1714 menuDiv.appendChild(menuItem);
1717 document.getElementById("body_container").appendChild(overlayDiv);
1718 g_state.currentMenu = overlayDiv;
1720 // Store the menu in the browser history.
1722 history.pushState({ menu: menu }, "", document.location.href);
1726 function openIndexedDb() {
1730 if (!("indexedDB" in window)) {
1731 error("IndexedDB is not supported.");
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");
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}`);
1752 request.addEventListener("error", (event) => {
1753 // We init anyway, without the database.
1754 error(`Unable to open IndexedDB: ${request.errorCode}`);
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");
1779 transaction.addEventListener("error", () => {
1780 error("Load settings: Transaction error");
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;
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,
1806 item.value.messageKey,
1807 item.value.serverUrl,
1808 item.value.serverKey));
1809 chatSettingsLoaded = true;
1810 logSettings("Chat settings loaded");
1814 logSettings("Loaded all settings");
1816 const firstRun = !(userSettingsLoaded && chatSettingsLoaded);
1821 cursor.addEventListener("error", (event) => {
1822 error("Failed to load settings");
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");
1837 transaction.addEventListener("error", () => {
1838 error("Save settings: Transaction error");
1841 const objectStore = transaction.objectStore("settings");
1843 // Save user settings.
1844 const userSettings = {
1846 userName: g_state.userName,
1847 userId: g_state.userId,
1849 const userSettingsQuery = objectStore.put(userSettings);
1850 userSettingsQuery.addEventListener("success", () => {
1851 logSettings("User settings saved - successful");
1854 // Save chat settings.
1855 const chatSettings = {
1857 chatName: activeChat().chatName,
1858 chatId: activeChat().chatId,
1859 authKey: activeChat().authKey,
1860 messageKey: activeChat().messageKey,
1861 serverUrl: activeChat().serverUrl,
1862 serverKey: activeChat().serverKey,
1864 const chatSettingsQuery = objectStore.put(chatSettings);
1865 chatSettingsQuery.addEventListener("success", () => {
1866 logSettings("Chat settings saved - successful");
1870 function globalErrorHandler(event) {
1871 error(`${event.type}: ${event.message}`);
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();
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);
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);
1923 warn("Unhandled history popstate");
1927 // The root view is the chat (this may change in the future).
1931 // Was this the first run?
1933 // Allow the user to select a name (and save it).
1934 setTimeout(eventHandler_chat_chat_edit_user, 200);
1939 if (!openIndexedDb()) {