Procházet zdrojové kódy

Merge pull request #839 from threema-ch/579-report-tool-p2

Report Tool / Troubleshooting
Lennart Grahl před 6 roky
rodič
revize
8cc660a269

+ 2 - 2
dist/package.sh

@@ -100,8 +100,8 @@ for target in "${targets[@]}"; do
 done
 
 echo "+ Update version number..."
-sed -i.bak -e "s/\[\[VERSION\]\]/${VERSION}/g" $DIR/index.html $DIR/troubleshoot/index.html $DIR/*.bundle.js $DIR/manifest.webmanifest $DIR/browserconfig.xml $DIR/version.txt
-rm $DIR/*.bak $DIR/troubleshoot/index.html.bak
+sed -i.bak -e "s/\[\[VERSION\]\]/${VERSION}/g" $DIR/index.html $DIR/troubleshoot/*.html $DIR/*.bundle.js $DIR/manifest.webmanifest $DIR/browserconfig.xml $DIR/version.txt
+rm $DIR/*.bak $DIR/troubleshoot/*.html.bak
 
 echo "+ Update permissions..."
 find $DIR/ -type f -exec chmod 644 {} \;

+ 1 - 0
index.html

@@ -87,6 +87,7 @@
             <ul translate-cloak>
                 <li><a ng-click="ctrl.showVersionInfo('[[VERSION]]')" ng-keypress="ctrl.showVersionInfo('[[VERSION]]', $event)" tabindex="0">Version [[VERSION]] {{ ctrl.config.VERSION_MOUNTAIN }}</a></li>
                 <li><a href="https://threema.ch/threema-web" target="_blank" rel="noopener noreferrer" tabindex="0" translate>welcome.MORE_ABOUT_WEB</a></li>
+                <li><a ng-click="ctrl.showTroubleshooting()" ng-keypress="ctrl.showTroubleshooting($event)" translate>troubleshooting.TROUBLESHOOTING</a></li>
                 <li><a href="https://github.com/threema-ch/threema-web/blob/master/TRANSLATING.md" target="_blank" rel="noopener noreferrer" tabindex="0" translate>welcome.HELP_TRANSLATE</a></li>
             </ul>
         </footer>

+ 11 - 1
public/i18n/de.json

@@ -66,7 +66,17 @@
         "PLUGIN": "Ist in Ihrem Browser ein Plugin zum Blockieren von WebRTC installiert?",
         "ADBLOCKER": "Ist in Ihrem Browser ein Ad-Blocker installiert?",
         "PLEASE_UPDATE_APP": "Bitte stellen Sie sicher, dass Sie die <a href=\"https://threema.ch/de/whats-new\" target=\"_blank\">neuste App-Version</a> von Threema nutzen!",
-        "USE_ARCHIVE_VERSION": "Alternativ können Sie zu der <a href=\"{archiveUrl}\">vorherigen Version</a> von Threema Web wechseln."
+        "USE_ARCHIVE_VERSION": "Alternativ können Sie zu der <a href=\"{archiveUrl}\">vorherigen Version</a> von Threema Web wechseln.",
+        "TROUBLESHOOTING": "Fehlerbehebung",
+        "REPORT_FAQ": "Für allgemeine Fragen und Lösungsvorschläge für häufige Probleme, <a target=\"_blank\" href=\"https://threema.ch/faq\">schauen Sie bitte zuerst in das FAQ</a>.",
+        "REPORT_LOG": "Sollte das FAQ Ihre Frage nicht beantworten oder das Problem bestehen bleiben, können Sie einen Fehlerbericht an den Threema Support senden.",
+        "REPORT_VIA_THREEMA_UNAVAILABLE": "Das Senden eines Fehlerberichts ist zur Zeit nicht möglich, da keine Verbindung zu Ihrem Mobilgerät besteht. Stellen Sie zuerst eine Verbindung mit Ihrem Mobilgerät her.",
+        "REPORT_VIA_CLIPBOARD": "Alternativ können Sie den Fehlerbericht in die Zwischenablage kopieren und <a target=\"_blank\" href=\"https://threema.ch/support\">per Webformular an den Threema Support senden</a>",
+        "DESCRIBE_PROBLEM": "Bitte beschreiben Sie das Problem.",
+        "COPY_LOG_CLIPBOARD": "In die Zwischenablage kopieren",
+        "REPORT_VIA_THREEMA": "Fehlerbericht an *SUPPORT senden",
+        "REPORT_VIA_THREEMA_FAILED": "Der Fehlerbericht konnte nicht an *SUPPORT gesendet werden.",
+        "REPORT_VIA_THREEMA_SUCCESS": "Ein Fehlerbericht wurde an *SUPPORT gesendet."
     },
     "common": {
         "YES": "Ja",

+ 11 - 1
public/i18n/en.json

@@ -66,7 +66,17 @@
         "PLUGIN": "Is a privacy plugin installed in your browser which blocks WebRTC communication?",
         "ADBLOCKER": "Do you use an ad blocker which also blocks WebRTC communication?",
         "PLEASE_UPDATE_APP": "Please make sure that you're using the <a href=\"https://threema.ch/en/whats-new\" target=\"_blank\">latest version</a> of the Threema app!",
-        "USE_ARCHIVE_VERSION": "Alternatively you can switch back to the <a href=\"{archiveUrl}\">previous version</a> of Threema Web."
+        "USE_ARCHIVE_VERSION": "Alternatively you can switch back to the <a href=\"{archiveUrl}\">previous version</a> of Threema Web.",
+        "TROUBLESHOOTING": "Troubleshooting",
+        "REPORT_FAQ": "Please <a target=\"_blank\" href=\"https://threema.ch/faq\">check out our FAQs</a> first for general questions and to resolve common problems.",
+        "REPORT_LOG": "If you can't find an answer to your question or the problem persists, you can send a log report to the Threema support team.",
+        "REPORT_VIA_THREEMA_UNAVAILABLE": "Reporting the log via Threema is currently unavailable since there is no connection to your mobile device. Connect to your mobile device first.",
+        "REPORT_VIA_CLIPBOARD": "Alternatively, you may copy the log into the clipboard and <a target=\"_blank\" href=\"https://threema.ch/support\">report it to the Threema support team via the web form</a>.",
+        "DESCRIBE_PROBLEM": "Please describe the problem here.",
+        "COPY_LOG_CLIPBOARD": "Copy log to clipboard",
+        "REPORT_VIA_THREEMA": "Send log to *SUPPORT",
+        "REPORT_VIA_THREEMA_FAILED": "Sending log report to *SUPPORT failed.",
+        "REPORT_VIA_THREEMA_SUCCESS": "A log report has been sent to *SUPPORT."
     },
     "common": {
         "YES": "Yes",

+ 2 - 2
src/app.ts

@@ -62,14 +62,14 @@ angular.module('3ema', [
 ])
 
 // Set versions
-.value('VERSION', '[[VERSION]]')
+.value('VERSION', config.VERSION)
 .value('PROTOCOL_VERSION', 2)
 
 // Configuration object
 .constant('CONFIG', config)
 
 // Set cache bust parameter
-.constant('CACHE_BUST', 'v=[[VERSION]]')
+.constant('CACHE_BUST', `v=${config.VERSION}`)
 
 // Constants to be used by controllers
 .constant('BROWSER_MIN_VERSIONS', {

+ 2 - 0
src/config.ts

@@ -5,6 +5,8 @@
  */
 // tslint:disable:max-line-length
 export default {
+    // Version
+    VERSION: '[[VERSION]]',
 
     // General
     SELF_HOSTED: false,

+ 55 - 0
src/controllers/dialog.ts

@@ -0,0 +1,55 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * A general purpose dialog controller.
+ */
+export class DialogController {
+    public readonly $mdDialog: ng.material.IDialogService;
+    public readonly activeElement: HTMLElement | null;
+
+    public static readonly $inject = ['$mdDialog'];
+    constructor($mdDialog: ng.material.IDialogService, activeElement?: HTMLElement) {
+        this.$mdDialog = $mdDialog;
+        this.activeElement = activeElement !== undefined ? activeElement : document.activeElement as HTMLElement;
+    }
+
+    /**
+     * Cancel the dialog.
+     */
+    public cancel(): void {
+        this.$mdDialog.cancel();
+        this.restoreFocus();
+    }
+
+    /**
+     * Hide the dialog.
+     */
+    protected hide(data?: any): void {
+        this.$mdDialog.hide(data);
+        this.restoreFocus();
+    }
+
+    /**
+     * Restore focus to the previously active element.
+     */
+    private restoreFocus(): void {
+        if (this.activeElement !== null) {
+            this.activeElement.focus();
+        }
+    }
+}

+ 15 - 0
src/controllers/footer.ts

@@ -16,6 +16,7 @@
  */
 
 import {isActionTrigger} from '../helpers';
+import {TroubleshootingController} from './troubleshooting';
 
 /**
  * Handle footer information.
@@ -60,6 +61,20 @@ export class FooterController {
         });
     }
 
+    public showTroubleshooting(ev?: KeyboardEvent): void {
+        if (ev !== undefined && !isActionTrigger(ev)) {
+            return;
+        }
+        this.$mdDialog.show({
+            controller: TroubleshootingController,
+            controllerAs: 'ctrl',
+            templateUrl: 'partials/dialog.troubleshooting.html',
+            parent: angular.element(document.body),
+            clickOutsideToClose: true,
+            fullscreen: true,
+        });
+    }
+
     /**
      * Return the changelog URL.
      */

+ 213 - 0
src/controllers/troubleshooting.ts

@@ -0,0 +1,213 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import {Logger} from 'ts-log';
+
+import {arrayToBuffer, copyShallow, hasFeature, sleep} from '../helpers';
+import * as clipboard from '../helpers/clipboard';
+
+import {MemoryLogger} from '../helpers/logger';
+import {BrowserService} from '../services/browser';
+import {LogService} from '../services/log';
+import {WebClientService} from '../services/webclient';
+import {DialogController} from './dialog';
+
+export class TroubleshootingController extends DialogController {
+    public static readonly $inject = [
+        '$scope', '$mdDialog', '$mdToast', '$translate',
+        'CONFIG', 'LogService', 'BrowserService', 'WebClientService',
+    ];
+
+    private readonly $scope: ng.IScope;
+    private readonly $mdToast: ng.material.IToastService;
+    private readonly $translate: ng.translate.ITranslateService;
+    private readonly config: threema.Config;
+    private readonly logService: LogService;
+    private readonly browserService: BrowserService;
+    private readonly webClientService: WebClientService;
+    private readonly log: Logger;
+    public isSending: boolean = false;
+    public sendingFailed: boolean = false;
+    public description: string = '';
+
+    constructor(
+        $scope: ng.IScope,
+        $mdDialog: ng.material.IDialogService,
+        $mdToast: ng.material.IToastService,
+        $translate: ng.translate.ITranslateService,
+        config: threema.Config,
+        logService: LogService,
+        browserService: BrowserService,
+        webClientService: WebClientService,
+    ) {
+        super($mdDialog);
+        this.$scope = $scope;
+        this.$mdToast = $mdToast;
+        this.$translate = $translate;
+        this.config = config;
+        this.logService = logService;
+        this.browserService = browserService;
+        this.webClientService = webClientService;
+        this.log = logService.getLogger('Troubleshooting-C');
+    }
+
+    /**
+     * Return whether the web client is currently connected (or able to
+     * reconnect on its own).
+     */
+    public get isConnected(): boolean {
+        return this.webClientService.readyToSubmit;
+    }
+
+    /**
+     * Return whether the log is ready to be sent.
+     *
+     * This requires...
+     *
+     * - the web client to be connected (or able to reconnect on its own),
+     * - a description of the problem to be populated, and
+     * - sending to be not in progress already.
+     */
+    public get canSend(): boolean {
+        return this.isConnected && this.description.length > 0 && !this.isSending;
+    }
+
+    /**
+     * Send the log to *SUPPORT.
+     */
+    public async send(): Promise<void> {
+        this.isSending = true;
+        this.sendingFailed = false;
+
+        // Get the log
+        const log = new TextEncoder().encode(this.getLog());
+
+        // Error handler
+        const fail = () => {
+            this.$scope.$apply(() => {
+                this.isSending = false;
+                this.sendingFailed = true;
+
+                // Show toast
+                this.$mdToast.show(this.$mdToast.simple()
+                    .textContent(this.$translate.instant('troubleshooting.REPORT_VIA_THREEMA_FAILED'))
+                    .position('bottom center'));
+            });
+        };
+
+        // Add contact *SUPPORT (if needed)
+        const support: threema.BaseReceiver = {
+            id: '*SUPPORT',
+            type: 'contact',
+        };
+        if (!this.webClientService.contacts.has(support.id)) {
+            try {
+                await this.webClientService.addContact(support.id);
+            } catch (error) {
+                this.log.error('Unable to add contact *SUPPORT:', error);
+                return fail();
+            }
+        }
+
+        // Workaround for iOS which does not fetch the feature mask immediately
+        // TODO: Remove once IOS-809 has been resolved
+        for (let i = 0; i < 50; ++i) {
+            const contact = this.webClientService.contacts.get(support.id);
+            if (hasFeature(contact, threema.ContactReceiverFeature.FILE, this.log)) {
+                break;
+            }
+            await sleep(100);
+        }
+
+        // Send as file to *SUPPORT
+        const browser = this.browserService.getBrowser();
+        const message: threema.FileMessageData = {
+            name: `webclient-${this.config.VERSION}-${browser.description('-')}.log`,
+            fileType: 'text/plain',
+            size: log.byteLength,
+            data: arrayToBuffer(log),
+            caption: this.description,
+            sendAsFile: true,
+        };
+        try {
+            await this.webClientService.sendMessage(support, 'file', message, { waitUntilAcknowledged: true });
+        } catch (error) {
+            this.log.error('Unable to send log report to *SUPPORT:', error);
+            return fail();
+        }
+
+        // Done
+        this.isSending = false;
+        this.$mdToast.show(this.$mdToast.simple()
+            .textContent(this.$translate.instant('troubleshooting.REPORT_VIA_THREEMA_SUCCESS'))
+            .position('bottom center'));
+
+        // Hide dialog
+        this.hide();
+    }
+
+    /**
+     * Copy the log into the clipboard.
+     */
+    public copyToClipboard(): void {
+        // Get the log
+        const log = this.getLog();
+
+        // Copy to clipboard
+        let toastString = 'messenger.COPIED';
+        try {
+            clipboard.copyString(log, this.browserService.getBrowser().isSafari());
+        } catch (error) {
+            this.log.warn('Could not copy text to clipboard:', error);
+            toastString = 'messenger.COPY_ERROR';
+        }
+
+        // Show toast
+        this.$mdToast.show(this.$mdToast.simple()
+            .textContent(this.$translate.instant(toastString))
+            .position('bottom center'));
+    }
+
+    /**
+     * Serialise the memory log and add some metadata.
+     */
+    private getLog(): string {
+        const browser = this.browserService.getBrowser();
+
+        // Sanitise usernames and credentials from ICE servers in config
+        const config = copyShallow(this.config) as threema.Config;
+        config.ICE_SERVERS = config.ICE_SERVERS.map((server: RTCIceServer) => {
+            server = copyShallow(server) as RTCIceServer;
+            for (const key of ['username', 'credential', 'credentialType']) {
+                if (server[key] !== undefined) {
+                    server[key] = `[${server[key].constructor.name}]`;
+                }
+            }
+            return server;
+        });
+
+        // Create container for meta data and log records
+        const container = {
+            config: config,
+            browser: browser.description(),
+            log: this.logService.memory.getRecords(),
+        };
+
+        // Return serialised and sanitised
+        return JSON.stringify(container, MemoryLogger.replacer, 2);
+    }
+}

+ 8 - 32
src/directives/message.ts

@@ -19,6 +19,8 @@
 
 import {saveAs} from 'file-saver';
 
+import * as clipboard from '../helpers/clipboard';
+
 import {BrowserInfo} from '../helpers/browser_info';
 import {getSenderIdentity} from '../helpers/messages';
 import {BrowserService} from '../services/browser';
@@ -120,40 +122,14 @@ export default [
                             return;
                         }
 
-                        // In order to copy the text to the clipboard,
-                        // put it into a temporary textarea element.
-                        const textArea = document.createElement('textarea');
-                        textArea.value = text;
-                        document.body.appendChild(textArea);
-
-                        if ((this.browserInfo as BrowserInfo).isSafari()) {
-                            // Safari: Create a selection range.
-                            // Inspiration: https://stackoverflow.com/a/34046084/284318
-                            textArea.contentEditable = 'true';
-                            textArea.readOnly = false;
-                            const range = document.createRange();
-                            const selection = self.getSelection();
-                            selection.removeAllRanges();
-                            selection.addRange(range);
-                            textArea.setSelectionRange(0, 999999);
-                        } else {
-                            textArea.focus();
-                            textArea.select();
-                        }
-
-                        // Copy selection to clipboard
-                        let toastString = 'messenger.COPY_ERROR';
+                        // Copy to clipboard
+                        let toastString = 'messenger.COPIED';
                         try {
-                            const successful = document.execCommand('copy');
-                            if (!successful) {
-                                log.warn('Could not copy text to clipboard');
-                            } else {
-                                toastString = 'messenger.COPIED';
-                            }
-                        } catch (err) {
-                            log.warn('Could not copy text to clipboard:', err);
+                            clipboard.copyString(text, (this.browserInfo as BrowserInfo).isSafari());
+                        } catch (error) {
+                            log.warn('Could not copy text to clipboard:', error);
+                            toastString = 'messenger.COPY_ERROR';
                         }
-                        document.body.removeChild(textArea);
 
                         // Show toast
                         const toast = $mdToast.simple()

+ 14 - 0
src/helpers.ts

@@ -326,6 +326,20 @@ export function bufferToUrl(buffer: ArrayBuffer, mimeType: string, log: Logger):
     return 'data:' + mimeType + ';base64,' + u8aToBase64(new Uint8Array(buffer));
 }
 
