conversations: respond to message status changes

- Provides a UI elements that respond to states(sending, failure)
  for each outgoing message.

- Updates the message models' statuses at loading to correct
  erroneously stored data.

- Ignores the message status IDLE which is not intended
  for client use.

Change-Id: Ie6027d59ae519b96de204ba8d98bc2dd8eb9b4e4
Reviewed-by: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
diff --git a/Ring/Ring/Bridging/MessagesAdapter.h b/Ring/Ring/Bridging/MessagesAdapter.h
index 4cc6c85..5b30bc5 100644
--- a/Ring/Ring/Bridging/MessagesAdapter.h
+++ b/Ring/Ring/Bridging/MessagesAdapter.h
@@ -22,7 +22,6 @@
 
 typedef NS_ENUM(int, MessageStatus)  {
     MessageStatusUnknown = 0,
-    MessageStatusIdle,
     MessageStatusSending,
     MessageStatusSent,
     MessageStatusRead,
diff --git a/Ring/Ring/Bridging/MessagesAdapter.mm b/Ring/Ring/Bridging/MessagesAdapter.mm
index 69f90d4..29e95cd 100644
--- a/Ring/Ring/Bridging/MessagesAdapter.mm
+++ b/Ring/Ring/Bridging/MessagesAdapter.mm
@@ -59,10 +59,10 @@
     confHandlers.insert(exportable_callback<ConfigurationSignal::AccountMessageStatusChanged>([&](const std::string& account_id, uint64_t message_id, const std::string& to, int state) {
         if (MessagesAdapter.delegate) {
             NSString* fromAccountId = [NSString stringWithUTF8String:account_id.c_str()];
-            NSString* toAccount = [NSString stringWithUTF8String:to.c_str()];
+            NSString* toUri = [NSString stringWithUTF8String:to.c_str()];
             [MessagesAdapter.delegate messageStatusChanged:(MessageStatus)state
                                                        for:message_id from:fromAccountId
-                                                        to:toAccount];
+                                                        to:toUri];
         }
     }));
 
diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift
index 8eea0f5..292f5ae 100644
--- a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift
@@ -21,6 +21,7 @@
 
 import UIKit
 import Reusable
+import RxSwift
 
 class MessageCell: UITableViewCell, NibReusable {
 
@@ -33,4 +34,8 @@
     @IBOutlet weak var timeLabel: UILabel!
     @IBOutlet weak var leftDivider: UIView!
     @IBOutlet weak var rightDivider: UIView!
+    @IBOutlet weak var sendingIndicator: UIActivityIndicatorView!
+    @IBOutlet weak var failedStatusLabel: UILabel!
+
+    let disposeBag = DisposeBag()
 }
diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellGenerated.xib b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellGenerated.xib
index 84e8450..17b1311 100644
--- a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellGenerated.xib
+++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellGenerated.xib
@@ -15,7 +15,7 @@
             <rect key="frame" x="0.0" y="0.0" width="510" height="47"/>
             <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
             <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="3QB-g7-MaS" id="Dkz-SA-3Af">
-                <rect key="frame" x="0.0" y="0.0" width="510" height="46.5"/>
+                <rect key="frame" x="0.0" y="0.0" width="510" height="47"/>
                 <autoresizingMask key="autoresizingMask"/>
                 <subviews>
                     <view clipsSubviews="YES" contentMode="scaleToFill" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="xVQ-Jk-Sxy" customClass="MessageBubble" customModule="Ring" customModuleProvider="target">
diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.xib b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.xib
index 4b7eda3..8ead1fd 100644
--- a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.xib
+++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.xib
@@ -16,7 +16,7 @@
             <rect key="frame" x="0.0" y="0.0" width="510" height="47"/>
             <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
             <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
-                <rect key="frame" x="0.0" y="0.0" width="510" height="46.5"/>
+                <rect key="frame" x="0.0" y="0.0" width="510" height="47"/>
                 <autoresizingMask key="autoresizingMask"/>
                 <subviews>
                     <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="hdz-AQ-xHI" userLabel="Bottom Corner">
@@ -80,15 +80,27 @@
                         <nil key="textColor"/>
                         <nil key="highlightedColor"/>
                     </label>
