Browse Source

Add troubleshooting dialog for reporting logs

This introduces a new dialog that can be opened from a link in the
footer. It allows to copy & paste a log or report it to
*SUPPORT directly, if a connection to the mobile device has been
established.
Lennart Grahl 6 năm trước cách đây
mục cha
commit
a9d29e2074

+ 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",

+ 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.
      */

+ 195 - 0
src/controllers/troubleshooting.ts

@@ -0,0 +1,195 @@
+/**
+ * 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, hasFeature, sleep} from '../helpers';
+import * as clipboard from '../helpers/clipboard';
+
+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',
+        'LogService', 'BrowserService', 'WebClientService',
+    ];
+
+    private readonly $scope: ng.IScope;
+    private readonly $mdToast: ng.material.IToastService;
+    private readonly $translate: ng.translate.ITranslateService;
+    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,
+        logService: LogService,
+        browserService: BrowserService,
+        webClientService: WebClientService,
+    ) {
+        super($mdDialog);
+        this.$scope = $scope;
+        this.$mdToast = $mdToast;
+        this.$translate = $translate;
+        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;
+
+        // Serialise the log
+        const log = new TextEncoder().encode(this.logService.memory.serialize());
+
+        // 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();
+        let browserShortInfo = 'unknown';
+        if (browser.wasDetermined()) {
+            browserShortInfo = `${browser.name}-${browser.version}`;
+            if (browser.mobile) {
+                browserShortInfo += '-mobile';
+            }
+        }
+        const message: threema.FileMessageData = {
+            name: `webclient-[[VERSION]]-${browserShortInfo}.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.
+     */
+    private getLog(): string {
+        // TODO: Add metadata
+        return this.logService.memory.serialize();
+    }
+}

+ 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>

+ 3 - 2
src/partials/messenger.ts

@@ -636,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);
                                 })

+ 184 - 171
src/services/webclient.ts

@@ -1693,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;
+        }
     }
 
     /**