Browse Source

Merge pull request #601 from threema-ch/361-pin-chats

Implement chat pinning
Danilo Bargen 6 years ago
parent
commit
7e07a88de5

+ 2 - 1
dist/package.sh

@@ -33,7 +33,7 @@ VERSION=$(grep "\"version\"" package.json  | sed 's/[[:blank:]]*\"version\": \"\
 DIR="release/threema-web-$VERSION"
 
 echo "+ Create release directory..."
-mkdir -p $DIR/{dist,partials,directives,node_modules,partials/messenger.receiver,troubleshoot}
+mkdir -p $DIR/{dist,partials,directives,components,node_modules,partials/messenger.receiver,troubleshoot}
 
 echo "+ Copy code..."
 cp -R index.html $DIR/
@@ -43,6 +43,7 @@ cp -R troubleshoot/* $DIR/troubleshoot/
 cp -R src/partials/*.html $DIR/partials/
 cp -R src/partials/messenger.receiver/*.html $DIR/partials/messenger.receiver/
 cp -R src/directives/*.html $DIR/directives/
+cp -R src/components/*.html $DIR/components/
 
 echo "+ Copy dependencies..."
 targets=(

+ 7 - 1
public/i18n/de.json

@@ -183,7 +183,13 @@
         "MUTED_MENTION_ONLY": "Nur bei Erwähnung benachrichtigen",
         "MUTED_SILENT": "Stumme Benachrichtigungen",
         "ALL": "Alle",
-        "LOADING_MESSAGES": "Nachrichten werden geladen…"
+        "LOADING_MESSAGES": "Nachrichten werden geladen…",
+        "PINNED_CONVERSATION": "Unterhaltung ist angepinnt. Klicken, um sie zu entpinnen.",
+        "UNPINNED_CONVERSATION": "Unterhaltung ist nicht angepinnt. Klicken, um sie anzupinnen.",
+        "PINNED_CONVERSATION_OK": "Unterhaltung angepinnt",
+        "PINNED_CONVERSATION_ERROR": "Unterhaltung konnte nicht angepinnt werden",
+        "UNPINNED_CONVERSATION_OK": "Unterhaltung entpinnt",
+        "UNPINNED_CONVERSATION_ERROR": "Unterhaltung konnte nicht entpinnt werden"
     },
     "messageStates": {
         "WE_ACK": "Sie haben ein Daumen-Hoch gesendet",

+ 7 - 1
public/i18n/en.json

@@ -182,7 +182,13 @@
         "MUTED_MENTION_ONLY": "Only show notification when mentioned",
         "MUTED_SILENT": "Silent notifications",
         "ALL": "All",
-        "LOADING_MESSAGES": "Loading messages…"
+        "LOADING_MESSAGES": "Loading messages…",
+        "PINNED_CONVERSATION": "Conversation is pinned. Click to unpin.",
+        "UNPINNED_CONVERSATION": "Conversation is not pinned. Click to pin.",
+        "PINNED_CONVERSATION_OK": "Conversation pinned",
+        "PINNED_CONVERSATION_ERROR": "Conversation could not be pinned",
+        "UNPINNED_CONVERSATION_OK": "Conversation unpinned",
+        "UNPINNED_CONVERSATION_ERROR": "Conversation could not be unpinned"
     },
     "messageStates": {
         "WE_ACK": "You sent thumbs-up",

+ 10 - 0
public/img/ic_pin.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg"
+  height="24"
+  width="24"
+  viewBox="0 0 24 24">
+  <path
+    fill="#000000"
+    fill-opacity="0.87"
+    d="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12Z"/>
+</svg>

+ 10 - 0
public/img/ic_unpin.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg"
+  height="24"
+  width="24"
+  viewBox="0 0 24 24">
+  <path
+    fill="#000000"
+    fill-opacity="0.87"
+    d="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12M8.8,14L10,12.8V4H14V12.8L15.2,14H8.8Z" />
+</svg>

+ 3 - 0
src/app.ts

@@ -19,6 +19,7 @@
 
 import {AsyncEvent} from 'ts-events';
 
+import './components';
 import config from './config';
 import './controllers';
 import './directives';
@@ -50,6 +51,7 @@ angular.module('3ema', [
 
     // Own
     '3ema.filters',
+    '3ema.components',
     '3ema.directives',
     '3ema.container',
     '3ema.services',
@@ -125,6 +127,7 @@ angular.module('3ema', [
             request: (conf) => {
                 if (conf.url.indexOf('partials/') !== -1 ||
                     conf.url.indexOf('directives/') !== -1 ||
+                    conf.url.indexOf('components/') !== -1 ||
                     conf.url.indexOf('i18n/') !== -1) {
                     const separator = conf.url.indexOf('?') === -1 ? '?' : '&';
                     conf.url = conf.url + separator + CACHE_BUST;

+ 22 - 0
src/components.ts

@@ -0,0 +1,22 @@
+/**
+ * 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/>.
+ */
+
+angular.module('3ema.components', []);
+
+import toggleButton from './components/toggle_button';
+
+angular.module('3ema.components').component('toggleButton', toggleButton);

+ 51 - 0
src/components/toggle_button.ts

@@ -0,0 +1,51 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * A generic toggle button.
+ *
+ * The toggle button has a boolean flag, which sets it to enabled/disabled. The
+ * caller needs to provide labels and icons for both states, as well as
+ * transition functions (onEnable and onDisable).
+ */
+export default {
+    bindings: {
+        flag: '<',
+        onEnable: '&',
+        onDisable: '&',
+        labelEnabled: '@',
+        labelDisabled: '@',
+        iconEnabled: '@',
+        iconDisabled: '@',
+    },
+    template: `
+        <md-button
+            class="md-icon-button"
+            translate-attr="{'aria-label': $ctrl.labelEnabled, 'title': $ctrl.labelEnabled}"
+            ng-if="$ctrl.flag"
+            ng-click="$ctrl.onDisable()">
+            <md-icon><img ng-src="{{ $ctrl.iconEnabled }}"></md-icon>
+        </md-button>
+        <md-button
+            class="md-icon-button"
+            translate-attr="{'aria-label': $ctrl.labelDisabled, 'title': $ctrl.labelDisabled}"
+            ng-if="!$ctrl.flag"
+            ng-click="$ctrl.onEnable()">
+            <md-icon><img ng-src="{{ $ctrl.iconDisabled }}"></md-icon>
+        </md-button>
+    `,
+};

+ 11 - 11
src/directives/latest_message.html

@@ -10,26 +10,26 @@
     </div>
 
     <!-- Left aligned message content -->
-    <div class="left no-draft no-typing no-hidden" ng-if="ctrl.message">
+    <div class="left no-draft no-typing no-hidden" ng-if="ctrl.conversation.latestMessage">
 
         <!-- If this receiver is a group, show contact name that sent last message. -->
         <span eee-message-contact
-              ng-if="ctrl.isGroup" eee-contact="ctrl.contact"></span>
+              ng-if="ctrl.isGroup" eee-contact="ctrl.getContact()"></span>
 
         <!-- For non-text-messages, show an icon. -->
         <span eee-message-icon
-              ng-show="ctrl.showIcon" class="message-icon"
-              eee-message="ctrl.message"></span>
+              ng-show="ctrl.showIcon()" class="message-icon"
+              eee-message="ctrl.conversation.latestMessage"></span>
 
         <!-- For voip status messages -->
         <eee-message-voip-status
-                ng-if="ctrl.showVoipInfo"
+                ng-if="ctrl.showVoipInfo()"
                 class="message-voip-status"
-                eee-message="ctrl.message">
+                eee-message="ctrl.conversation.latestMessage">
         </eee-message-voip-status>
 
         <!-- For text-messages, show message text excerpt. -->
-        <span eee-message-text class="message-text" eee-message="ctrl.message" multi-line="false" linkify="false"></span>
+        <span eee-message-text class="message-text" message="ctrl.conversation.latestMessage" multi-line="false" linkify="false"></span>
 
     </div>
     <div class="left hidden no-typing">
@@ -43,11 +43,11 @@
     <div class="right">
         <span class="no-draft no-hidden">
             <span eee-message-date
-                  class="message-date" eee-message="ctrl.message"></span>
+                  class="message-date" eee-message="ctrl.conversation.latestMessage"></span>
 
-            <span class="message-state" ng-show="ctrl.statusIcon">
-                 <i class="material-icons md-medium-dark md-14 {{ctrl.message.state}}">
-                     {{ ctrl.statusIcon }}
+            <span class="message-state" ng-show="ctrl.getStatusIcon()">
+                 <i class="material-icons md-medium-dark md-14 {{ctrl.conversation.latestMessage.state}}">
+                     {{ ctrl.getStatusIcon() }}
                  </i>
             </span>
         </span>

+ 38 - 45
src/directives/latest_message.ts

@@ -28,71 +28,64 @@ export default [
             restrict: 'EA',
             scope: {},
             bindToController: {
-                type: '=eeeType',
-                message: '=eeeMessage',
-                receiver: '=eeeReceiver',
+                conversation: '<conversation',
             },
             controllerAs: 'ctrl',
             controller: [function() {
                 this.$onInit = function() {
-
                     // Conversation properties
-                    this.isGroup = this.type as threema.ReceiverType === 'group';
-                    this.isDistributionList = !this.isGroup
-                        && this.type as threema.ReceiverType === 'distributionList';
+                    this.isGroup = this.conversation.type === 'group';
+                    this.isDistributionList = !this.isGroup && this.conversation.type === 'distributionList';
 
-                    this.showVoipInfo = this.message
-                        && (this.message as threema.Message).type === 'voipStatus';
+                    // Voip status
+                    this.showVoipInfo = () => this.conversation.latestMessage.type === 'voipStatus';
 
-                    if (this.showVoipInfo) {
-                        this.statusIcon = 'phone_locked';
-                    } else if (this.isGroup) {
-                        this.statusIcon = 'group';
-                    } else if (this.isDistributionList) {
-                        this.statusIcon = 'forum';
-                    } else if (!this.message.isOutbox) {
-                        this.statusIcon = 'reply';
-                    } else if (messageService.showStatusIcon(this.message, this.receiver)) {
-                        // Show status icon of incoming messages every time
-                        this.statusIcon = $filter('messageStateIcon')(this.message);
-                    } else {
-                        // Do not show a status icon
-                        this.statusIcon = null;
-                    }
+                    this.getStatusIcon = () => {
+                        if (this.showVoipInfo()) {
+                            return 'phone_locked';
+                        } else if (this.isGroup) {
+                            return 'group';
+                        } else if (this.isDistributionList) {
+                            return 'forum';
+                        } else if (!this.conversation.latestMessage.isOutbox) {
+                            return 'reply';
+                        } else if (messageService.showStatusIcon(
+                            this.conversation.latestMessage, this.conversation.receiver)
+                        ) {
+                            return $filter('messageStateIcon')(this.conversation.latestMessage);
+                        }
+                        return null;
+                    };
 
                     // Find sender of latest message
-                    this.contact = null;
-                    if (this.message) {
-                        this.contact = webClientService.contacts.get(
-                            getSenderIdentity(this.message, webClientService.me.id),
+                    this.getContact = () => {
+                        return webClientService.contacts.get(
+                            getSenderIdentity(
+                                (this.conversation as threema.Conversation).latestMessage,
+                                webClientService.me.id,
+                            ),
                         );
-                    }
+                    };
+                    const contact = this.getContact();
 
                     // Typing indicator
                     this.isTyping = () => false;
-                    if (this.isGroup === false
-                        && this.isDistributionList === false
-                        && this.contact !== null) {
-                        this.isTyping = () => {
-                            return webClientService.isTyping(this.contact);
-                        };
+                    if (this.isGroup === false && this.isDistributionList === false && contact !== null) {
+                        this.isTyping = () => webClientService.isTyping(contact);
                     }
 
-                    this.isHidden = () => {
-                        return this.receiver.locked;
-                    };
+                    this.isHidden = () => this.conversation.receiver.locked;
 
                     // Show...
-                    this.showIcon = this.message
-                        && this.message.type !== 'text'
-                        && this.message.type !== 'status';
-
-                    this.getDraft = () => {
-                        return webClientService.getDraft(this.receiver);
+                    this.showIcon = () => {
+                        const message = (this.conversation as threema.Conversation).latestMessage;
+                        return message.type !== 'text' && message.type !== 'status';
                     };
 
+                    // Drafts
+                    this.getDraft = () => webClientService.getDraft(this.conversation.receiver);
                     this.showDraft = () => {
-                        if (receiverService.isConversationActive(this.receiver)) {
+                        if (receiverService.isConversationActive(this.conversation.receiver)) {
                             // Don't show draft if conversation is active
                             return false;
                         }

+ 2 - 2
src/directives/message.html

@@ -41,7 +41,7 @@
         <eee-message-text
             ng-if="ctrl.showText"
             class="message-text"
-            eee-message="ctrl.message">
+            message="ctrl.message">
         </eee-message-text>
 
         <div class="message-info">
@@ -60,7 +60,7 @@
 <!-- Status messages -->
 <article ng-if="ctrl.isStatusMessage" class="message message-status">
     <div ng-if="ctrl.message.statusType == 'text'" class="message-body">
-        <eee-message-text class="message-text" eee-message="ctrl.message"></eee-message-text>
+        <eee-message-text class="message-text" message="ctrl.message"></eee-message-text>
     </div>
     <div ng-if="ctrl.message.statusType == 'firstUnreadMessage'" class="unread-separator">
         <div class="line"></div>

+ 48 - 22
src/directives/message_text.ts

@@ -17,37 +17,50 @@
 
 // tslint:disable:max-line-length
 
+import {hasValue} from '../helpers';
 import {WebClientService} from '../services/webclient';
 
+// Get text depending on type
+function getText(message: threema.Message): string {
+    switch (message.type) {
+        case 'text':
+            return message.body;
+        case 'location':
+            return message.location.description;
+        case 'file':
+            // Prefer caption for file messages, if available
+            if (message.caption && message.caption.length > 0) {
+                return message.caption;
+            }
+            return message.file.name;
+    }
+    return message.caption;
+}
+
 export default [
     function() {
         return {
             restrict: 'EA',
             scope: {},
             bindToController: {
-                message: '=eeeMessage',
+                message: '=',
                 multiLine: '@?multiLine',
                 linkify: '@?linkify',
             },
+            link: function(scope, elem, attrs) {
+                scope.$watch(
+                    () => scope.ctrl.message.id,
+                    (newId, oldId) => {
+                        // Register for message changes. When the ID changes, update the text.
+                        // This prevents processing the text more than once.
+                        if (hasValue(newId) && newId !== oldId) {
+                            scope.ctrl.updateText();
+                        }
+                    },
+                );
+            },
             controllerAs: 'ctrl',
             controller: ['WebClientService', '$filter', function(webClientService: WebClientService, $filter: ng.IFilterService) {
-                // Get text depending on type
-                function getText(message: threema.Message): string {
-                    switch (message.type) {
-                        case 'text':
-                            return message.body;
-                        case 'location':
-                            return message.location.description;
-                        case 'file':
-                            // Prefer caption for file messages, if available
-                            if (message.caption && message.caption.length > 0) {
-                                return message.caption;
-                            }
-                            return message.file.name;
-                    }
-                    return message.caption;
-                }
-
                 // TODO: Extract filters into helper functions
                 const escapeHtml = $filter('escapeHtml') as any;
                 const markify = $filter('markify') as any;
@@ -66,16 +79,29 @@ export default [
                     return nlToBr(maybeLinkified, multiLine);
                 }
 
-                this.enlargeSingleEmoji = webClientService.appConfig.largeSingleEmoji;
-
-                this.$onInit = function() {
+                /**
+                 * Text update function.
+                 */
+                this.updateText = () => {
                     // Because this.multiLine and this.linkify are bound using an `@` binding,
                     // they are either undefined or a string. Convert to boolean.
                     const multiLine = (this.multiLine === undefined || this.multiLine !== 'false');
                     const linkifyText = (this.linkify === undefined || this.linkify !== 'false');
 
                     // Process text once, apply all filter functions
-                    this.text = processText(getText(this.message), this.largeSingleEmoji, multiLine, linkifyText);
+                    this.text = processText(
+                        getText(this.message),
+                        this.largeSingleEmoji,
+                        multiLine,
+                        linkifyText,
+                    );
+                };
+
+                this.enlargeSingleEmoji = webClientService.appConfig.largeSingleEmoji;
+
+                this.$onInit = function() {
+                    // Process initial text
+                    this.updateText();
                 };
             }],
             template: `

+ 14 - 0
src/helpers.ts

@@ -371,3 +371,17 @@ export function isActionTrigger(ev: KeyboardEvent): boolean {
             return false;
     }
 }
+
+/*
+ * Create a shallow copy of an object.
+ */
+export function copyShallow<T extends object>(obj: T): T {
+    return Object.assign({}, obj);
+}
+
+/**
+ * Create a deep copy of an object by serializing and deserializing it.
+ */
+export function copyDeep<T extends object>(obj: T): T {
+    return JSON.parse(JSON.stringify(obj));
+}

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

@@ -6,6 +6,8 @@
 
     <div id="conversation-header" class="detail-header">
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
+
+        <!-- Conversation details -->
         <div class="header-avatar" ng-click="ctrl.showReceiver()">
             <eee-avatar eee-receiver="ctrl.receiver"
                         eee-resolution="'low'"></eee-avatar>
@@ -22,7 +24,20 @@
                 <span ng-bind-html="ctrl.receiver.members | idsToNames | escapeHtml | emojify"></span>
             </div>
         </div>
+
+        <!-- Menu -->
+        <div class="header-buttons">
+            <toggle-button
+                flag="ctrl.conversation.isStarred"
+                on-enable="ctrl.pinConversation()"
+                on-disable="ctrl.unpinConversation()"
+                label-enabled="messenger.PINNED_CONVERSATION"
+                label-disabled="messenger.UNPINNED_CONVERSATION"
+                icon-enabled="img/ic_pin.svg"
+                icon-disabled="img/ic_unpin.svg">
+        </div>
     </div>
+
     <div id="conversation-is-private" ng-if="ctrl.locked">
         <md-card>
             <md-toolbar class="md-warn">
@@ -40,7 +55,7 @@
         <ul class="chat">
             <li in-view="$inview && !ctrl.locked && ctrl.topOfChat()" class="load-more">
                 <div ng-if="ctrl.hasMoreMessages()" class="loading">
-                    <img ng-src="img/spinner.gif" alt="..." translate translate-attr-aria-label="messenger.LOADING_MESSAGES">
+                    <img src="img/spinner.gif" alt="..." translate translate-attr-aria-label="messenger.LOADING_MESSAGES">
                 </div>
             </li>
             <li ng-repeat="message in ctrl.messages" id="message-{{message.id}}">

+ 1 - 3
src/partials/messenger.navigation.html

@@ -105,9 +105,7 @@
                         <eee-latest-message
                             ng-if="!conversation.receiver.isTyping() && conversation.latestMessage"
                             ng-class="latest-message-text"
-                            eee-type="conversation.type"
-                            eee-receiver="conversation.receiver"
-                            eee-message="conversation.latestMessage"></eee-latest-message>
+                            conversation="conversation"></eee-latest-message>
                     </section>
                 </section>
 

+ 39 - 2
src/partials/messenger.ts

@@ -233,6 +233,7 @@ class ConversationController {
 
     // The conversation receiver
     public receiver: threema.Receiver;
+    public conversation: threema.Conversation;
     public type: threema.ReceiverType;
 
     // The conversation messages
@@ -342,9 +343,10 @@ class ConversationController {
             }, 100, this), supportsPassive() ? {passive: true} : false);
         }
 
-        // Set receiver and type
+        // Set receiver, conversation and type
         try {
             this.receiver = webClientService.receivers.getData({type: $stateParams.type, id: $stateParams.id});
+            this.conversation = this.webClientService.conversations.find(this.receiver);
             this.type = $stateParams.type;
 
             if (this.receiver.type === undefined) {
@@ -493,7 +495,7 @@ class ConversationController {
         this.webClientService.setQuote(this.receiver);
     }
 
-    public showError(errorMessage: string, hideDelayMs = 3000) {
+    public showError(errorMessage?: string, hideDelayMs = 3000) {
         if (errorMessage === undefined || errorMessage.length === 0) {
             errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
         }
@@ -503,6 +505,15 @@ class ConversationController {
                 .position('bottom center')
                 .hideDelay(hideDelayMs));
     }
+
+    public showMessage(msgTranslation: string, hideDelayMs = 3000) {
+        this.$mdToast.show(
+            this.$mdToast.simple()
+                .textContent(this.$translate.instant(msgTranslation))
+                .position('bottom center')
+                .hideDelay(hideDelayMs));
+    }
+
     /**
      * Submit function for input field. Can contain text or file data.
      * Return whether sending was successful.
@@ -856,6 +867,32 @@ class ConversationController {
         const chat = this.domChatElement;
         this.showScrollJump = chat.scrollHeight - (chat.scrollTop + chat.offsetHeight) > 10;
     }
+
+    /**
+     * Mark the current conversation as pinned.
+     */
+    public pinConversation(): void {
+        this.webClientService
+            .modifyConversation(this.conversation, true)
+            .then(() => this.showMessage('messenger.PINNED_CONVERSATION_OK'))
+            .catch((msg) => {
+                this.showMessage('messenger.PINNED_CONVERSATION_ERROR');
+                this.$log.error(this.logTag, 'Pinning conversation failed: ' + msg);
+            });
+    }
+
+    /**
+     * Mark the current conversation as not pinned.
+     */
+    public unpinConversation(): void {
+        this.webClientService
+            .modifyConversation(this.conversation, false)
+            .then(() => this.showMessage('messenger.UNPINNED_CONVERSATION_OK'))
+            .catch((msg) => {
+                this.showMessage('messenger.UNPINNED_CONVERSATION_ERROR');
+                this.$log.error(this.logTag, 'Unpinning conversation failed: ' + msg);
+            });
+    }
 }
 
 class NavigationController {

+ 1 - 0
src/sass/sections/_conversation.scss

@@ -14,6 +14,7 @@
         .header-details {
             @include mouse-hand;
             overflow: hidden;
+            flex-grow: 1;
 
             & > *:first-child {
                 font-weight: bold;

+ 32 - 4
src/services/webclient.ts

@@ -23,7 +23,8 @@ import {StateService as UiStateService} from '@uirouter/angularjs';
 
 import * as msgpack from 'msgpack-lite';
 import {
-    arraysAreEqual, hasFeature, hasValue, hexToU8a, msgpackVisualizer, randomString, stringToUtf8a, u8aToHex,
+    arraysAreEqual, copyDeep, hasFeature, hasValue, hexToU8a,
+    msgpackVisualizer, randomString, stringToUtf8a, u8aToHex,
 } from '../helpers';
 import {isContactReceiver, isDistributionListReceiver, isGroupReceiver, isValidReceiverType} from '../typeguards';
 import {BatteryStatusService} from './battery';
@@ -1817,6 +1818,33 @@ export class WebClientService {
         return promise;
     }
 
+    /*
+     * Modify a conversation.
+     */
+    public modifyConversation(conversation: threema.Conversation, isPinned?: boolean): Promise<null> {
+        const DATA_STARRED = 'isStarred';
+
+        // Prepare payload data
+        const args = {
+            [WebClientService.ARGUMENT_RECEIVER_TYPE]: conversation.type,
+            [WebClientService.ARGUMENT_RECEIVER_ID]: conversation.id,
+        };
+        const data = {};
+        if (hasValue(isPinned)) {
+            data[DATA_STARRED] = isPinned;
+        }
+
+        // If no changes happened, resolve the promise immediately.
+        if (Object.keys(data).length === 0) {
+            this.$log.warn(this.logTag, 'Trying to modify conversation without any changes');
+            return Promise.resolve(null);
+        }
+
+        // Send update
+        const subType = WebClientService.SUB_TYPE_CONVERSATION;
+        return this.sendUpdateWireMessage(subType, true, args, data);
+    }
+
     /**
      * Create a group receiver.
      */
@@ -2853,7 +2881,7 @@ export class WebClientService {
                 // To find out, we'll look at the unread count. If it has been
                 // incremented, it must be a new message.
                 if (data.unreadCount > 0) {
-                    const oldConversation = this.conversations.updateOrAdd(data);
+                    const oldConversation = this.conversations.updateOrAdd(data, true);
                     if (oldConversation === null) {
                         this.onNewMessage(data.latestMessage, data, receiver);
                     } else {
@@ -2871,7 +2899,7 @@ export class WebClientService {
                     }
                 } else {
                     // Update the conversation and hide any notifications
-                    this.conversations.updateOrAdd(data);
+                    this.conversations.updateOrAdd(data, false);
                     this.notificationService.hideNotification(data.type + '-' + data.id);
                 }
 
@@ -3832,7 +3860,7 @@ export class WebClientService {
         // If desired, log message type / subtype
         if (this.config.MSG_DEBUGGING) {
             // Deep copy message to prevent issues with JS debugger
-            const deepcopy = JSON.parse(JSON.stringify(message));
+            const deepcopy = copyDeep(message);
             this.$log.debug('[Message] Incoming:', message.type, '/', message.subType, deepcopy);
         }
 

+ 1 - 1
src/threema.d.ts

@@ -787,7 +787,7 @@ declare namespace threema {
             set(data: Conversation[]): void;
             find(pattern: Conversation | Receiver): Conversation | null;
             add(conversation: Conversation): void;
-            updateOrAdd(conversation: Conversation): Conversation | null;
+            updateOrAdd(conversation: Conversation, returnOld?: boolean): Conversation | null;
             remove(conversation: Conversation): void;
             setFilter(filter: (data: Conversation[]) => Conversation[]): void;
             setConverter(converter: (data: Conversation) => Conversation): void;

+ 28 - 5
src/threema/container.ts

@@ -15,6 +15,7 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {copyShallow} from '../helpers';
 import {isFirstUnreadStatusMessage} from '../message_helpers';
 import {ReceiverService} from '../services/receiver';
 
@@ -315,17 +316,39 @@ export class Conversations implements threema.Container.Conversations {
 
     /**
      * Add a conversation at the correct position.
-     * If a conversation already exists, replace it and return the old conversation.
+     * If a conversation already exists, update it and – in case returnOld is set –
+     * return a copy of the old conversation.
      */
-    public updateOrAdd(conversation: threema.ConversationWithPosition): threema.Conversation | null {
-        let replaced = null;
+    public updateOrAdd(
+        conversation: threema.ConversationWithPosition,
+        returnOld: boolean = false,
+    ): threema.Conversation | null {
         for (const i of this.conversations.keys()) {
             if (this.receiverService.compare(this.conversations[i], conversation)) {
-                replaced = this.conversations.splice(i, 1)[0];
+                // Conversation already exists!
+                // If `returnOld` is set, create a copy of the old conversation
+                let previousConversation = null;
+                if (returnOld) {
+                    previousConversation = copyShallow(this.conversations[i]);
+                }
+
+                // Explicitly set defaults, to be able to override old values
+                setDefault(conversation, 'isStarred', false);
+
+                // Copy properties from new conversation to old conversation
+                Object.assign(this.conversations[i], conversation);
+
+                // If the position changed, re-sort.
+                if (this.conversations[i].position !== i) {
+                    const tmp = this.conversations.splice(i, 1)[0];
+                    this.conversations.splice(conversation.position, 0, tmp);
+                }
+
+                return previousConversation;
             }
         }
         this.add(conversation);
-        return replaced;
+        return null;
     }
 
     /**