Просмотр исходного кода

Check text length before sending a ask for message splitting (#79)

Silly 8 лет назад
Родитель
Сommit
a73d2220d0

+ 2 - 1
karma.conf.js

@@ -8,7 +8,8 @@ module.exports = function(config) {
             'dist/app.js',
             'tests/filters.js',
             'tests/qrcode.js',
-            'tests/service/message.js'
+            'tests/service/message.js',
+            'tests/helpers.js',
         ],
         customLaunchers: {
             Chromium_ci_gitlab: {

+ 5 - 2
public/i18n/de.json

@@ -140,7 +140,9 @@
         "DRAFT": "Entwurf",
         "PRIVATE": "Privat",
         "PRIVATE_CHAT": "Private Unterhaltung",
-        "PRIVATE_CHAT_DESCRIPTION": "Private Unterhaltungen werden in Threema Web nicht unterstützt."
+        "PRIVATE_CHAT_DESCRIPTION": "Private Unterhaltungen werden in Threema Web nicht unterstützt.",
+        "MESSAGE_TOO_LONG_SPLIT_SUBJECT": "Nachricht aufteilen.",
+        "MESSAGE_TOO_LONG_SPLIT_BODY": "Es werden maximal {max} Zeichen pro Nachricht unterstützt, wollen Sie die Nachricht in {count} seperate Nachrichten aufteilen?"
     },
     "messageTypes": {
         "AUDIO_MESSAGE": "Sprachnachricht",
@@ -157,7 +159,8 @@
         "AUDIO_MESSAGES_NOT_SUPPORTED": "«{receiverName}» kann noch keine Sprachnachrichten erhalten.",
         "FILE_MESSAGES_NOT_SUPPORTED": "«{receiverName}» kann noch keine Dateien erhalten.",
         "ERROR_OCCURRED": "Es ist ein Fehler aufgetreten.",
-        "FILE_TOO_LARGE": "Aktuell können keine Dateien grösser als 15 MiB über Threema Web versendet werden"
+        "FILE_TOO_LARGE": "Aktuell können keine Dateien grösser als 15 MiB über Threema Web versendet werden",
+        "TEXT_TOO_LONG": "Diese Nachricht ist zu lang und kann nicht gesendet werden (Maximale Länge {max} Zeichen)."
     },
     "mimeTypes": {
         "android_apk": "Android-Paket",

+ 5 - 2
public/i18n/en.json

@@ -141,7 +141,9 @@
         "DRAFT": "Draft",
         "PRIVATE": "Private",
         "PRIVATE_CHAT": "Private chat",
-        "PRIVATE_CHAT_DESCRIPTION": "Private chat are not supported in Threema Web."
+        "PRIVATE_CHAT_DESCRIPTION": "Private chat are not supported in Threema Web.",
+        "MESSAGE_TOO_LONG_SPLIT_SUBJECT": "Split Message",
+        "MESSAGE_TOO_LONG_SPLIT_BODY": "No more than {max} characters can be sent per message, do you want to split it into {count} separate messages?"
     },
     "messageTypes": {
         "AUDIO_MESSAGE": "Audio Message",
@@ -158,7 +160,8 @@
         "AUDIO_MESSAGES_NOT_SUPPORTED": "«{receiverName}» cannot receive voice messages.",
         "FILE_MESSAGES_NOT_SUPPORTED": "«{receiverName}» cannot receive files.",
         "ERROR_OCCURRED": "An error occurred.",
-        "FILE_TOO_LARGE": "Currently files larger than 15 MiB cannot be sent through Threema Web"
+        "FILE_TOO_LARGE": "Currently files larger than 15 MiB cannot be sent through Threema Web",
+        "TEXT_TOO_LONG": "This message is too long and cannot be sent (Max length {max} characters)."
     },
     "mimeTypes": {
         "android_apk": "Android package",

+ 49 - 9
src/directives/compose_area.ts

@@ -20,15 +20,19 @@
  */
 export default [
     'BrowserService',
+    'StringService',
     '$window',
     '$timeout',
     '$translate',
+    '$mdDialog',
     '$filter',
     '$sanitize',
     '$log',
     function(browserService: threema.BrowserService,
+             stringService: threema.StringService,
              $window, $timeout: ng.ITimeoutService,
              $translate: ng.translate.ITranslateService,
+             $mdDialog: ng.material.IDialogService,
              $filter: ng.IFilterService,
              $sanitize: ng.sanitize.ISanitizeService,
              $log: ng.ILogService) {
@@ -41,6 +45,7 @@ export default [
                 onTyping: '=',
                 draft: '=',
                 onUploading: '=',
+                maxTextLength: '=',
             },
             link(scope: any, element) {
                 // Logging
@@ -101,7 +106,7 @@ export default [
                 // Submit the text from the compose area.
                 //
                 // Emoji images are converted to their alt text in this process.
-                function submitText() {
+                function submitText(): Promise<any> {
                     let text = '';
                     for (let node of composeDiv[0].childNodes) {
                         switch (node.nodeType) {
@@ -121,15 +126,46 @@ export default [
                                 $log.warn(logTag, 'Unhandled node:', node);
                         }
                     }
-                    const textMessageData: threema.TextMessageData = {text: text.trim()};
-                    // send as array
-                    return scope.submit('text', [textMessageData]);
+                    return new Promise((resolve, reject) => {
+                        let submitTexts = (strings: string[]) => {
+                            let messages: threema.TextMessageData[] = [];
+                            for (let piece of strings) {
+                                messages.push({
+                                    text: piece,
+                                });
+                            }
+
+                            scope.submit('text', messages)
+                                .then(resolve)
+                                .catch(reject);
+                        };
+
+                        let fullText = text.trim();
+                        if (fullText.length > scope.maxTextLength) {
+                            let pieces: string[] = stringService.byteChunk(fullText, scope.maxTextLength, 50);
+                            let confirm = $mdDialog.confirm()
+                                .title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
+                                .textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
+                                    max: scope.maxTextLength,
+                                    count: pieces.length,
+                                }))
+                                .ok($translate.instant('common.YES'))
+                                .cancel($translate.instant('common.NO'));
+
+                            $mdDialog.show(confirm).then(function () {
+                                submitTexts(pieces);
+                            }, () => {
+                                reject();
+                            });
+                        } else {
+                            submitTexts([fullText]);
+                        }
+                    });
                 }
 
                 function sendText(): boolean {
                     if  (composeDiv[0].innerHTML.length > 0) {
-                        const submitted = submitText();
-                        if (submitted) {
+                        submitText().then(() => {
                             // Clear compose div
                             composeDiv[0].innerText = '';
 
@@ -137,10 +173,14 @@ export default [
                             scope.stopTyping();
 
                             scope.onTyping('');
-                        }
 
-                        updateView();
-                        return submitted;
+                            updateView();
+                        }).catch(() => {
+                            // do nothing
+                            this.$log.warn('failed to submit text');
+                        });
+
+                        return true;
                     }
                     return false;
                 }

+ 2 - 1
src/partials/messenger.conversation.html

@@ -85,7 +85,8 @@
                     on-typing="ctrl.onTyping"
                     start-typing="ctrl.startTyping"
                     stop-typing="ctrl.stopTyping"
-                    on-uploading="ctrl.onUploading">
+                    on-uploading="ctrl.onUploading"
+                    max-text-length="ctrl.maxTextLength">
             </compose-area>
         </div>
     </div>

+ 108 - 83
src/partials/messenger.ts

@@ -117,6 +117,7 @@ class ConversationController {
     private draft: string;
     private $translate: ng.translate.ITranslateService;
     private locked = false;
+    public maxTextLength: number;
     public isTyping = (): boolean => false;
 
     private uploading = {
@@ -162,6 +163,8 @@ class ConversationController {
         // Close any showing dialogs
         this.$mdDialog.cancel();
 
+        this.maxTextLength = this.webClientService.getMaxTextLength();
+
         // On every navigation event, close all dialogs.
         // Note: Deprecated. When migrating ui-router ($state),
         // replace with transition hooks.
@@ -275,99 +278,121 @@ class ConversationController {
      * Submit function for input field. Can contain text or file data.
      * Return whether sending was successful.
      */
-    public submit = (type: threema.MessageContentType, contents: threema.MessageData[]): boolean => {
+    public submit = (type: threema.MessageContentType, contents: threema.MessageData[]): Promise<any> => {
         // Validate whether a connection is available
-        if (this.stateService.state !== 'ok') {
-            // Invalid connection, show toast and abort
-            this.showError(this.$translate.instant('error.NO_CONNECTION'));
-            return false;
-        }
-        switch (type) {
-            case 'file':
-                // Determine file type
-                let showSendAsFileCheckbox = false;
-                for (let msg of contents) {
-                    const mime = (msg as threema.FileMessageData).fileType;
-                    if (this.mimeService.isImage(mime)
-                        || this.mimeService.isAudio(mime)
-                        || this.mimeService.isVideo(mime)) {
-                        showSendAsFileCheckbox = true;
-                        break;
+        return new Promise((resolve, reject) => {
+            if (this.stateService.state !== 'ok') {
+                // Invalid connection, show toast and abort
+                this.showError(this.$translate.instant('error.NO_CONNECTION'));
+                return reject();
+            }
+            let success = true;
+            let nextCallback = (index: number) => {
+                if (index === contents.length - 1) {
+                    if (success) {
+                        resolve();
+                    } else {
+                        reject();
                     }
                 }
-
-                // Eager translations
-                const title = this.$translate.instant('messenger.CONFIRM_FILE_SEND', {
-                    senderName: this.receiver.displayName,
-                });
-                const placeholder = this.$translate.instant('messenger.CONFIRM_FILE_CAPTION');
-                const confirmSendAsFile = this.$translate.instant('messenger.CONFIRM_SEND_AS_FILE');
-
-                // Show confirmation dialog
-                this.$mdDialog.show({
-                    clickOutsideToClose: false,
-                    controller: 'SendFileController',
-                    controllerAs: 'ctrl',
-                    // tslint:disable:max-line-length
-                    template: `
-                        <md-dialog class="send-file-dialog">
-                            <md-dialog-content class="md-dialog-content">
-                                <h2 class="md-title">${title}</h2>
-                                <md-input-container md-no-float class="input-caption md-prompt-input-container">
-                                    <input md-autofocus ng-keypress="ctrl.keypress($event)" ng-model="ctrl.caption" placeholder="${placeholder}" aria-label="${placeholder}">
-                                </md-input-container>
-                                <md-input-container md-no-float class="input-send-as-file md-prompt-input-container" ng-show="${showSendAsFileCheckbox}">
-                                    <md-checkbox ng-model="ctrl.sendAsFile" aria-label="${confirmSendAsFile}">
-                                        ${confirmSendAsFile}
-                                    </md-checkbox>
-                                </md-input-container>
-                            </md-dialog-content>
-                            <md-dialog-actions>
-                                <button class="md-primary md-cancel-button md-button" md-ink-ripple type="button" ng-click="ctrl.cancel()">
-                                    <span translate>common.CANCEL</span>
-                                </button>
-                                <button class="md-primary md-cancel-button md-button" md-ink-ripple type="button" ng-click="ctrl.send()">
-                                    <span translate>common.SEND</span>
-                                </button>
-                            </md-dialog-actions>
-                        </md-dialog>
-                    `,
-                    // tslint:enable:max-line-length
-                }).then((data) => {
-                    const caption = data.caption;
-                    const sendAsFile = data.sendAsFile;
-                    contents.forEach((msg: threema.FileMessageData) => {
-                        if (caption !== undefined && caption.length > 0) {
-                            msg.caption = caption;
+            };
+
+            switch (type) {
+                case 'file':
+                    // Determine file type
+                    let showSendAsFileCheckbox = false;
+                    for (let msg of contents) {
+                        const mime = (msg as threema.FileMessageData).fileType;
+                        if (this.mimeService.isImage(mime)
+                            || this.mimeService.isAudio(mime)
+                            || this.mimeService.isVideo(mime)) {
+                            showSendAsFileCheckbox = true;
+                            break;
                         }
-                        msg.sendAsFile = sendAsFile;
+                    }
+
+                    // Eager translations
+                    const title = this.$translate.instant('messenger.CONFIRM_FILE_SEND', {
+                        senderName: this.receiver.displayName,
+                    });
+                    const placeholder = this.$translate.instant('messenger.CONFIRM_FILE_CAPTION');
+                    const confirmSendAsFile = this.$translate.instant('messenger.CONFIRM_SEND_AS_FILE');
+
+                    // Show confirmation dialog
+                    this.$mdDialog.show({
+                        clickOutsideToClose: false,
+                        controller: 'SendFileController',
+                        controllerAs: 'ctrl',
+                        // tslint:disable:max-line-length
+                        template: `
+                            <md-dialog class="send-file-dialog">
+                                <md-dialog-content class="md-dialog-content">
+                                    <h2 class="md-title">${title}</h2>
+                                    <md-input-container md-no-float class="input-caption md-prompt-input-container">
+                                        <input md-autofocus ng-keypress="ctrl.keypress($event)" ng-model="ctrl.caption" placeholder="${placeholder}" aria-label="${placeholder}">
+                                    </md-input-container>
+                                    <md-input-container md-no-float class="input-send-as-file md-prompt-input-container" ng-show="${showSendAsFileCheckbox}">
+                                        <md-checkbox ng-model="ctrl.sendAsFile" aria-label="${confirmSendAsFile}">
+                                            ${confirmSendAsFile}
+                                        </md-checkbox>
+                                    </md-input-container>
+                                </md-dialog-content>
+                                <md-dialog-actions>
+                                    <button class="md-primary md-cancel-button md-button" md-ink-ripple type="button" ng-click="ctrl.cancel()">
+                                        <span translate>common.CANCEL</span>
+                                    </button>
+                                    <button class="md-primary md-cancel-button md-button" md-ink-ripple type="button" ng-click="ctrl.send()">
+                                        <span translate>common.SEND</span>
+                                    </button>
+                                </md-dialog-actions>
+                            </md-dialog>
+                        `,
+                        // tslint:enable:max-line-length
+                    }).then((data) => {
+                        const caption = data.caption;
+                        const sendAsFile = data.sendAsFile;
+                        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)
+                                .then(() => {
+                                    nextCallback(index);
+                                })
+                                .catch((error) => {
+                                    this.$log.error(error);
+                                    this.showError(error);
+                                    success = false;
+                                    nextCallback(index);
+                                });
+                        });
+                    }, angular.noop);
+                    break;
+                case 'text':
+                    // do not show confirmation, send directly
+                    contents.forEach((msg: threema.MessageData, index: number) => {
+                        msg.quote = this.webClientService.getQuote(this.receiver);
+                        // remove quote
+                        this.webClientService.setQuote(this.receiver);
+                        // send message
                         this.webClientService.sendMessage(this.$stateParams, type, msg)
+                            .then(() => {
+                                nextCallback(index);
+                            })
                             .catch((error) => {
                                 this.$log.error(error);
                                 this.showError(error);
+                                success = false;
+                                nextCallback(index);
                             });
                     });
-                }, angular.noop);
-                break;
-            case 'text':
-                // do not show confirmation, send directly
-                contents.forEach((msg) => {
-                    msg.quote = this.webClientService.getQuote(this.receiver);
-                    // remove quote
-                    this.webClientService.setQuote(this.receiver);
-                    // send message
-                    this.webClientService.sendMessage(this.$stateParams, type, msg)
-                        .catch((error) => {
-                            this.$log.error(error);
-                            this.showError(error);
-                        });
-                });
-
-                break;
-            default:
-                this.$log.warn('Invalid message type:', type);
-        }
-        return true;
+                    return;
+                default:
+                    this.$log.warn('Invalid message type:', type);
+                    reject();
+            }
+        });
     }
 
     /**

+ 2 - 0
src/services.ts

@@ -28,6 +28,7 @@ import {PushService} from './services/push';
 import {QrCodeService} from './services/qrcode';
 import {ReceiverService} from './services/receiver';
 import {StateService} from './services/state';
+import {StringService} from './services/string';
 import {TitleService} from './services/title';
 import {WebClientService} from './services/webclient';
 
@@ -50,4 +51,5 @@ angular.module('3ema.services', [])
 .service('MimeService', MimeService)
 .service('BrowserService', BrowserService)
 .service('ControllerService', ControllerService)
+.service('StringService', StringService)
 ;

+ 61 - 0
src/services/string.ts

@@ -0,0 +1,61 @@
+/**
+ * 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/>.
+ */
+
+export class StringService implements threema.StringService {
+    public byteChunk(str: string, byteLength: number, offset: number = null): string[] {
+        let chars = [...str];
+        let chunks = [''];
+        let currentChunkSize = 0;
+        let chunkIndex = 0;
+        let offsetChars = [' ', '\r', '\n', '\t', '.'];
+        let lastSeparator = -1;
+        chars.forEach ((charString: string) => {
+            let length = Buffer.byteLength(charString, 'utf8');
+            if (offset !== null) {
+                if (offsetChars.indexOf(charString) > -1) {
+                    lastSeparator = currentChunkSize + 1;
+                }
+            }
+            if (currentChunkSize + length > byteLength) {
+                let appendNewChunk = true;
+                if (lastSeparator > -1) {
+                    // check if sepeator in offset
+                    if (currentChunkSize - lastSeparator <= offset
+                        && chunks.length >= 1) {
+                        // create new chunk with existing data
+                        chunks.push(chunks[chunkIndex].substr(lastSeparator).trim());
+                        // modify old chunk
+                        chunks[chunkIndex] = chunks[chunkIndex].substr(0, lastSeparator).trim();
+                        appendNewChunk = false;
+                        currentChunkSize -= lastSeparator;
+                        chunkIndex++;
+                        lastSeparator = -1;
+                    }
+                }
+                if (appendNewChunk) {
+                    chunkIndex++;
+                    currentChunkSize = 0;
+                    // create a new chunk
+                    chunks.push('');
+                }
+            }
+            chunks[chunkIndex] = (chunks[chunkIndex]  + charString);
+            currentChunkSize += length;
+        });
+        return chunks;
+    }
+}

+ 18 - 3
src/services/webclient.ts

@@ -55,6 +55,8 @@ class WebClientDefault implements threema.WebClientDefault {
  */
 export class WebClientService implements threema.WebClientService {
     private static AVATAR_LOW_MAX_SIZE = 48;
+    private static MAX_TEXT_LENGTH = 3500;
+    private static MAX_FILE_SIZE = 15 * 1024 * 1024;
 
     private static TYPE_REQUEST = 'request';
     private static TYPE_RESPONSE = 'response';
@@ -775,13 +777,18 @@ export class WebClientService implements threema.WebClientService {
                     case 'text':
                         subType = WebClientService.SUB_TYPE_TEXT_MESSAGE;
                         // Ignore empty text messages
-                        if ((message as threema.TextMessageData).text.length === 0) {
+                        let textSize = (message as threema.TextMessageData).text.length;
+                        if (textSize === 0) {
                             return reject();
+                        } else if (textSize > WebClientService.MAX_TEXT_LENGTH) {
+                            return reject(this.$translate.instant('error.TEXT_TOO_LONG', {
+                                max: WebClientService.MAX_TEXT_LENGTH,
+                            }));
                         }
                         break;
                     case 'file':
                         // validate max file size of 20mb
-                        if ((message as threema.FileMessageData).size > 15 * 1024 * 1024) {
+                        if ((message as threema.FileMessageData).size > WebClientService.MAX_FILE_SIZE) {
                             return reject(this.$translate.instant('error.FILE_TOO_LARGE'));
                         }
                         // determine feature required feature level
@@ -858,7 +865,7 @@ export class WebClientService implements threema.WebClientService {
                 // send message and handling error promise
                 this._sendCreatePromise(subType, args, message)
                     .catch((error) => {
-                        this.$log.error('error sending file', error);
+                        this.$log.error('error sending message', error);
                         // remove temporary message
                         this.messages.removeTemporary(receiver, temporaryMessage.temporaryId);
 
@@ -1884,6 +1891,14 @@ export class WebClientService implements threema.WebClientService {
         this.blobCache.clear();
     }
 
+    /**
+     * Return the max text length
+     * @returns {number}
+     */
+    public getMaxTextLength(): number {
+        return WebClientService.MAX_TEXT_LENGTH;
+    }
+
     /**
      * Called when a new message arrives.
      */

+ 11 - 0
src/threema.d.ts

@@ -551,6 +551,7 @@ declare namespace threema {
         getDraft(receiver: Receiver): string;
         setPassword(password: string): void;
         clearCache(): void;
+        getMaxTextLength(): number;
     }
 
     interface ControllerService {
@@ -558,6 +559,16 @@ declare namespace threema {
         getControllerName(): string;
     }
 
+    interface StringService {
+        /**
+         * create chunks of a string by counting the used bytes
+         * @param str
+         * @param byteLength
+         * @param offset
+         */
+        byteChunk(str: string, byteLength: number, offset: number): string[];
+    }
+
     namespace Container {
         interface ReceiverData {
             me: MeReceiver;

+ 48 - 0
tests/helpers.js

@@ -0,0 +1,48 @@
+describe('Helpers', function () {
+    let stringService;
+
+    beforeEach(function () {
+            // Load 3ema.services
+        module('3ema.services');
+
+        // Inject the $filter function
+        inject(function (StringService) {
+            stringService = StringService;
+        });
+
+    });
+    describe('byteChunkSplit', function () {
+        this.testPatterns = (cases, size, offset) => {
+            for (let testcase of cases) {
+                const input = testcase[0];
+                const expected = testcase[1];
+                expect(stringService.byteChunk(input, size, offset)).toEqual(expected);
+            }
+        };
+
+        it('short chunks', () => {
+            this.testPatterns([
+                ['abc',
+                    ['abc',]],
+                ['abcdefghijklmn',
+                    ['abcdef', 'ghijkl', 'mn',]],
+                // four byte emoji
+                ['😅😅',
+                    ['😅', '😅']]
+            ], 6, null);
+        });
+
+
+        it('chunks with offset', () => {
+            this.testPatterns([
+                ['The quick white 🐼. He jumped over the lazy 🐶.',
+                    ['The', 'quick', 'white', '🐼.', 'He', 'jumped', 'over', 'the', 'lazy', '🐶.',]],
+            ], 6, 10);
+
+            this.testPatterns([
+                ['The quick white 🐼. He jumped over the lazy 🐶.',
+                    ['The quick white 🐼', '. He jumped over the', 'lazy 🐶.',]],
+            ], 20, 10);
+        });
+    });
+});

+ 1 - 0
tests/testsuite.html

@@ -19,6 +19,7 @@
         <script src="filters.js"></script>
         <script src="qrcode.js"></script>
         <script src="service/message.js"></script>
+        <script src="helpers.js"></script>
     </head>
     <body>
     <script>

+ 2 - 1
tslint.json

@@ -9,6 +9,7 @@
         "object-literal-shorthand": false,
         "object-literal-sort-keys": false,
         "only-arrow-functions": false,
-        "quotemark": [true, "single", "avoid-escape"]
+        "quotemark": [true, "single", "avoid-escape"],
+        "indent": [true, "spaces"]
     }
 }