+                    <activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" animating="YES" style="gray" translatesAutoresizingMaskIntoConstraints="NO" id="78h-fZ-7yf" userLabel="Sending Indicator">
+                        <rect key="frame" x="275.5" y="16" width="20" height="20"/>
+                    </activityIndicatorView>
+                    <label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Failed" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="P5a-HI-uhr" userLabel="Failed Status Label">
+                        <rect key="frame" x="253" y="16" width="42.5" height="19.5"/>
+                        <fontDescription key="fontDescription" type="system" pointSize="16"/>
+                        <color key="textColor" red="0.94117647058823528" green="0.0" blue="0.0" alpha="1" colorSpace="calibratedRGB"/>
+                        <nil key="highlightedColor"/>
+                    </label>
                 </subviews>
                 <constraints>
                     <constraint firstAttribute="bottom" secondItem="kZJ-Ay-LTR" secondAttribute="bottom" constant="8" id="1QQ-bu-6Bl" userLabel="Bubble Bottom Constraint"/>
                     <constraint firstItem="h8N-aw-5lV" firstAttribute="leading" secondItem="ogn-wv-fZy" secondAttribute="trailing" constant="16" id="1jW-JR-t5r"/>
+                    <constraint firstItem="78h-fZ-7yf" firstAttribute="trailing" secondItem="kZJ-Ay-LTR" secondAttribute="leading" constant="-8" id="4ME-jl-Uol"/>
                     <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="kZJ-Ay-LTR" secondAttribute="trailing" priority="1" constant="64" id="99Y-bR-Ioq"/>
                     <constraint firstItem="kZJ-Ay-LTR" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" priority="1" constant="16" id="Eso-cy-OYs"/>
                     <constraint firstItem="ogn-wv-fZy" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" constant="-2" id="Fxg-Wa-Rb9"/>
+                    <constraint firstItem="78h-fZ-7yf" firstAttribute="top" secondItem="kZJ-Ay-LTR" secondAttribute="top" constant="8" id="Gei-s7-aWx"/>
                     <constraint firstItem="2U4-l3-KET" firstAttribute="centerY" secondItem="ogn-wv-fZy" secondAttribute="centerY" id="J6Y-Ti-HDv"/>
                     <constraint firstItem="EMh-bG-ilg" firstAttribute="trailing" secondItem="kZJ-Ay-LTR" secondAttribute="trailing" id="MY3-Aj-94K"/>
+                    <constraint firstItem="P5a-HI-uhr" firstAttribute="top" secondItem="kZJ-Ay-LTR" secondAttribute="top" constant="8" id="O07-uI-R80"/>
                     <constraint firstItem="ogn-wv-fZy" firstAttribute="centerX" secondItem="H2p-sc-9uM" secondAttribute="centerX" id="RaG-SO-xFo"/>
                     <constraint firstAttribute="trailing" secondItem="kZJ-Ay-LTR" secondAttribute="trailing" constant="16" id="TCY-7X-mFs"/>
                     <constraint firstItem="h8N-aw-5lV" firstAttribute="centerY" secondItem="ogn-wv-fZy" secondAttribute="centerY" id="Xdu-7c-MbP"/>
@@ -99,6 +111,7 @@
                     <constraint firstItem="kZJ-Ay-LTR" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="64" id="nWe-5k-Qpn"/>
                     <constraint firstItem="2U4-l3-KET" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" constant="16" id="uoy-US-ksI"/>
                     <constraint firstItem="EMh-bG-ilg" firstAttribute="top" secondItem="kZJ-Ay-LTR" secondAttribute="top" id="zEh-jv-0Ha"/>
+                    <constraint firstItem="P5a-HI-uhr" firstAttribute="trailing" secondItem="kZJ-Ay-LTR" secondAttribute="leading" constant="-8" id="zI5-Gc-i6d"/>
                     <constraint firstItem="hdz-AQ-xHI" firstAttribute="bottom" secondItem="kZJ-Ay-LTR" secondAttribute="bottom" id="zWA-Jg-F6Q"/>
                 </constraints>
             </tableViewCellContentView>
@@ -107,9 +120,11 @@
                 <outlet property="bubble" destination="kZJ-Ay-LTR" id="hdG-fG-L69"/>
                 <outlet property="bubbleBottomConstraint" destination="1QQ-bu-6Bl" id="woo-UQ-wXK"/>
                 <outlet property="bubbleTopConstraint" destination="jhd-A8-c1o" id="cll-eA-OC5"/>
+                <outlet property="failedStatusLabel" destination="P5a-HI-uhr" id="6Sq-NU-j0d"/>
                 <outlet property="leftDivider" destination="2U4-l3-KET" id="y4j-CT-gez"/>
                 <outlet property="messageLabel" destination="lyR-7c-S2k" id="hd3-pz-Pwh"/>
                 <outlet property="rightDivider" destination="h8N-aw-5lV" id="9pc-93-BG6"/>
