فهرست منبع

Improve message upload preview and thumbnails (#858)

The temporary message displayed when uploading a new message to the device
now mimics the look & feel of the resulting message as much as possible.

An image or video that has not been downloaded yet by the app will show an
icon resembling the type instead instead of showing an empty message.

Also, dozens of bug fixes and cleanup.

Resolves #848
Improves #485
Lennart Grahl 6 سال پیش
والد
کامیت
ae41aa8f37

+ 2 - 1
public/i18n/de.json

@@ -124,8 +124,9 @@
         "GROUP_CREATOR": "Ersteller",
         "GROUP_ROLE_NORMAL": "Mitglied",
         "GROUP_ROLE_CREATOR": "Ersteller",
+        "UPLOADING": "Wird hochgeladen …",
         "DOWNLOAD": "Herunterladen",
-        "DOWNLOADING": "Laden …",
+        "DOWNLOADING": "Wird heruntergeladen …",
         "COPY": "Kopieren",
         "COPIED": "Inhalt wurde in die Zwischenablage kopiert!",
         "COPY_ERROR": "Fehler: Konnte Inhalt nicht in die Zwischenablage kopieren",

+ 1 - 0
public/i18n/en.json

@@ -124,6 +124,7 @@
         "GROUP_CREATOR": "Group creator",
         "GROUP_ROLE_NORMAL": "Member",
         "GROUP_ROLE_CREATOR": "Creator",
+        "UPLOADING": "Uploading …",
         "DOWNLOAD": "Download",
         "DOWNLOADING": "Downloading …",
         "COPY": "Copy",

+ 81 - 73
src/directives/message_media.html

@@ -1,94 +1,102 @@
-<!-- Thumbnail -->
-<!-- Images, Gifs & Videos -->
+<!-- Image, Video, GIF (not downloaded) -->
+<div ng-if="ctrl.type === 'image' || ctrl.type === 'video' || (!ctrl.downloaded && ctrl.isGif)"
+     class="thumbnail {{ ctrl.type }}"
+     ng-class="{clickable: !ctrl.uploading, 'large-preview': ctrl.hasPreviewThumbnail()}"
+     ng-click="ctrl.download()"
+     ng-style="ctrl.thumbnailStyle">
 
-<div ng-if="ctrl.uploading">
     <!-- Loading indicator -->
-    <div class="circle active center">
-        <i class="material-icons md-24">file_upload</i>
-        <div class="loading active"></div>
+    <div class="loading-wrapper" ng-if="ctrl.hasPreviewThumbnail()" ng-class="{active: ctrl.isLoading()}">
+        <div class="loading"></div>
+        <div class="loading-text" ng-if="ctrl.uploading" translate>messenger.UPLOADING</div>
+        <div class="loading-text" ng-if="ctrl.isDownloading()" translate>messenger.DOWNLOADING</div>
     </div>
-</div>
-<div ng-if="!ctrl.uploading">
-    <div ng-if="ctrl.showThumbnail" class="thumbnail {{ ctrl.type }}" ng-click="ctrl.download()" ng-style="ctrl.thumbnailStyle">
-
-        <!-- Loading indicator -->
-        <div class="loading-wrapper" ng-class="{active: ctrl.isDownloading()}">
-            <div class="loading"></div>
-            <div class="loading-text" translate>messenger.DOWNLOADING</div>
-        </div>
 
-        <!-- Thumbnail overlays for videos and GIFs -->
-        <div class="overlay video" ng-if="ctrl.type === 'video' && !ctrl.isDownloading()">
-            <i class="material-icons md-light">play_circle_outline</i>
-        </div>
-        <div class="overlay gif" ng-if="ctrl.type === 'file' && ctrl.message.file.type === 'image/gif' && !ctrl.isDownloading()">
-            <i class="material-icons md-light">play_circle_outline</i>
-        </div>
-
-        <!-- Thumbnails -->
-        <span class="in-view-indicator" ng-if="ctrl.type !== 'location'" in-view="ctrl.thumbnailInView($inview)"></span>
-        <img ng-if="ctrl.thumbnail" ng-src="{{ ctrl.thumbnail }}">
-        <div ng-if="ctrl.message.thumbnail && !ctrl.thumbnail" class="thumbnail-loader">
-            <img ng-src="{{ ctrl.getThumbnailPreviewUri() }}">
-        </div>
+    <!-- Thumbnail overlays for videos and GIFs -->
+    <div class="overlay video" ng-if="ctrl.type === 'video' && !ctrl.isLoading()">
+        <i class="material-icons md-light">play_circle_outline</i>
+    </div>
+    <div class="overlay gif" ng-if="ctrl.isGif && !ctrl.isLoading()">
+        <i class="material-icons md-light">play_circle_outline</i>
+    </div>
 
+    <!-- Thumbnails -->
+    <span class="in-view-indicator" ng-if="ctrl.type !== 'location'" in-view="ctrl.thumbnailInView($inview)"></span>
+    <div ng-if="ctrl.thumbnail"
+         class="preview-image"
+         ng-style="{'background-image': ctrl.thumbnail }"></div>
+    <div ng-if="!ctrl.thumbnail && ctrl.hasPreviewThumbnail()"
+         class="thumbnail-loader preview-image"
+         ng-style="{'background-image': ctrl.getThumbnailPreviewUriStyle() }"></div>
+    <div ng-if="!ctrl.thumbnail && !ctrl.hasPreviewThumbnail()" class="circle">
+        <i ng-if="ctrl.type === 'image' || ctrl.isGif" class="material-icons md-24">photo</i>
+        <i ng-if="ctrl.type === 'video'" class="material-icons md-24">movie</i>
+        <div class="loading" ng-class="{active: ctrl.isLoading()}"></div>
     </div>
+</div>
 
-    <!-- Location -->
-    <location ng-if="ctrl.type === 'location'" location="ctrl.location" ng-click="ctrl.openMapLink()"></location>
+<!-- Location -->
+<location ng-if="ctrl.type === 'location'" location="ctrl.location" ng-click="ctrl.openMapLink()"></location>
 
-    <!-- Audio file -->
-    <div class="file-message" ng-if="ctrl.type === 'audio'" ng-click="ctrl.download()">
-        <!-- Loading indicator -->
-        <div class="circle"
-             ng-class="{active: !ctrl.isDownloading()}"
-             ng-if="!ctrl.downloaded">
-            <i class="material-icons md-24">file_download</i>
-            <div class="loading" ng-class="{active: ctrl.isDownloading()}"></div>
-        </div>
-        <!-- Play Indicator -->
-        <div class="circle" ng-if="ctrl.downloaded">
-            <i class="material-icons md-24">play_arrow</i>
-        </div>
-        <div class="info" translate>messageTypes.AUDIO_MESSAGE</div>
+<!-- Audio file -->
+<div class="file-message"
+     ng-if="ctrl.type === 'audio'"
+     ng-class="{clickable: !ctrl.uploading}"
+     ng-click="ctrl.download()">
+    <!-- Loading indicator -->
+    <div class="circle"
+         ng-if="!ctrl.downloaded">
+        <i ng-if="ctrl.uploading" class="material-icons md-24">file_upload</i>
+        <i ng-if="!ctrl.uploading" class="material-icons md-24">file_download</i>
+        <div class="loading" ng-class="{active: ctrl.isLoading()}"></div>
     </div>
 
-    <!-- Anim GIF -->
-    <div class="animgif" ng-if="ctrl.downloaded && ctrl.isAnimGif">
-        <img ng-src="{{ ctrl.blobBufferUrl }}">
+    <!-- Play Indicator -->
+    <div class="circle" ng-if="ctrl.downloaded">
+        <i class="material-icons md-24">play_arrow</i>
     </div>
+    <div class="info" translate>messageTypes.AUDIO_MESSAGE</div>
+</div>
 
-    <!-- Other file messages -->
-    <div class="file-message" ng-if="ctrl.type === 'file' && !ctrl.isAnimGif" ng-click="ctrl.download()">
+<!-- GIF (downloaded) -->
+<div class="animgif" ng-if="ctrl.downloaded && ctrl.isGif">
+    <img ng-src="{{ ctrl.blobBufferUrl }}">
+</div>
 
-        <!-- Loading indicator -->
-        <div class="circle"
-             ng-class="{active: !ctrl.isDownloading()}"
-             ng-if="!ctrl.downloaded"
-             ng-style="{'background-image': 'url({{ ctrl.getThumbnailPreviewUri() }})' }">
-            <i class="material-icons md-24">file_download</i>
-            <div class="loading" ng-class="{active: ctrl.isDownloading()}"></div>
-        </div>
+<!-- Other file messages -->
+<div class="file-message"
+     ng-if="ctrl.type === 'file' && !ctrl.isGif"
+     ng-class="{clickable: !ctrl.uploading}"
+     ng-click="ctrl.download()">
 
-        <!-- File type indicator -->
-        <div class="circle"
-             ng-if="ctrl.downloaded && ctrl.message.thumbnail.preview !== undefined"
-             ng-style="{'background-image': 'url({{ ctrl.getThumbnailPreviewUri() }})' }">
-        </div>
-        <div class="circle"
-             ng-if="ctrl.downloaded && ctrl.message.thumbnail.preview == undefined">
-            <img ng-src="{{ ctrl.message.file.type | mimeTypeIcon }}">
-        </div>
+    <!-- Loading indicator -->
+    <div class="circle"
+         ng-if="!ctrl.downloaded"
+         ng-style="{'background-image': ctrl.getThumbnailPreviewUriStyle() }">
+        <i class="material-icons md-24" ng-if="ctrl.uploading">file_upload</i>
+        <i class="material-icons md-24" ng-if="!ctrl.uploading">file_download</i>
+        <div class="loading" ng-class="{active: ctrl.isLoading()}"></div>
+    </div>
 
-        <!-- File information -->
-        <div class="info">
-            <p>{{ctrl.message.file.name}}</p>
-            <p>{{ctrl.message.file.type | mimeTypeLabel}}</p>
-            <p>{{ctrl.message.file.size | fileSize}}</p>
-        </div>
+    <!-- File type indicator -->
+    <div class="circle"
+         ng-if="ctrl.downloaded && ctrl.message.thumbnail.preview !== undefined"
+         ng-style="{'background-image': ctrl.getThumbnailPreviewUriStyle() }">
+    </div>
+    <div class="circle"
+         ng-if="ctrl.downloaded && ctrl.message.thumbnail.preview === undefined">
+        <img ng-src="{{ ctrl.message.file.type | mimeTypeIcon }}">
+    </div>
 
+    <!-- File information -->
+    <div class="info">
+        <p>{{ctrl.message.file.name}}</p>
+        <p>{{ctrl.message.file.type | mimeTypeLabel}}</p>
+        <p>{{ctrl.message.file.size | fileSize}}</p>
     </div>
+
 </div>
+
 <!-- Ballot -->
 <span ng-if="ctrl.type === 'ballot'"><em translate>messenger.BALLOT_MESSAGES_NOT_SUPPORTED</em></span> <!-- TODO -->
 <span ng-if="ctrl.type === 'unknown'"><em translate>messenger.UNKNOWN_MESSAGE_TYPE</em></span> <!-- TODO -->

+ 50 - 37
src/directives/message_media.ts

@@ -102,7 +102,8 @@ export default [
                 });
 
                 this.$onInit = function() {
-                    this.type = this.message.type;
+                    const message = this.message as threema.Message;
+                    this.type = message.type;
 
                     // Downloading
                     this.downloading = false;
@@ -110,39 +111,45 @@ export default [
                     this.downloaded = false;
 
                     // Uploading
-                    this.uploading = this.message.temporaryId !== undefined
-                        && this.message.temporaryId !== null;
+                    this.uploading = message.temporaryId !== undefined
+                        && message.temporaryId !== null;
 
                     // AnimGIF detection
-                    this.isAnimGif = !this.uploading
-                        && (this.message as threema.Message).type === 'file'
-                        && (this.message as threema.Message).file.type === 'image/gif';
+                    this.isGif = message.type === 'file' && message.file.type === 'image/gif';
+
+                    // Has a preview thumbnail
+                    this.hasPreviewThumbnail = (): boolean => {
+                        return hasValue(message.thumbnail) && (
+                            hasValue(message.thumbnail.previewDataUrl) || hasValue(message.thumbnail.preview));
+                    };
 
                     // Preview thumbnail
-                    let thumbnailPreviewUri = null;
-                    this.getThumbnailPreviewUri = () => {
+                    this.getThumbnailPreviewUri = (): string | null => {
                         // Cache thumbnail preview URI
-                        if (thumbnailPreviewUri === null
-                                && hasValue(this.message) && hasValue(this.message.thumbnail)) {
-                            thumbnailPreviewUri = bufferToUrl(
-                                (this.message as threema.Message).thumbnail.preview,
+                        if (hasValue(message.thumbnail.previewDataUrl)) {
+                            return message.thumbnail.previewDataUrl;
+                        }
+                        if (hasValue(message.thumbnail.preview)) {
+                            message.thumbnail.previewDataUrl = bufferToUrl(
+                                message.thumbnail.preview,
                                 webClientService.appCapabilities.imageFormat.thumbnail,
                                 log,
                             );
+                            return message.thumbnail.previewDataUrl;
                         }
-                        return thumbnailPreviewUri;
+                        return null;
+                    };
+                    // TODO: Uuuuugly!
+                    this.getThumbnailPreviewUriStyle = (): string => {
+                        const previewUri = hasValue(message.thumbnail) ? this.getThumbnailPreviewUri() : null;
+                        return previewUri !== null ? `url(${previewUri})` : 'none';
                     };
 
-                    // Thumbnail loading
-                    //
-                    // Do not show thumbnail in file messages (except anim gif).
-                    // If a thumbnail in file messages are available, the thumbnail
-                    // will be shown in the file circle
-                    this.showThumbnail = this.message.thumbnail !== undefined
-                        && ((this.message as threema.Message).type !== 'file' || this.isAnimGif);
+                    // Only show thumbnails for images, videos and GIFs
+                    // If a preview image is not available, we fall back to
+                    // icons depending on the type.
                     this.thumbnail = null;
-                    this.thumbnailFormat = webClientService.appCapabilities.imageFormat.thumbnail;
-                    if (this.message.thumbnail !== undefined) {
+                    if (message.thumbnail !== undefined) {
                         this.thumbnailStyle = {
                             width: this.message.thumbnail.width + 'px',
                             height: this.message.thumbnail.height + 'px',
@@ -153,8 +160,7 @@ export default [
 
                     this.wasInView = false;
                     this.thumbnailInView = (inView: boolean) => {
-                        if (this.message.thumbnail === undefined
-                                || this.wasInView === inView) {
+                        if (this.uploading || message.thumbnail === undefined || this.wasInView === inView) {
                             // do nothing
                             return;
                         }
@@ -169,29 +175,29 @@ export default [
                         } else {
                             if (this.thumbnail === null) {
                                 const setThumbnail = (buf: ArrayBuffer) => {
-                                    this.thumbnail = bufferToUrl(
+                                    this.thumbnail = `url(${bufferToUrl(
                                         buf,
                                         webClientService.appCapabilities.imageFormat.thumbnail,
                                         log,
-                                    );
+                                    )})`;
                                 };
 
-                                if (this.message.thumbnail.img !== undefined) {
-                                    setThumbnail(this.message.thumbnail.img);
+                                if (message.thumbnail.img !== undefined) {
+                                    setThumbnail(message.thumbnail.img);
                                     return;
                                 } else {
                                     this.thumbnailDownloading = true;
                                     loadingThumbnailTimeout = timeoutService.register(() => {
                                         webClientService
-                                            .requestThumbnail(this.receiver, this.message)
+                                            .requestThumbnail(this.receiver, message)
                                             .then((img) => $timeout(() => {
                                                 setThumbnail(img);
                                                 this.thumbnailDownloading = false;
                                             }))
                                             .catch((error) => {
                                                 // TODO: Handle this properly / show an error message
-                                                const message = `Thumbnail request has been rejected: ${error}`;
-                                                this.log.error(message);
+                                                const description = `Thumbnail request has been rejected: ${error}`;
+                                                this.log.error(description);
                                             });
                                     }, 1000, false, 'thumbnail');
                                 }
@@ -201,8 +207,8 @@ export default [
 
                     // For locations, retrieve the coordinates
                     this.location = null;
-                    if (this.message.location !== undefined) {
-                        this.location = this.message.location;
+                    if (message.location !== undefined) {
+                        this.location = message.location;
                         this.downloaded = true;
                     }
 
@@ -217,11 +223,14 @@ export default [
                     // Download function
                     this.download = () => {
                         log.debug('Download blob');
+                        if (this.uploading) {
+                            log.debug('Cannot download, still uploading');
+                            return;
+                        }
                         if (this.downloading) {
                             log.debug('Download already in progress...');
                             return;
                         }
-                        const message: threema.Message = this.message;
                         const receiver: threema.Receiver = this.receiver;
                         this.downloading = true;
                         webClientService.requestBlob(message.id, receiver)
@@ -231,7 +240,7 @@ export default [
                                     this.downloading = false;
                                     this.downloaded = true;
 
-                                    switch (this.message.type) {
+                                    switch (message.type) {
                                         case 'image':
                                             const caption = message.caption || '';
                                             mediaboxService.setMedia(
@@ -245,7 +254,7 @@ export default [
                                             saveAs(new Blob([blobInfo.buffer]), blobInfo.filename);
                                             break;
                                         case 'file':
-                                            if (this.message.file.type === 'image/gif') {
+                                            if (message.file.type === 'image/gif') {
                                                 // Show inline
                                                 this.blobBufferUrl = bufferToUrl(
                                                     blobInfo.buffer, 'image/gif', log);
@@ -260,7 +269,7 @@ export default [
                                             this.playAudio(blobInfo);
                                             break;
                                         default:
-                                            log.warn('Ignored download request for message type', this.message.type);
+                                            log.warn('Ignored download request for message type', message.type);
                                     }
                                 });
                             })
@@ -288,6 +297,10 @@ export default [
                             });
                     };
 
+                    this.isLoading = () => {
+                        return this.uploading || this.isDownloading();
+                    };
+
                     this.isDownloading = () => {
                         return this.downloading
                             || this.thumbnailDownloading

+ 1 - 1
src/directives/message_meta.ts

@@ -45,7 +45,7 @@ export default [
             }],
             template: `
                 <span ng-if="ctrl.isGif" class="message-meta-item">GIF</span>
-                <span ng-if="ctrl.duration" class="message-meta-item message-duration">
+                <span ng-if="ctrl.duration !== null" class="message-meta-item message-duration">
                     <md-icon class="material-icons">av_timer</md-icon>
                     {{ctrl.duration | duration}}
                 </span>

+ 3 - 1
src/partials/messenger.ts

@@ -111,6 +111,7 @@ class SendFileController extends DialogController {
         this.hide({
             caption: this.caption,
             sendAsFile: this.sendAsFile,
+            previewDataUrl: this.previewDataUrl,
         });
     }
 
@@ -696,12 +697,13 @@ class ConversationController {
                         //       type.
                         const caption = data.caption;
                         const sendAsFile = data.sendAsFile;
+                        const 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)
+                            this.webClientService.sendMessage(this.$stateParams, type, msg, previewDataUrl)
                                 .then(() => {
                                     nextCallback(index);
                                 })

+ 23 - 10
src/sass/components/_message_media.scss

@@ -30,6 +30,7 @@ $loading-ring-thickness: 5px;
         outline: none;
         border-radius: $circle-size / 2;
         background-color: #808080;
+        background-size: cover;
         width: $circle-size;
         min-width: $circle-size;
         height: $circle-size;
@@ -64,15 +65,28 @@ $loading-ring-thickness: 5px;
         height: $circle-size - (2 * $loading-ring-thickness);
     }
 
-    // Thumbnails (Images, GIFs, Locations, ...)
+    // Thumbnails
     .thumbnail {
-        // Make it clickable
         position: relative;
-        // For absolute positioning of children
-        cursor: pointer;
-        // For big pictures
+        max-width: 350px;
+        max-height: 350px;
         overflow: hidden;
 
+        &.large-preview {
+            min-width: 160px;
+            min-height: 100px;
+        }
+
+        &.clickable {
+            cursor: pointer;
+        }
+
+        .preview-image {
+            background-size: cover;
+            width: 100%;
+            height: 100%;
+        }
+
         // Styling of loader
         .loading-wrapper {
             display: none;
@@ -132,9 +146,12 @@ $loading-ring-thickness: 5px;
 
     // File messages (Files, Audio, ...)
     .file-message {
-        cursor: pointer;
         height: $circle-size;
 
+        &.clickable {
+            cursor: pointer;
+        }
+
         .message-text {
             padding-top: 8px;
         }
@@ -161,8 +178,4 @@ $loading-ring-thickness: 5px;
         }
 
     }
-
-    img {
-        max-width: 100%;
-    }
 }

+ 2 - 13
src/sass/sections/_conversation.scss

@@ -259,23 +259,12 @@
         max-width: 85%;
 
         &:not(.text-message-body) {
-            // set fixed height to thumbnails
             .thumbnail-loader {
-                position: absolute;
-                width: calc(100%);
-                max-width: 100%;
-                height: calc(100%);
-                max-height: 100%;
-                overflow: hidden;
-
-                img {
-                    width: 100%;
-                    filter: blur(10px);
-                }
+                width: 100%;
+                filter: blur(10px);
             }
 
             .thumbnail {
-                max-width: 100%;
                 text-align: center;
             }
 

+ 82 - 16
src/services/message.ts

@@ -29,6 +29,11 @@ export class MessageAccess {
 }
 
 export class MessageService {
+    // Maximum thumbnail size (width and height)
+    // Note: Keep this in sync with `.thumbnail`s `max-width` and `max-height`
+    //       properties!
+    private static readonly MAX_THUMBNAIL_SIZE = 350;
+
     // Own services
     private receiverService: ReceiverService;
     private timeoutService: TimeoutService;
@@ -136,31 +141,92 @@ export class MessageService {
     }
 
     /**
-     * Create a message object with a temporary id
+     * Get a preview Thumbnail object from a data URI.
+     */
+    public getPreviewThumbnail(uri: string, preview: ArrayBuffer): threema.Thumbnail {
+        const image = new Image();
+        image.src = uri;
+
+        // Downscale image (if necessary)
+        if (image.width > MessageService.MAX_THUMBNAIL_SIZE || image.height > MessageService.MAX_THUMBNAIL_SIZE) {
+            const scale = Math.min(
+                MessageService.MAX_THUMBNAIL_SIZE / image.width,
+                MessageService.MAX_THUMBNAIL_SIZE / image.height);
+            image.width = Math.round(scale * image.width);
+            image.height = Math.round(scale * image.height);
+        }
+
+        return {
+            previewDataUrl: uri,
+            preview: preview,
+            width: image.width,
+            height: image.height,
+        };
+    }
+
+    /**
+     * Create a message object with a temporary id.
      */
     public createTemporary(
         temporaryId: string,
         receiver: threema.Receiver,
-        msgType: string,
-        messageData: threema.MessageData,
+        type: threema.MessageType,
+        data: threema.MessageData,
+        previewDataUrl?: string,
     ): threema.Message {
-        const message = {
-            temporaryId: temporaryId,
-            type: msgType,
-            isOutbox: true,
-            state: 'pending',
-            id: undefined,
-            body: undefined,
-            date: Math.floor(Date.now() / 1000),
+        // Populate base message
+        const timestampS = Math.floor(Date.now() / 1000);
+        const message: threema.Message = {
+            type: type,
+            id: undefined, // Note: Hack, violates the interface
+            date: timestampS,
+            sortKey: Number.MAX_SAFE_INTEGER, // Note: Ugly hack
             partnerId: receiver.id,
+            isOutbox: true,
             isStatus: false,
-            quote: undefined,
-            caption: msgType === 'file' ? (messageData as threema.FileMessageData).caption : null,
+            state: 'pending',
+            quote: data.quote,
+            temporaryId: temporaryId,
         } as threema.Message;
 
-        if (msgType === 'text') {
-            message.body = (messageData as threema.TextMessageData).text;
-            message.quote = (messageData as threema.TextMessageData).quote;
+        // Populate message depending on type
+        switch (type) {
+            case 'text':
+                const textData = data as threema.TextMessageData;
+                message.body = textData.text;
+                break;
+            case 'image': {
+                const fileData = data as threema.FileMessageData;
+                message.caption = fileData.caption;
+                if (previewDataUrl !== undefined) {
+                    message.thumbnail = this.getPreviewThumbnail(previewDataUrl, fileData.data);
+                }
+                break;
+            }
+            case 'video': {
+                const fileData = data as threema.FileMessageData;
+                message.video = {
+                    duration: null, // Note: Hack, violates the interface
+                    size: fileData.size,
+                };
+                break;
+            }
+            case 'file': {
+                const fileData = data as threema.FileMessageData;
+                message.caption = fileData.caption;
+                message.file = {
+                    name: fileData.name,
+                    size: fileData.size,
+                    type: fileData.fileType,
+                    inApp: false,
+                };
+                if (previewDataUrl !== undefined) {
+                    message.thumbnail = this.getPreviewThumbnail(previewDataUrl, fileData.data);
+                }
+                break;
+            }
+            default:
+                throw new Error(`Cannot create temporary message for type: ${type}`);
         }
 
         // Add delay for timeout checking

+ 49 - 26
src/services/webclient.ts

@@ -1684,30 +1684,39 @@ export class WebClientService {
      * Send a message to the specified receiver.
      */
     public sendMessage(
-        receiver,
-        type: threema.MessageContentType,
-        message: threema.MessageData,
+        baseReceiver: threema.BaseReceiver,
+        sendType: threema.MessageContentType,
+        data: threema.MessageData,
+        previewDataUrl?: string,
     ): Promise<void> {
         return new Promise<void> (
             (resolve, reject) => {
-                // Try to load receiver object
-                const receiverObject = this.receivers.getData(receiver);
+                // 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(receiverObject) && receiverObject.isBlocked) {
+                if (isContactReceiver(receiver) && receiver.isBlocked) {
                     return reject(this.$translate.instant('error.CONTACT_BLOCKED'));
                 }
+
                 // Decide on subtype
                 let subType;
-                switch (type) {
+                switch (sendType) {
                     case 'text':
+                        reflectedType = 'text';
                         subType = WebClientService.SUB_TYPE_TEXT_MESSAGE;
 
-                        const textMessage = message as threema.TextMessageData;
-                        const msgLength = textMessage.text.length;
+                        const textData = data as threema.TextMessageData;
+                        const msgLength = textData.text.length;
 
                         // Ignore empty text messages
                         if (msgLength === 0) {
-                            return reject();
+                            this.log.warn('Ignored empty text message');
+                            return reject(this.$translate.instant('error.ERROR_OCCURRED'));
                         }
 
                         // Ignore text messages that are too long.
@@ -1719,31 +1728,37 @@ export class WebClientService {
 
                         break;
                     case 'file':
+                        const fileData = data as threema.FileMessageData;
+
                         // Validate max file size
                         if (this.chosenTask === threema.ChosenTask.WebRTC) {
-                            if ((message as threema.FileMessageData).size > WebClientService.MAX_FILE_SIZE_WEBRTC) {
+                            if (fileData.size > WebClientService.MAX_FILE_SIZE_WEBRTC) {
                                 return reject(this.$translate.instant('error.FILE_TOO_LARGE_WEB'));
                             }
                         } else {
-                            if ((message as threema.FileMessageData).size > this.clientInfo.capabilities.maxFileSize) {
+                            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),
                                 }));
                             }
                         }
 
-                        // Determine required feature mask
-                        let requiredFeature: ContactReceiverFeature = ContactReceiverFeature.FILE;
+                        // Determine reflected type and required feature mask
+                        reflectedType = 'file';
+                        let requiredFeature = ContactReceiverFeature.FILE;
                         let invalidFeatureMessage = 'error.FILE_MESSAGES_NOT_SUPPORTED';
-                        if ((message as threema.FileMessageData).sendAsFile !== true) {
-                            // check mime type
-                            const mime = (message as threema.FileMessageData).fileType;
-                            if (this.mimeService.isAudio(mime, this.clientInfo.os)) {
+                        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(mime) || this.mimeService.isVideo(mime)) {
-                                requiredFeature = ContactReceiverFeature.AUDIO;
-                                invalidFeatureMessage = 'error.MESSAGE_NOT_SUPPORTED';
+                            } else if (this.mimeService.isImage(mimeType)) {
+                                reflectedType = 'image';
+                            } else if (this.mimeService.isVideo(mimeType)) {
+                                reflectedType = 'video';
                             }
                         }
 
@@ -1809,16 +1824,24 @@ export class WebClientService {
                                 }
                                 break;
                             default:
-                                return reject();
+                                this.log.error('Invalid receiver type:', receiver.type);
+                                return reject(this.$translate.instant('error.ERROR_OCCURRED'));
                         }
                         break;
                     default:
-                        this.arpLog.warn('Invalid message type:', type);
-                        return reject();
+                        this.log.error('Invalid message type:', sendType);
+                        return reject(this.$translate.instant('error.ERROR_OCCURRED'));
                 }
 
                 const id = this.createRandomWireMessageId();
-                const temporaryMessage = this.messageService.createTemporary(id, receiver, type, message);
+                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 = {
@@ -1827,7 +1850,7 @@ export class WebClientService {
                 };
 
                 // Send message
-                this.sendCreateWireMessage(subType, true, args, message, id)
+                this.sendCreateWireMessage(subType, true, args, data, id)
                     .catch((error) => {
                         this.arpLog.error('Error sending message:', error);
 

+ 5 - 5
src/threema.d.ts

@@ -75,8 +75,9 @@ declare namespace threema {
     }
 
     interface Thumbnail {
-        img?: string;
+        img?: ArrayBuffer; // Note: Does not exist in ARP
         preview: ArrayBuffer;
+        previewDataUrl?: string, // Note: Does not exist in ARP
         width: number;
         height: number;
     }
@@ -107,7 +108,7 @@ declare namespace threema {
     interface Message {
         type: MessageType;
         id: string;
-        body: string;
+        body?: string;
         thumbnail?: Thumbnail;
         date?: number;
         events?: MessageEvent[];
@@ -131,11 +132,10 @@ declare namespace threema {
     }
 
     interface FileInfo {
-        description: string;
         name: string;
         size: number;
         type: string;
-        inApp: boolean;
+        inApp: boolean; // See: https://github.com/threema-ch/app-remote-protocol/issues/4
     }
 
     interface VideoInfo {
@@ -871,7 +871,7 @@ declare namespace threema {
             addOlder(receiver: BaseReceiver, messages: Message[]): void;
             addStatusMessage(receiver: BaseReceiver, text: string): void;
             update(receiver: BaseReceiver, message: Message): boolean;
-            setThumbnail(receiver: BaseReceiver, messageId: string, thumbnailImage: string): boolean;
+            setThumbnail(receiver: BaseReceiver, messageId: string, thumbnailImage: ArrayBuffer): boolean;
             remove(receiver: BaseReceiver, messageId: string): boolean;
             removeTemporary(receiver: BaseReceiver, temporaryMessageId: string): boolean;
             bindTemporaryToMessageId(receiver: BaseReceiver, temporaryId: string, messageId: string): boolean;

+ 1 - 1
src/threema/container.ts

@@ -662,7 +662,7 @@ class Messages implements threema.Container.Messages {
     /**
      * Update a thumbnail of a message, if a message was found the method will return true
      */
-    public setThumbnail(receiver: threema.BaseReceiver, messageId: string, thumbnailImage: string): boolean {
+    public setThumbnail(receiver: threema.BaseReceiver, messageId: string, thumbnailImage: ArrayBuffer): boolean {
         const list = this.getList(receiver);
         for (const message of list) {
             if (message.id === messageId) {