+/**
+ * Convert a TypedArray to an ArrayBuffer.
+ *
+ * **Important:** If the source array's data occupies the underlying buffer
+ *   completely, the underlying buffer will be returned directly. Thus, the
+ *   caller may not assume that the data has been copied.
+ */
+export function arrayToBuffer(array: ArrayBufferView): ArrayBuffer {
+    if (array.byteOffset === 0 && array.byteLength === array.buffer.byteLength) {
+        return array.buffer;
+    }
+    return array.buffer.slice(array.byteOffset, array.byteOffset + array.byteLength);
+}
+
 /**
  * Return whether a value is not null and not undefined.
  */

+ 2 - 2
src/helpers/browser_info.ts

@@ -38,7 +38,7 @@ export class BrowserInfo {
         return this.name !== null && this.version !== null;
     }
 
-    public description(): string {
+    public description(separator: string = ' '): string {
         if (this.name === null) {
             return 'Unknown';
         }
@@ -72,7 +72,7 @@ export class BrowserInfo {
         if (this.mobile) {
             description += ' [Mobile]';
         }
-        return description;
+        return description.split(' ').join(separator);
     }
 
     /**

+ 65 - 0
src/helpers/clipboard.ts

@@ -0,0 +1,65 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * Copy a string into the clipboard.
+ *
+ * @param text The string to be copied.
+ * @param useSelectionRange whether a selection range should be used. Required
+ *   for Safari.
+ *
+ * Throws an error in case it was unsuccessful.
+ */
+export function copyString(text: string, useSelectionRange: boolean = false): void {
+    const activeElement = document.activeElement as HTMLElement | null;
+
+    // Create temporary (and hidden) textarea element
+    const textArea = document.createElement('textarea');
+    textArea.value = text;
+    textArea.style.visibility = 'hidden';
+    document.body.appendChild(textArea);
+    try {
+        // Copy the text into the textarea element and select the text
+        if (useSelectionRange) {
+            // Safari: Create a selection range.
+            // Inspiration: https://stackoverflow.com/a/34046084/284318
+            textArea.contentEditable = 'true';
+            textArea.readOnly = false;
+            const range = document.createRange();
+            const selection = self.getSelection();
+            selection.removeAllRanges();
+            selection.addRange(range);
+            textArea.setSelectionRange(0, 999999);
+        } else {
+            textArea.focus();
+            textArea.select();
+        }
+
+        // Copy selection to clipboard
+        if (!document.execCommand('copy')) {
+            throw new Error('Unable to copy into clipboard');
+        }
+    } finally {
+        // Remove temporary textarea element
+        document.body.removeChild(textArea);
+
+        // Restore focus to the previously active element (if any)
+        if (activeElement !== null) {
+            activeElement.focus();
+        }
+    }
+}

+ 53 - 66
src/helpers/logger.ts

@@ -209,13 +209,30 @@ export class MemoryLogger implements Logger {
         }
 
         // Add newest record
-        this.records.push([new Date(), type, message, ...args]);
+        this.records.push([Date.now(), type, message, ...args]);
     }
 
     /**
-     * Serialise all log records to JSON.
+     * Get a copy of all currently logged records. Strips any style formatting
+     * of the log tags.
      *
-     * While serialising, a recursive filter will be applied:
+     * Important: Objects implementing the `Confidential` interface will be
+     *            returned as is.
+     */
+    public getRecords(): LogRecord[] {
+        return this.records.map(([date, type, message, ...args]: LogRecord) => {
+            // Trim first message (tag)
+            if (message !== null && message !== undefined && message.constructor === String) {
+                message = message.trim();
+            }
+            return [date, type, message, ...args];
+        });
+    }
+
+    /**
+     * Replacer function for serialising log records to JSON.
+     *
+     * A recursive filter will be applied:
      *
      * - the types `null`, `string`, `number` and `boolean` will be returned
      *   unmodified,
@@ -226,77 +243,47 @@ export class MemoryLogger implements Logger {
      *   information about the content, and
      * - everything else will return the value's type instead of the value
      *   itself.
-     *
-     * @param space Amount of white spaces used for nested block indentation.
      */
-    public serialize(space: number = 2): string {
-        const records = this.records.map(([date, type, message, ...args]: LogRecord) => {
-            // Strip message formatting
-            if (message !== null && message !== undefined && message.constructor === String) {
-                let stripped = false;
-
-                // Strip first style formatting placeholder if any
-                message = message.replace(/%c/, () => {
-                    stripped = true;
-                    return '';
-                });
-
-                // Trim
-                message = message.trim();
-
-                // Remove next argument if stripped
-                if (stripped) {
-                    args.shift();
-                }
-            }
+    public static replacer(key: string, value: any): any {
+        // Handle `null` and `undefined` early
+        if (value === null || value === undefined) {
+            return value;
+        }
 
-            // Convert date to a timestamp with millisecond accuracy
-            const timestampMs = date.getTime();
-            return [timestampMs, type, message, ...args];
-        });
+        // Apply filter to confidential data
+        if (value instanceof BaseConfidential) {
+            return value.censored();
+        }
 
-        // Serialise to JSON
-        return JSON.stringify(records, (_, value) => {
-            // Handle `null` and `undefined` early
-            if (value === null || value === undefined) {
+        // Allowed (standard) types
+        for (const allowedType of ALLOWED_TYPES) {
+            if (value.constructor === allowedType) {
                 return value;
             }
+        }
 
-            // Apply filter to confidential data
-            if (value instanceof BaseConfidential) {
-                return value.censored();
-            }
-
-            // Allowed (standard) types
-            for (const allowedType of ALLOWED_TYPES) {
-                if (value.constructor === allowedType) {
-                    return value;
-                }
-            }
-
-            // Allow exceptions
-            if (value instanceof Error) {
-                return value.toString();
-            }
+        // Allow exceptions
+        if (value instanceof Error) {
+            return value.toString();
+        }
 
-            // Filter binary data
-            if (value instanceof ArrayBuffer) {
-                return `[ArrayBuffer: length=${value.byteLength}]`;
-            }
-            if (value instanceof Uint8Array) {
-                return `[Uint8Array: length=${value.byteLength}, offset=${value.byteOffset}]`;
-            }
-            if (value instanceof Blob) {
-                return `[Blob: length=${value.size}, type=${value.type}]`;
-            }
+        // Filter binary data
+        if (value instanceof ArrayBuffer) {
+            return `[ArrayBuffer: length=${value.byteLength}]`;
+        }
+        if (value instanceof Uint8Array) {
+            return `[Uint8Array: length=${value.byteLength}, offset=${value.byteOffset}]`;
+        }
+        if (value instanceof Blob) {
+            return `[Blob: length=${value.size}, type=${value.type}]`;
+        }
 
-            // Plain object
-            if (value.constructor === Object) {
-                return value;
-            }
+        // Plain object
+        if (value.constructor === Object) {
+            return value;
+        }
 
-            // Not listed
-            return `[${value.constructor.name}]`;
-        }, space);
+        // Not listed
+        return `[${value.constructor.name}]`;
     }
 }

+ 43 - 0
src/partials/dialog.troubleshooting.html

@@ -0,0 +1,43 @@
+<md-dialog aria-label="Troubleshooting">
+    <form ng-cloak>
+        <md-toolbar>
+            <div class="md-toolbar-tools">
+                <h2 translate>troubleshooting.TROUBLESHOOTING</h2>
+                <span flex></span>
+                <md-button class="md-icon-button" ng-click="ctrl.cancel()">
+                    <md-icon aria-label="Close dialog" class="material-icons md-24">close</md-icon>
+                </md-button>
+            </div>
+        </md-toolbar>
+
+        <md-dialog-content>
+            <div class="md-dialog-content">
+                <p translate>troubleshooting.REPORT_FAQ</p>
+
+                <p translate>troubleshooting.REPORT_LOG</p>
+
+                <p ng-if="!ctrl.isConnected" translate>troubleshooting.REPORT_VIA_THREEMA_UNAVAILABLE</p>
+                <p ng-if="!ctrl.isConnected || ctrl.sendingFailed" translate>troubleshooting.REPORT_VIA_CLIPBOARD</p>
+
+                <md-input-container ng-if="ctrl.isConnected" class="md-block">
+                    <label translate>troubleshooting.DESCRIBE_PROBLEM</label>
+                    <textarea ng-model="ctrl.description" rows="2" max-rows="10" md-select-on-focus></textarea>
+                </md-input-container>
+            </div>
+        </md-dialog-content>
+
+        <md-dialog-actions layout="row">
+            <span flex></span>
+            <md-button role="button" class="md-primary" ng-click="ctrl.copyToClipboard()" aria-labelledby="aria-label-copy-log-clipboard">
+                <span translate id="aria-label-copy-log-clipboard">troubleshooting.COPY_LOG_CLIPBOARD</span>
+            </md-button>
+            <md-button role="button" class="md-accent circular-progress-button" ng-click="ctrl.send()" ng-disabled="!ctrl.canSend" aria-labelledby="aria-label-report-via-threema">
+                <md-progress-circular ng-if="ctrl.isSending" md-mode="indeterminate" md-diameter="20"></md-progress-circular>
+                <span translate id="aria-label-report-via-threema">troubleshooting.REPORT_VIA_THREEMA</span>
+            </md-button>
+            <md-button role="button" class="md-primary" ng-click="ctrl.cancel()" aria-labelledby="aria-label-close">
+                <span translate id="aria-label-close">common.CLOSE</span>
+            </md-button>
+        </md-dialog-actions>
+    </form>
+</md-dialog>

+ 12 - 72
src/partials/messenger.ts

@@ -25,6 +25,7 @@ import {
 import {Logger} from 'ts-log';
 
 import {ContactControllerModel} from '../controller_model/contact';
+import {DialogController} from '../controllers/dialog';
 import {bufferToUrl, hasValue, supportsPassive, throttle, u8aToHex} from '../helpers';
 import {emojify} from '../helpers/emoji';
 import {ContactService} from '../services/contact';
@@ -46,49 +47,11 @@ import {controllerModelHasMembers, isContactReceiver} from '../typeguards';
 // Type aliases
 import ControllerModelMode = threema.ControllerModelMode;
 
-class DialogController {
-    public $mdDialog: ng.material.IDialogService;
-    public activeElement: HTMLElement | null;
-    public config: threema.Config;
-
-    public static $inject = ['$mdDialog', 'CONFIG'];
-    constructor($mdDialog: ng.material.IDialogService, CONFIG: threema.Config) {
-        this.$mdDialog = $mdDialog;
-        this.activeElement = document.activeElement as HTMLElement;
-        this.config = CONFIG;
-    }
-
-    public cancel(): void {
-        this.$mdDialog.cancel();
-        this.done();
-    }
-
-    protected hide(data?: any): void {
-        this.$mdDialog.hide(data);
-        this.done();
-    }
-
-    private done(): void {
-        if (this.resumeFocusOnClose() === true && this.activeElement !== null) {
-            // reset focus
-            this.activeElement.focus();
-        }
-    }
-
-    /**
-     * If true, the focus on the active element (before opening the dialog)
-     * will be restored. Default `true`, override if desired.
-     */
-    protected resumeFocusOnClose(): boolean {
-        return true;
-    }
-}
-
 /**
  * Handle sending of files.
  */
 class SendFileController extends DialogController {
-    public static $inject = ['$mdDialog', 'CONFIG', 'LogService', 'preview'];
+    public static $inject = ['$mdDialog', 'LogService', 'preview'];
 
     public caption: string;
     public sendAsFile: boolean = false;
@@ -96,10 +59,9 @@ class SendFileController extends DialogController {
     public previewDataUrl: string | null = null;
 
     constructor($mdDialog: ng.material.IDialogService,
-                CONFIG: threema.Config,
                 logService: LogService,
                 preview: threema.FileMessageData) {
-        super($mdDialog, CONFIG);
+        super($mdDialog);
         const log = logService.getLogger('SendFile-C');
         this.preview = preview;
         if (preview !== null) {
@@ -132,7 +94,7 @@ class SendFileController extends DialogController {
 export class DeviceUnreachableController extends DialogController {
     public static readonly $inject = [
         '$rootScope', '$window', '$mdDialog',
-        'CONFIG', 'StateService', 'WebClientService',
+        'StateService', 'WebClientService',
     ];
     private readonly $rootScope: any;
     private readonly $window: ng.IWindowService;
@@ -143,9 +105,9 @@ export class DeviceUnreachableController extends DialogController {
 
     constructor(
         $rootScope: any, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
-        CONFIG: threema.Config, stateService: StateService, webClientService: WebClientService,
+        stateService: StateService, webClientService: WebClientService,
     ) {
-        super($mdDialog, CONFIG);
+        super($mdDialog);
         this.$rootScope = $rootScope;
         this.$window = $window;
         this.stateService = stateService;
@@ -204,14 +166,12 @@ export class DeviceUnreachableController extends DialogController {
 /**
  * Handle settings
  */
-class SettingsController {
+class SettingsController extends DialogController {
     public static $inject = ['$mdDialog', '$window', 'SettingsService', 'NotificationService'];
 
-    public $mdDialog: ng.material.IDialogService;
     public $window: ng.IWindowService;
     public settingsService: SettingsService;
     private notificationService: NotificationService;
-    public activeElement: HTMLElement | null;
 
     private desktopNotifications: boolean;
     private notificationApiAvailable: boolean;
@@ -223,11 +183,10 @@ class SettingsController {
                 $window: ng.IWindowService,
                 settingsService: SettingsService,
                 notificationService: NotificationService) {
-        this.$mdDialog = $mdDialog;
+        super($mdDialog);
         this.$window = $window;
         this.settingsService = settingsService;
         this.notificationService = notificationService;
-        this.activeElement = document.activeElement as HTMLElement;
         this.desktopNotifications = notificationService.getWantsNotifications();
         this.notificationApiAvailable = notificationService.isNotificationApiAvailable();
         this.notificationPermission = notificationService.getNotificationPermission();
@@ -235,23 +194,6 @@ class SettingsController {
         this.notificationSound = notificationService.getWantsSound();
     }
 
-    public cancel(): void {
-        this.$mdDialog.cancel();
-        this.done();
-    }
-
-    protected hide(data: any): void {
-        this.$mdDialog.hide(data);
-        this.done();
-    }
-
-    private done(): void {
-        if (this.activeElement !== null) {
-            // Reset focus
-            this.activeElement.focus();
-        }
-    }
-
     public setWantsNotifications(desktopNotifications: boolean) {
         this.notificationService.setWantsNotifications(desktopNotifications);
     }
@@ -604,10 +546,7 @@ class ConversationController {
     public submit = (type: threema.MessageContentType, contents: threema.MessageData[]): Promise<any> => {
         // Validate whether a connection is available
         return new Promise((resolve, reject) => {
-            if (!this.stateService.readyToSubmit(
-                this.webClientService.chosenTask,
-                this.webClientService.startupDone,
-            )) {
+            if (!this.webClientService.readyToSubmit) {
                 // Invalid connection, show toast and abort
                 this.showError(this.$translate.instant('error.NO_CONNECTION'));
                 return reject();
@@ -697,13 +636,14 @@ class ConversationController {
                         //       type.
                         const caption = data.caption;
                         const sendAsFile = data.sendAsFile;
-                        const previewDataUrl = data.previewDataUrl || undefined;
+                        const options = { previewDataUrl: data.previewDataUrl || undefined };
                         contents.forEach((msg: threema.FileMessageData, index: number) => {
                             if (caption !== undefined && caption.length > 0) {
                                 msg.caption = caption;
                             }
                             msg.sendAsFile = sendAsFile;
-                            this.webClientService.sendMessage(this.$stateParams, type, msg, previewDataUrl)
+
+                            this.webClientService.sendMessage(this.$stateParams, type, msg, options)
                                 .then(() => {
                                     nextCallback(index);
                                 })

+ 1 - 16
src/partials/welcome.ts

@@ -26,6 +26,7 @@ import {
     StateService as UiStateService,
 } from '@uirouter/angularjs';
 
+import {DialogController} from '../controllers/dialog';
 import {BrowserInfo} from '../helpers/browser_info';
 import {BrowserService} from '../services/browser';
 import {ControllerService} from '../services/controller';
@@ -41,22 +42,6 @@ import {WebClientService} from '../services/webclient';
 import GlobalConnectionState = threema.GlobalConnectionState;
 import DisconnectReason = threema.DisconnectReason;
 
-class DialogController {
-    // TODO: This is also used in partials/messenger.ts. We could somehow
-    // extract it into a separate file.
-    public static $inject = ['$mdDialog'];
-
-    public $mdDialog;
-
-    constructor($mdDialog) {
-        this.$mdDialog = $mdDialog;
-    }
-
-    public cancel() {
-        this.$mdDialog.cancel();
-    }
-}
-
 class WelcomeController {
     private static REDIRECT_DELAY = 500;
 

+ 195 - 172
src/services/webclient.ts

@@ -369,6 +369,16 @@ export class WebClientService {
         this.stateService.evtGlobalConnectionStateChange.attach(this.handleGlobalConnectionStateChange.bind(this));
     }
 
+    /**
+     * Return whether wire messages can be sent (or queued to be sent).
+     *
+     * Note: This will not return `true` before an initial connection has been
+     *       established successfully.
+     */
+    get readyToSubmit(): boolean {
+        return this.stateService.readyToSubmit(this.chosenTask, this.startupDone);
+    }
+
     get me(): threema.MeReceiver {
         return this.receivers.me;
     }
@@ -514,7 +524,7 @@ export class WebClientService {
             builder = builder.withTrustedPeerKey(flags.peerTrustedKey);
         }
         this.salty = builder.asInitiator();
-        this.arpLog.debug('Public key:', this.salty.permanentKeyHex);
+        this.arpLog.info('Public key:', this.salty.permanentKeyHex);
         this.arpLogV.debug('Auth token:', this.salty.authTokenHex);
 
         // We want to know about state changes
@@ -1683,203 +1693,216 @@ export class WebClientService {
     /**
      * Send a message to the specified receiver.
      */
-    public sendMessage(
+    public async sendMessage(
         baseReceiver: threema.BaseReceiver,
         sendType: threema.MessageContentType,
         data: threema.MessageData,
-        previewDataUrl?: string,
-    ): Promise<void> {
-        return new Promise<void> (
-            (resolve, reject) => {
-                // This is the expected message type that will be reflected
-                // back once the message has been created successfully.
-                let reflectedType: threema.MessageType;
-
-                // Try to load receiver
-                const receiver = this.receivers.getData(baseReceiver);
-
-                // Check blocked flag
-                if (isContactReceiver(receiver) && receiver.isBlocked) {
-                    return reject(this.$translate.instant('error.CONTACT_BLOCKED'));
-                }
+        options: {
+            previewDataUrl?: string,
+            waitUntilAcknowledged?: boolean,
+        } = {},
+    ): Promise<any> {
+        // This is the expected message type that will be reflected
+        // back once the message has been created successfully.
+        let reflectedType: threema.MessageType;
 
-                // Decide on subtype
-                let subType;
-                switch (sendType) {
-                    case 'text':
-                        reflectedType = 'text';
-                        subType = WebClientService.SUB_TYPE_TEXT_MESSAGE;
+        // Try to load receiver
+        const receiver = this.receivers.getData(baseReceiver);
 
-                        const textData = data as threema.TextMessageData;
-                        const msgLength = textData.text.length;
+        // Check blocked flag
+        if (isContactReceiver(receiver) && receiver.isBlocked) {
+            throw this.$translate.instant('error.CONTACT_BLOCKED');
+        }
 
-                        // Ignore empty text messages
-                        if (msgLength === 0) {
-                            this.log.warn('Ignored empty text message');
-                            return reject(this.$translate.instant('error.ERROR_OCCURRED'));
-                        }
+        // Decide on subtype
+        let subType;
+        switch (sendType) {
+            case 'text':
+                reflectedType = 'text';
+                subType = WebClientService.SUB_TYPE_TEXT_MESSAGE;
 
-                        // Ignore text messages that are too long.
-                        if (msgLength > WebClientService.MAX_TEXT_LENGTH) {
-                            return reject(this.$translate.instant('error.TEXT_TOO_LONG', {
-                                max: WebClientService.MAX_TEXT_LENGTH,
-                            }));
-                        }
+                const textData = data as threema.TextMessageData;
+                const msgLength = textData.text.length;
 
-                        break;
-                    case 'file':
-                        const fileData = data as threema.FileMessageData;
+                // Ignore empty text messages
+                if (msgLength === 0) {
+                    this.log.warn('Ignored empty text message');
+                    throw this.$translate.instant('error.ERROR_OCCURRED');
+                }
 
-                        // Validate max file size
-                        if (this.chosenTask === threema.ChosenTask.WebRTC) {
-                            if (fileData.size > WebClientService.MAX_FILE_SIZE_WEBRTC) {
-                                return reject(this.$translate.instant('error.FILE_TOO_LARGE_WEB'));
-                            }
-                        } else {
-                            if (fileData.size > this.clientInfo.capabilities.maxFileSize) {
-                                return reject(this.$translate.instant('error.FILE_TOO_LARGE', {
-                                    maxmb: Math.floor(this.clientInfo.capabilities.maxFileSize / 1024 / 1024),
-                                }));
-                            }
-                        }
+                // Ignore text messages that are too long.
+                if (msgLength > WebClientService.MAX_TEXT_LENGTH) {
+                    throw this.$translate.instant('error.TEXT_TOO_LONG', {
+                        max: WebClientService.MAX_TEXT_LENGTH,
+                    });
+                }
 
-                        // Determine reflected type and required feature mask
-                        reflectedType = 'file';
-                        let requiredFeature = ContactReceiverFeature.FILE;
-                        let invalidFeatureMessage = 'error.FILE_MESSAGES_NOT_SUPPORTED';
-                        if (fileData.sendAsFile !== true) {
-                            // File will be dispatched to the app as a file but the actual type sent
-                            // to the recipient depends on the MIME type.
-                            const mimeType = fileData.fileType;
-                            if (this.mimeService.isAudio(mimeType, this.clientInfo.os)) {
-                                reflectedType = 'audio';
-                                requiredFeature = ContactReceiverFeature.AUDIO;
-                                invalidFeatureMessage = 'error.AUDIO_MESSAGES_NOT_SUPPORTED';
-                            } else if (this.mimeService.isImage(mimeType)) {
-                                reflectedType = 'image';
-                            } else if (this.mimeService.isVideo(mimeType)) {
-                                reflectedType = 'video';
-                            }
-                        }
+                break;
+            case 'file':
+                const fileData = data as threema.FileMessageData;
 
-                        subType = WebClientService.SUB_TYPE_FILE_MESSAGE;
+                // Validate max file size
+                if (this.chosenTask === threema.ChosenTask.WebRTC) {
+                    if (fileData.size > WebClientService.MAX_FILE_SIZE_WEBRTC) {
+                        throw this.$translate.instant('error.FILE_TOO_LARGE_WEB');
+                    }
+                } else {
+                    if (fileData.size > this.clientInfo.capabilities.maxFileSize) {
+                        throw this.$translate.instant('error.FILE_TOO_LARGE', {
+                            maxmb: Math.floor(this.clientInfo.capabilities.maxFileSize / 1024 / 1024),
+                        });
+                    }
+                }
 
-                        // check receiver
+                // Determine reflected type and required feature mask
+                reflectedType = 'file';
+                let requiredFeature = ContactReceiverFeature.FILE;
+                let invalidFeatureMessage = 'error.FILE_MESSAGES_NOT_SUPPORTED';
+                if (fileData.sendAsFile !== true) {
+                    // File will be dispatched to the app as a file but the actual type sent
+                    // to the recipient depends on the MIME type.
+                    const mimeType = fileData.fileType;
+                    if (this.mimeService.isAudio(mimeType, this.clientInfo.os)) {
+                        reflectedType = 'audio';
+                        requiredFeature = ContactReceiverFeature.AUDIO;
+                        invalidFeatureMessage = 'error.AUDIO_MESSAGES_NOT_SUPPORTED';
+                    } else if (this.mimeService.isImage(mimeType)) {
+                        reflectedType = 'image';
+                    } else if (this.mimeService.isVideo(mimeType)) {
+                        reflectedType = 'video';
+                    }
+                }
+
+                subType = WebClientService.SUB_TYPE_FILE_MESSAGE;
+
+                // check receiver
+                switch (receiver.type) {
+                    case 'group':
+                    case 'distributionList':
+                        const unsupportedMembers = [];
+                        let members: string[];
                         switch (receiver.type) {
                             case 'group':
-                            case 'distributionList':
-                                const unsupportedMembers = [];
-                                let members: string[];
-                                switch (receiver.type) {
-                                    case 'group':
-                                        const group = this.groups.get(receiver.id);
-                                        if (group === undefined) {
-                                            this.log.error(`Group ${receiver.id} not found`);
-                                            return reject(this.$translate.instant('error.ERROR_OCCURRED'));
-                                        }
-                                        members = group.members;
-                                        break;
-                                    case 'distributionList':
-                                        const distributionList = this.distributionLists.get(receiver.id);
-                                        if (distributionList === undefined) {
-                                            this.log.error(`Distribution list ${receiver.id} not found`);
-                                            return reject(this.$translate.instant('error.ERROR_OCCURRED'));
-                                        }
-                                        members = distributionList.members;
-                                        break;
+                                const group = this.groups.get(receiver.id);
+                                if (group === undefined) {
+                                    this.log.error(`Group ${receiver.id} not found`);
+                                    throw this.$translate.instant('error.ERROR_OCCURRED');
                                 }
-                                for (const identity of members) {
-                                    if (identity !== this.me.id) {
-                                        // tslint:disable-next-line: no-shadowed-variable
-                                        const contact = this.contacts.get(identity);
-                                        if (contact === undefined) {
-                                            // This shouldn't actually happen. But if it happens, log an error
-                                            // and assume image support. It's much more likely that the contact
-                                            // can receive images (feature flag 0x01) than otherwise. And if one
-                                            // of the contacts really cannot receive images, the app will return
-                                            // an error message.
-                                            this.log.error(`Cannot retrieve contact ${identity}`);
-                                        } else if (!hasFeature(contact, requiredFeature, this.log)) {
-                                            unsupportedMembers.push(contact.displayName);
-                                        }
-                                    }
-                                }
-
-                                if (unsupportedMembers.length > 0) {
-                                    return reject(this.$translate.instant(
-                                        invalidFeatureMessage, {receiverName: unsupportedMembers.join(',')},
-                                    ));
+                                members = group.members;
+                                break;
+                            case 'distributionList':
+                                const distributionList = this.distributionLists.get(receiver.id);
+                                if (distributionList === undefined) {
+                                    this.log.error(`Distribution list ${receiver.id} not found`);
+                                    throw this.$translate.instant('error.ERROR_OCCURRED');
                                 }
+                                members = distributionList.members;
                                 break;
-                            case 'contact':
-                                const contact = this.contacts.get(receiver.id);
+                        }
+                        for (const identity of members) {
+                            if (identity !== this.me.id) {
+                                // tslint:disable-next-line: no-shadowed-variable
+                                const contact = this.contacts.get(identity);
                                 if (contact === undefined) {
-                                    this.log.error('Cannot retrieve contact');
-                                    return reject(this.$translate.instant('error.ERROR_OCCURRED'));
+                                    // This shouldn't actually happen. But if it happens, log an error
+                                    // and assume image support. It's much more likely that the contact
+                                    // can receive images (feature flag 0x01) than otherwise. And if one
+                                    // of the contacts really cannot receive images, the app will return
+                                    // an error message.
+                                    this.log.error(`Cannot retrieve contact ${identity}`);
                                 } else if (!hasFeature(contact, requiredFeature, this.log)) {
-                                    this.log.debug('Cannot send message: Feature level mismatch:',
-                                        contact.featureMask, 'does not include', requiredFeature);
-                                    return reject(this.$translate.instant(invalidFeatureMessage, {
-                                        receiverName: contact.displayName}));
+                                    unsupportedMembers.push(contact.displayName);
                                 }
-                                break;
-                            default:
-                                this.log.error('Invalid receiver type:', receiver.type);
-                                return reject(this.$translate.instant('error.ERROR_OCCURRED'));
+                            }
+                        }
+
+                        if (unsupportedMembers.length > 0) {
+                            throw this.$translate.instant(
+                                invalidFeatureMessage, {receiverName: unsupportedMembers.join(',')},
+                            );
+                        }
+                        break;
+                    case 'contact':
+                        const contact = this.contacts.get(receiver.id);
+                        if (contact === undefined) {
+                            this.log.error('Cannot retrieve contact');
+                            throw this.$translate.instant('error.ERROR_OCCURRED');
+                        } else if (!hasFeature(contact, requiredFeature, this.log)) {
+                            this.log.debug('Cannot send message: Feature level mismatch:',
+                                contact.featureMask, 'does not include', requiredFeature);
+                            throw this.$translate.instant(invalidFeatureMessage, {
+                                receiverName: contact.displayName});
                         }
                         break;
                     default:
-                        this.log.error('Invalid message type:', sendType);
-                        return reject(this.$translate.instant('error.ERROR_OCCURRED'));
+                        this.log.error('Invalid receiver type:', receiver.type);
+                        throw this.$translate.instant('error.ERROR_OCCURRED');
                 }
+                break;
+            default:
+                this.log.error('Invalid message type:', sendType);
+                throw this.$translate.instant('error.ERROR_OCCURRED');
+        }
 
-                const id = this.createRandomWireMessageId();
-                let temporaryMessage: threema.Message;
-                try {
-                    temporaryMessage = this.messageService.createTemporary(
-                        id, receiver, reflectedType, data, previewDataUrl);
-                } catch (error) {
-                    this.log.error(error);
-                    return reject(this.$translate.instant('error.ERROR_OCCURRED'));
-                }
-                this.messages.addNewer(receiver, [temporaryMessage]);
-
-                const args = {
-                    [WebClientService.ARGUMENT_RECEIVER_TYPE]: receiver.type,
-                    [WebClientService.ARGUMENT_RECEIVER_ID]: receiver.id,
-                };
-
-                // Send message
-                this.sendCreateWireMessage(subType, true, args, data, id)
-                    .catch((error) => {
-                        this.arpLog.error('Error sending message:', error);
-
-                        // Remove temporary message
-                        this.messages.removeTemporary(receiver, temporaryMessage.temporaryId);
-
-                        // Determine error message
-                        let errorMessage;
-                        switch (error) {
-                            case 'file_too_large': // TODO: deprecated
-                            case 'fileTooLarge':
-                                errorMessage = this.$translate.instant('error.FILE_TOO_LARGE_GENERIC');
-                                break;
-                            case 'blocked':
-                                errorMessage = this.$translate.instant('error.CONTACT_BLOCKED');
-                                break;
-                            default:
-                                errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
-                        }
+        // Request the conversation to be loaded
+        // Note: This is required since we need to retrieve updates of the
+        //       message we're going to send.
+        if (this.messages.getList(receiver).length === 0) {
+            await this.requestMessages(receiver);
+        }
 
-                        // Show alert
-                        this.alerts.push({
-                            source: 'sendMessage',
-                            type: 'alert',
-                            message: errorMessage,
-                        } as threema.Alert);
-                    });
-                resolve();
-            });
+        // Create temporary message to be displayed until acknowledged by the
+        // mobile device
+        const id = this.createRandomWireMessageId();
+        let temporaryMessage: threema.Message;
+        try {
+            temporaryMessage = this.messageService.createTemporary(
+                id, receiver, reflectedType, data, options.previewDataUrl);
+        } catch (error) {
+            this.log.error(error);
+            throw this.$translate.instant('error.ERROR_OCCURRED');
+        }
+        this.messages.addNewer(receiver, [temporaryMessage]);
+
+        const args = {
+            [WebClientService.ARGUMENT_RECEIVER_TYPE]: receiver.type,
+            [WebClientService.ARGUMENT_RECEIVER_ID]: receiver.id,
+        };
+
+        // Send message
+        const sendPromise = this.sendCreateWireMessage(subType, true, args, data, id);
+        sendPromise.catch((error) => {
+            this.arpLog.error('Error sending message:', error);
+
+            // Remove temporary message
+            this.messages.removeTemporary(receiver, temporaryMessage.temporaryId);
+
+            // Determine error message
+            let errorMessage;
+            switch (error) {
+                case 'file_too_large': // TODO: deprecated
+                case 'fileTooLarge':
+                    errorMessage = this.$translate.instant('error.FILE_TOO_LARGE_GENERIC');
+                    break;
+                case 'blocked':
+                    errorMessage = this.$translate.instant('error.CONTACT_BLOCKED');
+                    break;
+                default:
+                    errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
+            }
+
+            // Show alert
+            this.alerts.push({
+                source: 'sendMessage',
+                type: 'alert',
+                message: errorMessage,
+            } as threema.Alert);
+        });
+
+        // Wait until the wire message has been acknowledged (if requested)
+        if (options.waitUntilAcknowledged) {
+            await sendPromise;
+        }
     }
 
     /**

+ 4 - 1
src/threema.d.ts

@@ -20,7 +20,7 @@ declare const angular: ng.IAngularStatic;
 declare namespace threema {
     type LogType = 'debug' | 'trace' | 'info' | 'warn' | 'error';
     type LogLevel = 'none' | 'debug' | 'info' | 'warn' | 'error';
-    type LogRecord = [Date, LogType, any?, ...any[]];
+    type LogRecord = [number, LogType, any?, ...any[]];
 
     /**
      * An object can be marked as confidential in which case it needs to
@@ -650,6 +650,9 @@ declare namespace threema {
     }
 
     interface Config {
+        // Version
+        VERSION: string;
+
         // General
         SELF_HOSTED: boolean;
         VERSION_MOUNTAIN: string;

+ 3 - 1
tests/bootstrap.ts

@@ -17,14 +17,16 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-// tslint:disable:no-console
 import config from '../src/config';
+import {MemoryLogger} from '../src/helpers/logger';
 
 // A dependency graph that contains any wasm must all be imported asynchronously.
 import('../src/app')
     .then(() => {
         // @ts-ignore
         window.config = config;
+        // @ts-ignore
+        window.MemoryLogger = MemoryLogger;
         console.info('Bundle loaded')
     })
     .catch((e) => console.error('Could not load bundle', e));

+ 10 - 0
tests/service/browser.js

@@ -135,4 +135,14 @@ describe('BrowserService', function() {
         expect(browser.description()).toEqual('Safari 10 [Mobile]');
     });
 
+    it('with dash as separator', () => {
+        const ua = 'Mozilla/5.0 (iPad; CPU OS 10_0_1 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) FxiOS/8.3b5826 Mobile/14A403 Safari/602.1.50';
+        const service = testUserAgent(ua);
+        const browser = service.getBrowser();
+        expect(browser.name).toBe(BrowserName.FirefoxIos);
+        expect(browser.version).toEqual(8);
+        expect(browser.mobile).toBe(true);
+        expect(browser.description('-')).toEqual('Firefox-(iOS)-8-[Mobile]');
+    });
+
 });

+ 5 - 5
tests/service/log.js

@@ -94,7 +94,7 @@ describe('LogService', function() {
             // Expect the memory logger to have been called for 'debug' and above
             // (i.e. all log levels).
             expect(JSON
-                .parse($service.memory.serialize())
+                .parse(JSON.stringify($service.memory.getRecords(), MemoryLogger.replacer))
                 .map((record) => record.slice(1))
             ).toEqual([
                 ['debug', '[test]', 'debug'],
@@ -116,7 +116,7 @@ describe('LogService', function() {
 
             // Expect the memory logger tag to be unpadded
             expect(JSON
-                .parse($service.memory.serialize())
+                .parse(JSON.stringify($service.memory.getRecords(), MemoryLogger.replacer))
                 .map((record) => record.slice(1))
             ).toEqual([
                 ['info', '[test]', 'test']
@@ -135,10 +135,10 @@ describe('LogService', function() {
 
             // Expect the memory logger tag to be unpadded and unstyled
             expect(JSON
-                .parse($service.memory.serialize())
+                .parse(JSON.stringify($service.memory.getRecords(), MemoryLogger.replacer))
                 .map((record) => record.slice(1))
             ).toEqual([
-                ['info', '[test]', 'test']
+                ['info', '%c[test]', style, 'test']
             ]);
         });
 
@@ -155,7 +155,7 @@ describe('LogService', function() {
 
             // Expect the memory logger to only contain the 'info' log
             expect(JSON
-                .parse($service.memory.serialize())
+                .parse(JSON.stringify($service.memory.getRecords(), MemoryLogger.replacer))
                 .map((record) => record.slice(1))
             ).toEqual([
                 ['info', '[test]', 'info']

+ 16 - 16
tests/ts/logger_helpers.ts

@@ -298,7 +298,7 @@ describe('Logger Helpers', () => {
             }
             const end = Date.now();
             const timestamps = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry[0]);
             expect(timestamps.length).toBe(10);
             for (const timestamp of timestamps) {
@@ -323,17 +323,17 @@ describe('Logger Helpers', () => {
             ];
             logger.debug(...record.slice(1));
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
             expect(records[0]).toEqual(record);
         });
 
-        it("strips style formatting of the log record's message (tag)", () => {
+        it("trims the first log record's message (tag)", () => {
             const logger = new MemoryLogger();
 
-            // Ensure %c CSS style formatting placeholder and the following
-            // argument is being stripped.
+            // Ensure %c CSS style formatting placeholder remains but the tag
+            // is being trimmed.
             const args = [
                 null,
                 true,
@@ -344,10 +344,10 @@ describe('Logger Helpers', () => {
             ];
             logger.debug('  te%cst  ', 'color: #fff', ...args);
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
-            expect(records[0]).toEqual((['debug', 'test'] as any[]).concat(args));
+            expect(records[0]).toEqual((['debug', 'te%cst', 'color: #fff'] as any[]).concat(args));
         });
 
         it("ignores style formatting beyond the log record's message (args)", () => {
@@ -371,7 +371,7 @@ describe('Logger Helpers', () => {
             ];
             logger.debug(...record.slice(1));
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
             expect(records[0]).toEqual(record);
@@ -391,7 +391,7 @@ describe('Logger Helpers', () => {
             ];
             logger.debug(...record.slice(1));
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
             expect(records[0]).toEqual(record);
@@ -404,7 +404,7 @@ describe('Logger Helpers', () => {
             const confidential = new TestConfidential();
             logger.debug(confidential, confidential, confidential);
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
             expect(records[0]).toEqual(['debug', 'censored', 'censored', 'censored']);
@@ -417,7 +417,7 @@ describe('Logger Helpers', () => {
             const error = new Error('WTF!');
             logger.error(error);
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
             expect(records[0]).toEqual(['error', error.toString()]);
@@ -433,7 +433,7 @@ describe('Logger Helpers', () => {
             const blob = new Blob([JSON.stringify({ a: 10 })], { type: 'application/json'} );
             logger.debug(buffer, array, blob);
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
             expect(records[0]).toEqual([
@@ -450,7 +450,7 @@ describe('Logger Helpers', () => {
             // Ensure instances are being represented with their name.
             logger.debug(logger);
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
             expect(records[0]).toEqual(['debug', '[MemoryLogger]']);
@@ -469,7 +469,7 @@ describe('Logger Helpers', () => {
             };
             logger.debug(object);
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
             expect(records[0]).toEqual(['debug', object]);
@@ -486,7 +486,7 @@ describe('Logger Helpers', () => {
             ];
             logger.debug(array);
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records.length).toBe(1);
             expect(records[0]).toEqual(['debug', array]);
@@ -500,7 +500,7 @@ describe('Logger Helpers', () => {
                 logger.debug(i);
             }
             const records = JSON
-                .parse(logger.serialize())
+                .parse(JSON.stringify(logger.getRecords(), MemoryLogger.replacer))
                 .map((entry) => entry.slice(1));
             expect(records).toEqual([
                 ['debug', 8],

+ 196 - 0
troubleshoot/log.html

@@ -0,0 +1,196 @@
+<!DOCTYPE html>
+<!--
+
+    Copyright © 2017-2019 Threema GmbH (https://threema.ch/).
+
+    This file is part of Threema Web.
+
+    Threema Web is free software: you can redistribute it and/or modify it
+    under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or (at
+    your option) any later version.
+
+    This program is distributed in the hope that it will be useful, but
+    WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+    General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+
+-->
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <meta name="referrer" content="no-referrer">
+    <meta name="robots" content="noindex">
+
+    <title>Threema Web Log Viewer</title>
+
+    <!-- Favicon -->
+    <link rel="icon" href="../img/favicon/favicon.ico?v=[[VERSION]]" type="image/x-icon">
+    <link rel="shortcut icon" href="../img/favicon/favicon.ico?v=[[VERSION]]" type="image/x-icon">
+
+    <!-- Fonts -->
+    <link rel="stylesheet" href="../fonts/roboto.css?v=[[VERSION]]" type="text/css">
+    <link rel="stylesheet" href="../fonts/material.css?v=[[VERSION]]" type="text/css">
+
+    <!-- Styling -->
+    <style>
+        body {
+            padding: 16px;
+            font-family: 'Roboto';
+            background: url('../img/bg.jpg?v=[[VERSION]]') no-repeat fixed center;
+            background-size: cover;
+        }
+
+        #wrapper {
+            background-color: white;
+            margin: 0 auto;
+            padding: 16px 32px 32px;
+            text-align: center;
+        }
+
+        .drag-over {
+            background-color: #a5d6a7 !important;
+        }
+
+        #logo {
+            width: 300px;
+            color: white;
+            margin: 0 auto 16px;
+        }
+
+        h1 {
+            margin-top: 0;
+            font-size: 30px;
+            font-weight: 500;
+        }
+        h2 {
+            font-weight: 300;
+            font-size: 22px;
+        }
+        p {
+            font-weight: 300;
+        }
+        summary h1, summary h2 {
+            display: inline-block;
+        }
+
+        #config {
+            background-color: #fafafa;
+            border: 1px solid #e0e0e0;
+            padding: 8px;
+            text-align: left;
+            overflow: auto;
+        }
+
+        #log {
+            width: 100%;
+            border: 1px solid #e0e0e0;
+            padding: 8px;
+            text-align: left;
+        }
+
+        #log {
+            border-collapse: collapse;
+            font-family: monospace;
+        }
+
+        #log td {
+            border-bottom: 1px solid #e0e0e0;
+            padding: 2px 6px;
+        }
+
+        .record .message > details {
+            display: inline-block;
+        }
+        .record ol, .record ul {
+            margin: 0 0 0 3px;
+            padding: 0 10px 0;
+            border-left: 1px solid #1565c0;
+            list-style-type: none;
+        }
+
+        .record.debug, .record.trace {
+            background-color: #fafafa;
+        }
+        .record.info {
+            background-color: #e3f2fd;
+        }
+        .record.warn {
+            background-color: #fff59d;
+        }
+        .record.error {
+            background-color: #ef9a9a;
+        }
+
+        .record .date {
+            color: #757575;
+        }
+        .record .tag {
+            text-align: right;
+        }
+
+        .record .null {
+            color: #616161;
+        }
+        .record .boolean {
+            color: #ab47bc;
+        }
+        .record .number {
+            color: #388e3c;
+        }
+        .record .type {
+            color: #1565c0;
+        }
+        .record .converted {
+            color: #00796b;
+        }
+        .record .error {
+            color: #c62828;
+        }
+
+        footer {
+            color: white;
+            font-weight: 300;
+            text-align: center;
+            padding-top: 16px;
+        }
+    </style>
+</head>
+<body>
+
+<header>
+    <div id="title">
+        <div id="logo">
+            <img src="../img/logo.svg?v=[[VERSION]]" alt="Logo">
+        </div>
+    </div>
+</header>
+
+<div id="wrapper">
+    <h1>Log Viewer</h1>
+
+    <p id="prompt">Paste or drag the log to be displayed here.</p>
+
+    <div id="container">
+        <h2>Browser: <span id="browser"></span></h2>
+
+        <details>
+            <summary><h2>Config</h2></summary>
+            <pre id="config" class="log-data"></pre>
+        </details>
+
+        <h2>Log</h2>
+        <table id="log"></table>
+    </div>
+</div>
+
+<footer>&copy; 2017&ndash;2019 Threema GmbH</footer>
+
+<!-- JS -->
+<script src="log.js?v=[[VERSION]]"></script>
+</body>
+</html>

+ 197 - 0
troubleshoot/log.js

@@ -0,0 +1,197 @@
+// Constants
+const regex = {
+    error: new RegExp('^[a-zA-Z]*Error:'),
+};
+
+// DOM elements
+const elements = {
+    wrapper: document.querySelector('#wrapper'),
+    prompt: document.querySelector('#prompt'),
+    container: document.querySelector('#container'),
+    browser: document.querySelector('#browser'),
+    config: document.querySelector('#config'),
+    log: document.querySelector('#log'),
+};
+
+// Show prompt and hide log container
+elements.prompt.hidden = false;
+elements.container.hidden = true;
+
+/**
+ * Escape HTML.
+ */
+function escapeHTML(text) {
+    const template = document.createElement('span');
+    template.innerText = text;
+    return template.innerHTML;
+}
+
+/**
+ * Create an element from HTML.
+ */
+function createElementFromHTML(html) {
+    const template = document.createElement('template');
+    template.innerHTML = html.trim();
+    return template.content.firstChild;
+}
+
+/**
+ * Format a record (message) value.
+ */
+function formatRecordValue(value) {
+    // Handle null
+    if (value === null) {
+        return `<span class="null">${escapeHTML(`${value}`)}</span>`;
+    }
+
+    // Handle boolean
+    if (value.constructor === Boolean) {
+        return `<span class="boolean">${escapeHTML(value)}</span>`;
+    }
+
+    // Handle number
+    if (value.constructor === Number) {
+        return `<span class="number">${escapeHTML(value)}</span>`;
+    }
+
+    // Handle string, converted types (e.g. ArrayBuffer, Blob, ...)
+    // and errors (exceptions).
+    if (value.constructor === String) {
+        if (value.startsWith('[') && value.endsWith(']')) {
+            return `<span class="converted">${escapeHTML(value)}</span>`;
+        }
+        if (regex.error.test(value)) {
+            return `<span class="error">${escapeHTML(value)}</span>`;
+        }
+        return `<span class="string">${escapeHTML(value)}</span>`;
+    }
+
+    // Handle object
+    if (value.constructor === Object) {
+        const entries = Object.entries(value);
+        return `
+            <details>
+                <summary class="type">Object(${entries.length})</summary>
+                <ul>
+                    ${entries.map(([key, value]) => {
+                        return `<li><span class="type">${escapeHTML(key)}:</span> ${formatRecordValue(value)}</li>`;
+                    }).join('\n')}
+                </ul>
+            </details>`;
+    }
+
+    // Handle array
+    if (value instanceof Array) {
+        return `
+            <details>
+                <summary class="type">Array(${value.length})</summary>
+                <ol>
+                    ${value.map((item, index) => {
+                        return `<li><span class="type">${index}:</span> ${formatRecordValue(item)}</li>`;
+                    }).join('\n')}
+                </ol>
+            </details>`;
+    }
+
+    // Unknown
+    return `[${value.constructor}]`;
+}
+
+/**
+ * Show the log in the UI.
+ * @param data A log report in JSON notation.
+ */
+function showLog(data) {
+    // Decode as JSON
+    let container;
+    try {
+        container = JSON.parse(data);
+    } catch (error) {
+        return console.error('Could not parse pasted text to object:', error);
+    }
+
+    // Required keys to be available
+    if (!(container.config instanceof Object) ||
+        container.browser.constructor !== String ||
+        !(container.log instanceof Array)) {
+        return console.error('Not a valid container object');
+    }
+
+    // Hide prompt and show log container
+    elements.prompt.hidden = true;
+    elements.container.hidden = false;
+
+    // Display meta data
+    elements.browser.textContent = container.browser;
+    elements.config.textContent = JSON.stringify(container.config, null, 2);
+
+    // Display log records
+    elements.log.innerHTML = '';
+    let startTimestampMs;
+    for (let [timestampMs, type, tag, ...values] of container.log) {
+        // Determine start timestamp so we can display the offset in seconds
+        if (startTimestampMs === undefined) {
+            startTimestampMs = timestampMs;
+        }
+
+        // Get CSS style from tag (if any)
+        if (tag.startsWith('%c')) {
+            const style = escapeHTML(values.shift());
+            tag = `<span style="${style}">${escapeHTML(tag.substring(2))}</span>`;
+        } else {
+            tag = escapeHTML(tag);
+        }
+
+        // Add element to log container
+        elements.log.appendChild(createElementFromHTML(`
+            <tbody>
+                <tr class="record ${escapeHTML(type)}">
+                    <td class="date">${((timestampMs - startTimestampMs) / 1000).toFixed(3)}</td>
+                    <td class="tag">${tag}</td>
+                    <td class="message">${values.map((value) => formatRecordValue(value)).join('\n')}</td>  
+                </tr>
+            </tbody>`));
+    }
+}
+
+/**
+ * Listen for *paste* events.
+ */
+document.addEventListener('paste', (event) => {
+    // If no clipboard data is available, do nothing.
+    let text;
+    try {
+        text = event.clipboardData.getData('text');
+    } catch (error) {
+        return console.error('Could not retrieve pasted data as text:', error);
+    }
+
+    // Show log
+    showLog(text);
+});
+
+/**
+ * Listen for *drag* events.
+ */
+document.addEventListener('dragover', (event) => {
+    event.preventDefault();
+});
+document.addEventListener('dragenter', () => {
+    elements.wrapper.className = 'drag-over';
+});
+document.addEventListener('drop', (event) => {
+    event.preventDefault();
+    elements.wrapper.className = '';
+
+    // Read first file (if any)
+    const files = event.dataTransfer.files;
+    if (files.length === 0) {
+        console.error('No files in drop event');
+        return;
+    }
+    const reader = new FileReader();
+    reader.addEventListener('load', (event) => {
+        showLog(event.target.result);
+    });
+    reader.readAsText(files[0]);
+});