+                <outlet property="sendingIndicator" destination="78h-fZ-7yf" id="GrK-FT-q39"/>
                 <outlet property="timeLabel" destination="ogn-wv-fZy" id="7yt-vi-cSp"/>
                 <outlet property="topCorner" destination="EMh-bG-ilg" id="nHl-hn-BZ1"/>
             </connections>
diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift
index f84ab0b..db5d913 100644
--- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift
@@ -384,6 +384,19 @@
         } else if self.messageViewModels?.count == indexPath.row + 1 {
             cell.bubbleBottomConstraint.constant = 16
         }
+
+        if messageVM.bubblePosition() == .sent {
+            messageVM.status.asObservable()
+                .observeOn(MainScheduler.instance)
+                .map { value in value == MessageStatus.sending ? true : false }
+                .bind(to: cell.sendingIndicator.rx.isAnimating)
+                .disposed(by: disposeBag)
+            messageVM.status.asObservable()
+                .observeOn(MainScheduler.instance)
+                .map { value in value == MessageStatus.failure ? false : true }
+                .bind(to: cell.failedStatusLabel.rx.isHidden)
+                .disposed(by: disposeBag)
+        }
     }
 
 }
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift b/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift
index b3b174a..941ad9f 100644
--- a/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift
@@ -19,6 +19,7 @@
  */
 
 import RxSwift
+import SwiftyBeaver
 
 enum BubblePosition {
     case received
@@ -42,17 +43,41 @@
 
 class MessageViewModel {
 
+    fileprivate let log = SwiftyBeaver.self
+
     fileprivate let accountService: AccountsService
+    fileprivate let conversationsService: ConversationsService
     fileprivate var message: MessageModel
 
     var timeStringShown: String?
     var sequencing: MessageSequencing = .unknown
 
+    private let disposeBag = DisposeBag()
+
     init(withInjectionBag injectionBag: InjectionBag,
          withMessage message: MessageModel) {
         self.accountService = injectionBag.accountService
+        self.conversationsService = injectionBag.conversationsService
         self.message = message
         self.timeStringShown = nil
+        self.status.onNext(message.status)
+
+        // subscribe to message status updates for outgoing messages
+        self.conversationsService
+            .sharedResponseStream
+            .filter({ messageUpdateEvent in
+                let account = self.accountService.getAccount(fromAccountId: messageUpdateEvent.getEventInput(.id)!)
+                let accountHelper = AccountModelHelper(withAccount: account!)
+                return messageUpdateEvent.eventType == ServiceEventType.messageStateChanged &&
+                    messageUpdateEvent.getEventInput(.messageId) == self.message.id &&
+                    accountHelper.ringId == self.message.author
+            })
+            .subscribe(onNext: { [unowned self] messageUpdateEvent in
+                if let status: MessageStatus = messageUpdateEvent.getEventInput(.messageStatus) {
+                    self.status.onNext(status)
+                }
+            })
+            .disposed(by: self.disposeBag)
     }
 
     var content: String {
@@ -67,9 +92,7 @@
         return UInt64(self.message.id)!
     }
 
-    var status: MessageStatus {
-        return self.message.status
-    }
+    var status = BehaviorSubject<MessageStatus>(value: .unknown)
 
     func bubblePosition() -> BubblePosition {
         if self.message.isGenerated {
diff --git a/Ring/Ring/Services/ConversationsService.swift b/Ring/Ring/Services/ConversationsService.swift
index a61e297..0eff2be 100644
--- a/Ring/Ring/Services/ConversationsService.swift
+++ b/Ring/Ring/Services/ConversationsService.swift
@@ -33,6 +33,9 @@
     fileprivate let disposeBag = DisposeBag()
     fileprivate let textPlainMIMEType = "text/plain"
 
+    fileprivate let responseStream = PublishSubject<ServiceEvent>()
+    var sharedResponseStream: Observable<ServiceEvent>
+
     private var realm: Realm!
 
     fileprivate let results: Results<ConversationModel>
@@ -40,6 +43,8 @@
     var conversations: Observable<Results<ConversationModel>>
 
     init(withMessageAdapter adapter: MessagesAdapter) {
+        self.responseStream.disposed(by: disposeBag)
+        self.sharedResponseStream = responseStream.share()
 
         guard let realm = try? Realm() else {
             fatalError("Enable to instantiate Realm")
@@ -50,8 +55,31 @@
         results = realm.objects(ConversationModel.self)
 
         conversations = Observable.collection(from: results, synchronousStart: true)
+
         MessagesAdapter.delegate = self
 
+        /**
+         If the app was closed prior to messages receiving a "stable"
+         status, incorrect status values will remain in the database.
+         Get updated message status from the daemon for each
+         message as conversations are loaded from the database.
+         Only sent messages having an 'unknown' or 'sending' status
+         are considered for updating.
+         */
+        for conversation in results.toArray() {
+            for message in (conversation.messages) {
+                if message.id != "" && (message.status == .unknown || message.status == .sending ) {
+                    let updatedMessageStatus = self.status(forMessageId: message.id)
+                    if updatedMessageStatus != message.status {
+                        self.setMessageStatus(withMessage: message, withStatus: updatedMessageStatus)
+                            .subscribe(onCompleted: { [] in
+                                print("Message status updated - load")
+                            })
+                            .disposed(by: self.disposeBag)
+                    }
+                }
+            }
+        }
     }
 
     func sendMessage(withContent content: String,
@@ -142,8 +170,8 @@
         })
     }
 
-    func status(forMessageId messageId: UInt64) -> MessageStatus {
-        return self.messageAdapter.status(forMessageId: messageId)
+    func status(forMessageId messageId: String) -> MessageStatus {
+        return self.messageAdapter.status(forMessageId: UInt64(messageId)!)
     }
 
     func setMessagesAsRead(forConversation conversation: ConversationModel) -> Completable {
@@ -172,6 +200,24 @@
         })
     }
 
+    func setMessageStatus(withMessage message: MessageModel,
+                          withStatus status: MessageStatus) -> Completable {
+
+        return Completable.create(subscribe: { [unowned self] completable in
+            do {
+                try self.realm.write {
+                    message.status = status
+                }
+                completable(.completed)
+
+            } catch let error {
+                self.log.error("\(error)")
+            }
+
+            return Disposables.create { }
+        })
+    }
+
     func deleteConversation(conversation: ConversationModel) {
 
         do {
@@ -211,8 +257,33 @@
 
     func messageStatusChanged(_ status: MessageStatus,
                               for messageId: UInt64,
-                              from senderAccountId: String,
-                              to receiverAccount: String) {
-        log.debug("messageStatusChanged: \(status.rawValue) for: \(messageId) from: \(senderAccountId) to: \(receiverAccount)")
+                              from accountId: String,
+                              to uri: String) {
+
+        //Get conversations for this sender
+        let conversation = self.results.filter({ conversation in
+            return conversation.recipientRingId == uri &&
+                conversation.accountId == accountId
+        }).first
+
+        //Find message
+        if let message = conversation?.messages.filter({ messages in
+            return !messages.id.isEmpty && messages.id == String(messageId) && messages.status != status
+        }).first {
+            self.setMessageStatus(withMessage: message,
+                                  withStatus: status)
+                .subscribe(onCompleted: { [unowned self] in
+                    self.log.info("Message status updated")
+                    var event = ServiceEvent(withEventType: .messageStateChanged)
+                    event.addEventInput(.messageStatus, value: status)
+                    event.addEventInput(.messageId, value: String(messageId))
+                    event.addEventInput(.id, value: accountId)
+                    event.addEventInput(.uri, value: uri)
+                    self.responseStream.onNext(event)
+                })
+                .disposed(by: disposeBag)
+        }
+
+        log.debug("messageStatusChanged: \(status.rawValue) for: \(messageId) from: \(accountId) to: \(uri)")
     }
 }
diff --git a/Ring/Ring/Services/MessagesAdapterDelegate.swift b/Ring/Ring/Services/MessagesAdapterDelegate.swift
index 22ba215..f7419ce 100644
--- a/Ring/Ring/Services/MessagesAdapterDelegate.swift
+++ b/Ring/Ring/Services/MessagesAdapterDelegate.swift
@@ -23,6 +23,6 @@
     func didReceiveMessage(_ message: [String: String], from senderAccount: String,
                            to receiverAccountId: String)
 
-    func messageStatusChanged(_ status: MessageStatus, for messageId: UInt64, from senderAccountId: String,
-                              to receiverAccount: String)
+    func messageStatusChanged(_ status: MessageStatus, for messageId: UInt64, from accountId: String,
+                              to uri: String)
 }
diff --git a/Ring/Ring/Services/ServiceEvent.swift b/Ring/Ring/Services/ServiceEvent.swift
index 836d4b7..1f6150f 100644
--- a/Ring/Ring/Services/ServiceEvent.swift
+++ b/Ring/Ring/Services/ServiceEvent.swift
@@ -29,6 +29,7 @@
     case accountsChanged
     case registrationStateChanged
     case presenceUpdated
+    case messageStateChanged
 }
 
 /**
@@ -40,6 +41,8 @@
     case registrationState
     case uri
     case presenceStatus
+    case messageStatus
+    case messageId
 }
 
 /**