From d350c48e7f6141a5bf4cdb5dfa91349c63c936e5 Mon Sep 17 00:00:00 2001
From: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
Date: Fri, 6 Oct 2017 15:41:46 -0400
Subject: [PATCH] conversations: implement message grouping

- Calculates the grouping property of each message, determining
  whether it's first, middle, or last within a sequence either
  sent or received.

- Adjusts the top and bottom constraints for each message bubble
  according to its sequencing.

- Fixes the scroll-to-bottom feature by updating the bottoOffset
  value before conditionally scrolling, removing the scroll
  animation, and only scrolling down when the user is near the
  end of the chat.

- Applies a message bubble grouping style, adjusts line spacing
  of the content, and decouples the theming of the message
  bubble and text colors.

Change-Id: I9118c2bbca0433573c877450c73bd6dc5c9229a0
Reviewed-by: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
---
 Ring/Ring.xcodeproj/project.pbxproj           |   8 ++
 Ring/Ring/AppDelegate.swift                   |   2 +
 Ring/Ring/Extensions/Chameleon+Ring.swift     |  11 +-
 Ring/Ring/Extensions/UIColor+Ring.swift       |  20 +++
 Ring/Ring/Extensions/UILabel+Ring.swift       |  33 +++++
 .../Conversation/Cells/MessageCell.swift      |  33 +++++
 .../Cells/MessageCellReceived.swift           |   6 +-
 .../Cells/MessageCellReceived.xib             |  51 +++++--
 .../Conversation/Cells/MessageCellSent.swift  |   6 +-
 .../Conversation/Cells/MessageCellSent.xib    |  51 +++++--
 .../ConversationViewController.swift          | 133 ++++++++++++++++--
 11 files changed, 300 insertions(+), 54 deletions(-)
 create mode 100644 Ring/Ring/Extensions/UILabel+Ring.swift
 create mode 100644 Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift

diff --git a/Ring/Ring.xcodeproj/project.pbxproj b/Ring/Ring.xcodeproj/project.pbxproj
index aa7323ab8..f0d96eac5 100644
--- a/Ring/Ring.xcodeproj/project.pbxproj
+++ b/Ring/Ring.xcodeproj/project.pbxproj
@@ -186,6 +186,8 @@
 		56BBC9D41EDC7A6D00CDAF8B /* libargon2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 56BBC9D31EDC7A6D00CDAF8B /* libargon2.a */; };
 		56BBC9DF1EDDC9D300CDAF8B /* LookupNameResponse.m in Sources */ = {isa = PBXBuildFile; fileRef = 56BBC9DE1EDDC9D300CDAF8B /* LookupNameResponse.m */; };
 		56C715FF1F0D36C600770048 /* ContactsAdapter.mm in Sources */ = {isa = PBXBuildFile; fileRef = 56C715FE1F0D36C600770048 /* ContactsAdapter.mm */; };
+		621231F91F880EDF009B86F0 /* UILabel+Ring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621231F81F880EDF009B86F0 /* UILabel+Ring.swift */; };
+		621231FB1F8D6FEE009B86F0 /* MessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621231FA1F8D6FEE009B86F0 /* MessageCell.swift */; };
 		62A88D371F6C2ED400F8AB18 /* PresenceAdapterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62A88D361F6C2ED400F8AB18 /* PresenceAdapterDelegate.swift */; };
 		62A88D391F6C323500F8AB18 /* PresenceAdapter.mm in Sources */ = {isa = PBXBuildFile; fileRef = 62A88D381F6C323500F8AB18 /* PresenceAdapter.mm */; };
 		62A88D3B1F6C3ACC00F8AB18 /* PresenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62A88D3A1F6C3ACC00F8AB18 /* PresenceService.swift */; };
@@ -417,6 +419,8 @@
 		56C715FE1F0D36C600770048 /* ContactsAdapter.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ContactsAdapter.mm; sourceTree = "<group>"; };
 		56C716001F0D36D900770048 /* ContactsAdapterDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactsAdapterDelegate.swift; sourceTree = "<group>"; };
 		56C716021F0D466100770048 /* ContactsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactsService.swift; sourceTree = "<group>"; };
+		621231F81F880EDF009B86F0 /* UILabel+Ring.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UILabel+Ring.swift"; sourceTree = "<group>"; };
+		621231FA1F8D6FEE009B86F0 /* MessageCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCell.swift; sourceTree = "<group>"; };
 		62A88D351F6C2E5F00F8AB18 /* PresenceAdapter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PresenceAdapter.h; sourceTree = "<group>"; };
 		62A88D361F6C2ED400F8AB18 /* PresenceAdapterDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresenceAdapterDelegate.swift; sourceTree = "<group>"; };
 		62A88D381F6C323500F8AB18 /* PresenceAdapter.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = PresenceAdapter.mm; sourceTree = "<group>"; };
@@ -633,6 +637,7 @@
 				1A2D18A51F27F7A400B2C785 /* UIViewController+Rx.swift */,
 				0586C94A1F684DF600613517 /* UIImage+Helpers.swift */,
 				0EE1B54D1F75ACDE00BA98EE /* CNContactVCardSerialization+Helpers.swift */,
+				621231F81F880EDF009B86F0 /* UILabel+Ring.swift */,
 			);
 			path = Extensions;
 			sourceTree = "<group>";
@@ -957,6 +962,7 @@
 				1A2D18F11F292D7200B2C785 /* MessageCellReceived.swift */,
 				1A2D18F21F292D7200B2C785 /* MessageCellReceived.xib */,
 				1A2D18F31F292D7200B2C785 /* MessageCellSent.swift */,
+				621231FA1F8D6FEE009B86F0 /* MessageCell.swift */,
 				1A2D18F41F292D7200B2C785 /* MessageCellSent.xib */,
 				0E403F801F7D797300C80BC2 /* MessageCellGenerated.swift */,
 				0E403F821F7D79B000C80BC2 /* MessageCellGenerated.xib */,
@@ -1274,6 +1280,7 @@
 			files = (
 				557086521E8ADB9D001A7CE4 /* SystemAdapter.mm in Sources */,
 				0586C94B1F684DF600613517 /* UIImage+Helpers.swift in Sources */,
+				621231F91F880EDF009B86F0 /* UILabel+Ring.swift in Sources */,
 				1A2D18AC1F29149D00B2C785 /* MeCoordinator.swift in Sources */,
 				1A2D18C51F29180700B2C785 /* ContactModel.swift in Sources */,
 				1A2D18F71F292D7200B2C785 /* MessageCellSent.swift in Sources */,
@@ -1310,6 +1317,7 @@
 				56308BA71EA00E5700660275 /* NameRegistrationResponse.m in Sources */,
 				1A3CA32D1F13DA7200283748 /* Chameleon+Ring.swift in Sources */,
 				1ABE07E21F0D924700D36361 /* Strings.swift in Sources */,
+				621231FB1F8D6FEE009B86F0 /* MessageCell.swift in Sources */,
 				56AC650E1E85694D00EA1AA9 /* DesignableTextField.swift in Sources */,
 				1A2D189A1F2642C000B2C785 /* NotificationCenter+Ring.swift in Sources */,
 				1A2D18FC1F292DAD00B2C785 /* ConversationCell.swift in Sources */,
diff --git a/Ring/Ring/AppDelegate.swift b/Ring/Ring/AppDelegate.swift
index 6deb2d783..57f30ffbf 100644
--- a/Ring/Ring/AppDelegate.swift
+++ b/Ring/Ring/AppDelegate.swift
@@ -58,6 +58,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
 
         self.window = UIWindow(frame: UIScreen.main.bounds)
 
+        UserDefaults.standard.setValue(false, forKey: "_UIConstraintBasedLayoutLogUnsatisfiable")
+
         // initialize log format
         let console = ConsoleDestination()
         console.format = "$Dyyyy-MM-dd HH:mm:ss.SSS$d $C$L$c: $M"
diff --git a/Ring/Ring/Extensions/Chameleon+Ring.swift b/Ring/Ring/Extensions/Chameleon+Ring.swift
index 0146a074c..f3961e46b 100644
--- a/Ring/Ring/Extensions/Chameleon+Ring.swift
+++ b/Ring/Ring/Extensions/Chameleon+Ring.swift
@@ -31,15 +31,12 @@ extension Chameleon {
         case .contrast:
             contentColor = ContrastColorOf(primaryColor, returnFlat: false)
             secondaryContentColor = ContrastColorOf(secondaryColor, returnFlat: false)
-            break
         case .light:
             contentColor = UIColor.white
             secondaryContentColor = UIColor.white
-            break
         case .dark:
             contentColor = UIColor.flatBlackColorDark()
             secondaryContentColor = UIColor.flatBlackColorDark()
-            break
         }
 
         UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).tintColor = UIColor.flatGray()
@@ -48,12 +45,12 @@ extension Chameleon {
         MessageBubble.appearance().backgroundColor = secondaryColor
 
         MessageBubble.appearance(whenContainedInInstancesOf: [MessageCellSent.self]).tintColor = contentColor
-        MessageBubble.appearance(whenContainedInInstancesOf: [MessageCellSent.self]).backgroundColor = primaryColor
-        UILabel.appearance(whenContainedInInstancesOf: [MessageBubble.self, MessageCellSent.self]).textColor = contentColor
+        MessageBubble.appearance(whenContainedInInstancesOf: [MessageCellSent.self]).backgroundColor = UIColor.ringMsgCellSent
+        UILabel.appearance(whenContainedInInstancesOf: [MessageBubble.self, MessageCellSent.self]).textColor = UIColor.ringMsgCellSentText
 
         MessageBubble.appearance(whenContainedInInstancesOf: [MessageCellReceived.self]).tintColor = secondaryContentColor
-        MessageBubble.appearance(whenContainedInInstancesOf: [MessageCellReceived.self]).backgroundColor = secondaryColor
-        UILabel.appearance(whenContainedInInstancesOf: [MessageBubble.self, MessageCellReceived.self]).textColor = secondaryContentColor
+        MessageBubble.appearance(whenContainedInInstancesOf: [MessageCellReceived.self]).backgroundColor = UIColor.ringMsgCellReceived
+        UILabel.appearance(whenContainedInInstancesOf: [MessageBubble.self, MessageCellReceived.self]).textColor = UIColor.ringMsgCellReceivedText
 
         MessageBubble.appearance(whenContainedInInstancesOf: [MessageCellGenerated.self]).tintColor = UIColor.clear
         MessageBubble.appearance(whenContainedInInstancesOf: [MessageCellGenerated.self]).backgroundColor = UIColor.clear
diff --git a/Ring/Ring/Extensions/UIColor+Ring.swift b/Ring/Ring/Extensions/UIColor+Ring.swift
index 27d831c54..ae8892977 100644
--- a/Ring/Ring/Extensions/UIColor+Ring.swift
+++ b/Ring/Ring/Extensions/UIColor+Ring.swift
@@ -33,4 +33,24 @@ extension UIColor {
                                   blue: 96.0/255.0,
                                   alpha: 1.0)
 
+    static let ringMsgCellSent = UIColor(colorLiteralRed: 58.0/255.0,
+                                           green: 192.0/255.0,
+                                           blue: 210.0/255.0,
+                                           alpha: 1.0)
+
+    static let ringMsgCellSentText = UIColor(colorLiteralRed: 255.0/255.0,
+                                         green: 255.0/255.0,
+                                         blue: 255.0/255.0,
+                                         alpha: 1.0)
+
+    static let ringMsgCellReceived = UIColor(colorLiteralRed: 235.0/255.0,
+                                       green: 239.0/255.0,
+                                       blue: 239.0/255.0,
+                                       alpha: 1.0)
+
+    static let ringMsgCellReceivedText = UIColor(colorLiteralRed: 48.0/255.0,
+                                         green: 48.0/255.0,
+                                         blue: 48.0/255.0,
+                                         alpha: 1.0)
+
 }
diff --git a/Ring/Ring/Extensions/UILabel+Ring.swift b/Ring/Ring/Extensions/UILabel+Ring.swift
new file mode 100644
index 000000000..106c380a2
--- /dev/null
+++ b/Ring/Ring/Extensions/UILabel+Ring.swift
@@ -0,0 +1,33 @@
+/*
+ *  Copyright (C) 2016 Savoir-faire Linux Inc.
+ *
+ *  Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
+ *
+ *  This program is free software; you can redistribute it and/or modify
+ *  it under the terms of the GNU 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 General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with this program; if not, write to the Free Software
+ *  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301 USA.
+ */
+
+import Foundation
+
+extension UILabel {
+    func setTextWithLineSpacing(withText: String, withLineSpacing: CGFloat) {
+        let attrString = NSMutableAttributedString(string: withText)
+        let style = NSMutableParagraphStyle()
+        style.lineSpacing = withLineSpacing
+        attrString.addAttribute(NSParagraphStyleAttributeName,
+                                value: style,
+                                range: NSRange(location: 0, length: withText.utf16.count))
+        self.attributedText = attrString
+    }
+}
diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift
new file mode 100644
index 000000000..fa63c0302
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCell.swift
@@ -0,0 +1,33 @@
+/*
+ *  Copyright (C) 2017 Savoir-faire Linux Inc.
+ *
+ *  Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com>
+ *  Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
+ *
+ *  This program is free software; you can redistribute it and/or modify
+ *  it under the terms of the GNU 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 General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with this program; if not, write to the Free Software
+ *  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301 USA.
+ */
+
+import UIKit
+import Reusable
+
+class MessageCell: UITableViewCell, NibReusable {
+
+    @IBOutlet weak var bubble: MessageBubble!
+    @IBOutlet weak var bubbleBottomConstraint: NSLayoutConstraint!
+    @IBOutlet weak var bubbleTopConstraint: NSLayoutConstraint!
+    @IBOutlet weak var messageLabel: UILabel!
+    @IBOutlet weak var bottomCorner: UIView!
+    @IBOutlet weak var topCorner: UIView!
+}
diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellReceived.swift b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellReceived.swift
index 0b42e9a7c..c504acdef 100644
--- a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellReceived.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellReceived.swift
@@ -21,9 +21,5 @@
 import UIKit
 import Reusable
 
-class MessageCellReceived: UITableViewCell, NibReusable {
-
-    @IBOutlet weak var bubble: MessageBubble!
-    @IBOutlet weak var messageLabel: UILabel!
-
+class MessageCellReceived: MessageCell {
 }
diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellReceived.xib b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellReceived.xib
index 679b0e0e6..b381a8be6 100644
--- a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellReceived.xib
+++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellReceived.xib
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="12121" systemVersion="16E195" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
     <device id="retina4_7" orientation="portrait">
         <adaptation id="fullscreen"/>
     </device>
@@ -15,48 +15,73 @@
             <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="WBd-CS-7Qv" userLabel="Top Corner">
+                        <rect key="frame" x="16" y="8" width="15" height="15"/>
+                        <color key="backgroundColor" red="1" green="0.0" blue="1" alpha="1" colorSpace="calibratedRGB"/>
+                        <constraints>
+                            <constraint firstAttribute="height" constant="15" id="fjJ-O1-VNm"/>
+                            <constraint firstAttribute="width" constant="15" id="gch-Wg-ytg"/>
+                        </constraints>
+                    </view>
+                    <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="XcL-CH-BiH" userLabel="Bottom Corner">
+                        <rect key="frame" x="16" y="24" width="15" height="15"/>
+                        <color key="backgroundColor" red="1" green="0.5" blue="0.0" alpha="1" colorSpace="calibratedRGB"/>
+                        <constraints>
+                            <constraint firstAttribute="width" constant="15" id="ocR-DU-zKZ"/>
+                            <constraint firstAttribute="height" constant="15" id="ooc-tv-fiO"/>
+                        </constraints>
+                    </view>
                     <view clipsSubviews="YES" contentMode="scaleToFill" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="kZJ-Ay-LTR" customClass="MessageBubble" customModule="Ring" customModuleProvider="target">
-                        <rect key="frame" x="16" y="8" width="152.5" height="30.5"/>
+                        <rect key="frame" x="16" y="8" width="190.5" height="30.5"/>
                         <subviews>
-                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label Label Label Label " lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lyR-7c-S2k">
-                                <rect key="frame" x="8" y="4" width="136.5" height="22.5"/>
-                                <fontDescription key="fontDescription" type="system" pointSize="12"/>
-                                <nil key="textColor"/>
+                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label Label Label Label" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lyR-7c-S2k">
+                                <rect key="frame" x="10" y="8" width="170.5" height="14.5"/>
+                                <fontDescription key="fontDescription" type="system" pointSize="16"/>
+                                <color key="textColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
                                 <nil key="highlightedColor"/>
                             </label>
                         </subviews>
                         <color key="backgroundColor" red="0.66666666666666663" green="0.66666666666666663" blue="0.66666666666666663" alpha="1" colorSpace="calibratedRGB"/>
                         <constraints>
                             <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="30" id="1Kj-UZ-gu7"/>
-                            <constraint firstItem="lyR-7c-S2k" firstAttribute="leading" secondItem="kZJ-Ay-LTR" secondAttribute="leading" constant="8" id="8m5-sR-xnh"/>
-                            <constraint firstAttribute="bottom" secondItem="lyR-7c-S2k" secondAttribute="bottom" constant="4" id="gwN-uX-PWd"/>
-                            <constraint firstAttribute="trailing" secondItem="lyR-7c-S2k" secondAttribute="trailing" constant="8" id="uzV-kG-oGN"/>
-                            <constraint firstItem="lyR-7c-S2k" firstAttribute="top" secondItem="kZJ-Ay-LTR" secondAttribute="top" constant="4" id="ycc-WI-Jk6"/>
+                            <constraint firstItem="lyR-7c-S2k" firstAttribute="leading" secondItem="kZJ-Ay-LTR" secondAttribute="leading" constant="10" id="8m5-sR-xnh"/>
+                            <constraint firstAttribute="width" relation="greaterThanOrEqual" constant="28" id="UWN-H4-Sh9"/>
+                            <constraint firstAttribute="bottom" secondItem="lyR-7c-S2k" secondAttribute="bottom" constant="8" id="gwN-uX-PWd"/>
+                            <constraint firstAttribute="trailing" secondItem="lyR-7c-S2k" secondAttribute="trailing" constant="10" id="uzV-kG-oGN"/>
+                            <constraint firstItem="lyR-7c-S2k" firstAttribute="top" secondItem="kZJ-Ay-LTR" secondAttribute="top" constant="8" id="ycc-WI-Jk6"/>
                         </constraints>
                         <userDefinedRuntimeAttributes>
                             <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius">
-                                <real key="value" value="4"/>
+                                <integer key="value" value="15"/>
                             </userDefinedRuntimeAttribute>
                         </userDefinedRuntimeAttributes>
                     </view>
                 </subviews>
                 <constraints>
                     <constraint firstAttribute="bottom" secondItem="kZJ-Ay-LTR" secondAttribute="bottom" constant="8" id="1QQ-bu-6Bl"/>
+                    <constraint firstItem="XcL-CH-BiH" firstAttribute="bottom" secondItem="kZJ-Ay-LTR" secondAttribute="bottom" id="2d4-0F-VWg"/>
+                    <constraint firstItem="WBd-CS-7Qv" firstAttribute="top" secondItem="kZJ-Ay-LTR" secondAttribute="top" id="4Zp-8q-rFJ"/>
                     <constraint firstAttribute="trailing" secondItem="kZJ-Ay-LTR" secondAttribute="trailing" priority="1" constant="16" id="99Y-bR-Ioq"/>
                     <constraint firstItem="kZJ-Ay-LTR" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="H2p-sc-9uM" secondAttribute="leading" priority="1" constant="64" id="Eso-cy-OYs"/>
+                    <constraint firstItem="XcL-CH-BiH" firstAttribute="leading" secondItem="kZJ-Ay-LTR" secondAttribute="leading" id="GaI-yj-QFt"/>
                     <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="kZJ-Ay-LTR" secondAttribute="trailing" constant="64" id="TCY-7X-mFs"/>
                     <constraint firstItem="kZJ-Ay-LTR" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="jhd-A8-c1o"/>
                     <constraint firstItem="kZJ-Ay-LTR" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="nWe-5k-Qpn"/>
+                    <constraint firstItem="WBd-CS-7Qv" firstAttribute="leading" secondItem="kZJ-Ay-LTR" secondAttribute="leading" id="yBG-sT-w2a"/>
                 </constraints>
             </tableViewCellContentView>
             <connections>
+                <outlet property="bottomCorner" destination="XcL-CH-BiH" id="4gw-IC-EAM"/>
                 <outlet property="bubble" destination="kZJ-Ay-LTR" id="hdG-fG-L69"/>
+                <outlet property="bubbleBottomConstraint" destination="1QQ-bu-6Bl" id="a4F-pf-cXL"/>
+                <outlet property="bubbleTopConstraint" destination="jhd-A8-c1o" id="40k-2d-6rW"/>
                 <outlet property="messageLabel" destination="lyR-7c-S2k" id="hd3-pz-Pwh"/>
+                <outlet property="topCorner" destination="WBd-CS-7Qv" id="GCm-Hv-5Ei"/>
             </connections>
-            <point key="canvasLocation" x="-411" y="-132"/>
+            <point key="canvasLocation" x="-411" y="-132.5"/>
         </tableViewCell>
     </objects>
 </document>
diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.swift b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.swift
index 336836d6f..7d5d96a72 100644
--- a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.swift
@@ -21,9 +21,5 @@
 import UIKit
 import Reusable
 
-class MessageCellSent: UITableViewCell, NibReusable {
-
-    @IBOutlet weak var bubble: MessageBubble!
-    @IBOutlet weak var messageLabel: UILabel!
-
+class MessageCellSent: MessageCell {
 }
diff --git a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.xib b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.xib
index 04dc0efe4..41e65e646 100644
--- a/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.xib
+++ b/Ring/Ring/Features/Conversations/Conversation/Cells/MessageCellSent.xib
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="12121" systemVersion="16F73" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
     <device id="retina4_7" orientation="portrait">
         <adaptation id="fullscreen"/>
     </device>
@@ -18,45 +18,70 @@
                 <rect key="frame" x="0.0" y="0.0" width="510" height="46.5"/>
                 <autoresizingMask key="autoresizingMask"/>
                 <subviews>
+                    <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="hdz-AQ-xHI" userLabel="Bottom Corner">
+                        <rect key="frame" x="479" y="24" width="15" height="15"/>
+                        <color key="backgroundColor" red="1" green="0.5" blue="0.0" alpha="1" colorSpace="calibratedRGB"/>
+                        <constraints>
+                            <constraint firstAttribute="height" constant="15" id="D0h-cW-9kB"/>
+                            <constraint firstAttribute="width" constant="15" id="wlh-ar-Nsv"/>
+                        </constraints>
+                    </view>
+                    <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="EMh-bG-ilg" userLabel="Top Corner">
+                        <rect key="frame" x="479" y="8" width="15" height="15"/>
+                        <color key="backgroundColor" red="1" green="0.0" blue="1" alpha="1" colorSpace="calibratedRGB"/>
+                        <constraints>
+                            <constraint firstAttribute="width" constant="15" id="zaa-Rn-ziw"/>
+                            <constraint firstAttribute="height" constant="15" id="zuP-4P-1GS"/>
+                        </constraints>
+                    </view>
                     <view clipsSubviews="YES" contentMode="scaleToFill" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="kZJ-Ay-LTR" customClass="MessageBubble" customModule="Ring" customModuleProvider="target">
-                        <rect key="frame" x="341.5" y="8" width="152.5" height="30.5"/>
+                        <rect key="frame" x="303.5" y="8" width="190.5" height="31"/>
                         <subviews>
-                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label Label Label Label " lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lyR-7c-S2k">
-                                <rect key="frame" x="8" y="4" width="136.5" height="22.5"/>
-                                <fontDescription key="fontDescription" type="system" pointSize="12"/>
-                                <nil key="textColor"/>
+                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label Label Label Label" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lyR-7c-S2k">
+                                <rect key="frame" x="10" y="8" width="170.5" height="15"/>
+                                <fontDescription key="fontDescription" name=".AppleSystemUIFont" family=".AppleSystemUIFont" pointSize="16"/>
+                                <color key="textColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
                                 <nil key="highlightedColor"/>
                             </label>
                         </subviews>
                         <color key="backgroundColor" red="0.66666666666666663" green="0.66666666666666663" blue="0.66666666666666663" alpha="1" colorSpace="calibratedRGB"/>
                         <constraints>
                             <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="30" id="1Kj-UZ-gu7"/>
-                            <constraint firstItem="lyR-7c-S2k" firstAttribute="leading" secondItem="kZJ-Ay-LTR" secondAttribute="leading" constant="8" id="8m5-sR-xnh"/>
-                            <constraint firstAttribute="bottom" secondItem="lyR-7c-S2k" secondAttribute="bottom" constant="4" id="gwN-uX-PWd"/>
-                            <constraint firstAttribute="trailing" secondItem="lyR-7c-S2k" secondAttribute="trailing" constant="8" id="uzV-kG-oGN"/>
-                            <constraint firstItem="lyR-7c-S2k" firstAttribute="top" secondItem="kZJ-Ay-LTR" secondAttribute="top" constant="4" id="ycc-WI-Jk6"/>
+                            <constraint firstItem="lyR-7c-S2k" firstAttribute="leading" secondItem="kZJ-Ay-LTR" secondAttribute="leading" constant="10" id="8m5-sR-xnh"/>
+                            <constraint firstAttribute="width" relation="greaterThanOrEqual" constant="28" id="BZE-kP-hPK"/>
+                            <constraint firstAttribute="bottom" secondItem="lyR-7c-S2k" secondAttribute="bottom" constant="8" id="gwN-uX-PWd"/>
+                            <constraint firstAttribute="trailing" secondItem="lyR-7c-S2k" secondAttribute="trailing" constant="10" id="uzV-kG-oGN"/>
+                            <constraint firstItem="lyR-7c-S2k" firstAttribute="top" secondItem="kZJ-Ay-LTR" secondAttribute="top" constant="8" id="ycc-WI-Jk6"/>
                         </constraints>
                         <userDefinedRuntimeAttributes>
                             <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius">
-                                <real key="value" value="4"/>
+                                <integer key="value" value="15"/>
                             </userDefinedRuntimeAttribute>
                         </userDefinedRuntimeAttributes>
                     </view>
                 </subviews>
                 <constraints>
-                    <constraint firstAttribute="bottom" secondItem="kZJ-Ay-LTR" secondAttribute="bottom" constant="8" id="1QQ-bu-6Bl"/>
+                    <constraint firstAttribute="bottom" secondItem="kZJ-Ay-LTR" secondAttribute="bottom" constant="8" id="1QQ-bu-6Bl" userLabel="Bubble Bottom Constraint"/>
                     <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="EMh-bG-ilg" firstAttribute="trailing" secondItem="kZJ-Ay-LTR" secondAttribute="trailing" id="MY3-Aj-94K"/>
                     <constraint firstAttribute="trailing" secondItem="kZJ-Ay-LTR" secondAttribute="trailing" constant="16" id="TCY-7X-mFs"/>
                     <constraint firstItem="kZJ-Ay-LTR" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="jhd-A8-c1o"/>
+                    <constraint firstItem="hdz-AQ-xHI" firstAttribute="trailing" secondItem="kZJ-Ay-LTR" secondAttribute="trailing" id="lSl-vu-Wkl"/>
                     <constraint firstItem="kZJ-Ay-LTR" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="64" id="nWe-5k-Qpn"/>
+                    <constraint firstItem="EMh-bG-ilg" firstAttribute="top" secondItem="kZJ-Ay-LTR" secondAttribute="top" id="zEh-jv-0Ha"/>
+                    <constraint firstItem="hdz-AQ-xHI" firstAttribute="bottom" secondItem="kZJ-Ay-LTR" secondAttribute="bottom" id="zWA-Jg-F6Q"/>
                 </constraints>
             </tableViewCellContentView>
             <connections>
+                <outlet property="bottomCorner" destination="hdz-AQ-xHI" id="ChE-BT-0LS"/>
                 <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="messageLabel" destination="lyR-7c-S2k" id="hd3-pz-Pwh"/>
+                <outlet property="topCorner" destination="EMh-bG-ilg" id="nHl-hn-BZ1"/>
             </connections>
-            <point key="canvasLocation" x="-411" y="-132"/>
+            <point key="canvasLocation" x="-411" y="-132.5"/>
         </tableViewCell>
     </objects>
 </document>
diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift
index bf31333fc..c938de3ed 100644
--- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift
@@ -21,9 +21,20 @@
 import UIKit
 import RxSwift
 import Reusable
+import SwiftyBeaver
+
+enum BubbleChaining {
+    case singleMessage
+    case firstOfSequence
+    case lastOfSequence
+    case middleOfSequence
+    case error
+}
 
 class ConversationViewController: UIViewController, UITextFieldDelegate, StoryboardBased, ViewModelBased {
 
+    let log = SwiftyBeaver.self
+
     @IBOutlet weak var tableView: UITableView!
     @IBOutlet weak var spinnerView: UIView!
 
@@ -33,6 +44,7 @@ class ConversationViewController: UIViewController, UITextFieldDelegate, Storybo
     var messageViewModels: [MessageViewModel]?
     var textFieldShouldEndEditing = false
     var bottomOffset: CGFloat = 0
+    let scrollOffsetThreshold: CGFloat = 600
 
     override func viewDidLoad() {
         super.viewDidLoad()
@@ -147,7 +159,7 @@ class ConversationViewController: UIViewController, UITextFieldDelegate, Storybo
 
     fileprivate func scrollToBottomIfNeed() {
         if self.isBottomContentOffset {
-            self.scrollToBottom(animated: true)
+            self.scrollToBottom(animated: false)
         }
     }
 
@@ -160,7 +172,9 @@ class ConversationViewController: UIViewController, UITextFieldDelegate, Storybo
     }
 
     fileprivate var isBottomContentOffset: Bool {
-        return self.tableView.contentOffset.y + self.tableView.contentInset.top >= bottomOffset
+        updateBottomOffset()
+        let offset = abs((self.tableView.contentOffset.y + self.tableView.contentInset.top) - bottomOffset)
+        return offset <= scrollOffsetThreshold
     }
 
     override var inputAccessoryView: UIView {
@@ -189,6 +203,86 @@ class ConversationViewController: UIViewController, UITextFieldDelegate, Storybo
         return textFieldShouldEndEditing
     }
 
+    func isFirstMessage(cellForRowAt indexPath: IndexPath) -> Bool {
+        return indexPath.row == 0
+    }
+
+    func isLastMessage(cellForRowAt indexPath: IndexPath) -> Bool {
+        return self.messageViewModels?.count == indexPath.row + 1
+    }
+
+    func getBubbleChaining(cellForRowAt indexPath: IndexPath) -> BubbleChaining {
+        if let msgViewModel = self.messageViewModels?[indexPath.row] {
+            let msgOwner = msgViewModel.bubblePosition()
+            if self.messageViewModels?.count == 1 || indexPath.row == 0 {
+                if self.messageViewModels?.count == indexPath.row + 1 {
+                    return BubbleChaining.singleMessage
+                }
+                let nextMsgViewModel = indexPath.row + 1 <= (self.messageViewModels?.count)!
+                    ? self.messageViewModels?[indexPath.row + 1] : nil
+                if nextMsgViewModel != nil {
+                    return msgOwner != nextMsgViewModel?.bubblePosition()
+                        ? BubbleChaining.singleMessage : BubbleChaining.firstOfSequence
+                }
+            } else if self.messageViewModels?.count == indexPath.row + 1 {
+                let lastMsgViewModel = indexPath.row - 1 >= 0 && indexPath.row - 1 < (self.messageViewModels?.count)!
+                    ? self.messageViewModels?[indexPath.row - 1] : nil
+                if lastMsgViewModel != nil {
+                    return msgOwner != lastMsgViewModel?.bubblePosition()
+                        ? BubbleChaining.singleMessage : BubbleChaining.lastOfSequence
+                }
+            }
+            let lastMsgViewModel = indexPath.row - 1 >= 0 && indexPath.row - 1 < (self.messageViewModels?.count)!
+                ? self.messageViewModels?[indexPath.row - 1] : nil
+            let nextMsgViewModel = indexPath.row + 1 <= (self.messageViewModels?.count)!
+                ? self.messageViewModels?[indexPath.row + 1] : nil
+            var chaining = BubbleChaining.singleMessage
+            if (lastMsgViewModel != nil) && (nextMsgViewModel != nil) {
+                if msgOwner != lastMsgViewModel?.bubblePosition() && msgOwner == nextMsgViewModel?.bubblePosition() {
+                    chaining = BubbleChaining.firstOfSequence
+                } else if msgOwner != nextMsgViewModel?.bubblePosition() && msgOwner == lastMsgViewModel?.bubblePosition() {
+                    chaining = BubbleChaining.lastOfSequence
+                } else if msgOwner == nextMsgViewModel?.bubblePosition() && msgOwner == lastMsgViewModel?.bubblePosition() {
+                    chaining = BubbleChaining.middleOfSequence
+                }
+            }
+            return chaining
+        }
+        return BubbleChaining.error
+    }
+
+    func applyBubbleStyleToCell(toCell cell: MessageCell,
+                                withChaining chaining: BubbleChaining,
+                                withContent content: String,
+                                withType type: BubblePosition) {
+
+        let bubbleColor = type == .received ? UIColor.ringMsgCellReceived : UIColor.ringMsgCellSent
+
+        cell.messageLabel.setTextWithLineSpacing(withText: content, withLineSpacing: 2)
+
+        cell.topCorner.isHidden = true
+        cell.topCorner.backgroundColor = bubbleColor
+        cell.bottomCorner.isHidden = true
+        cell.bottomCorner.backgroundColor = bubbleColor
+        cell.bubbleBottomConstraint.constant = 8
+        cell.bubbleTopConstraint.constant = 8
+
+        switch chaining {
+        case .middleOfSequence:
+            cell.topCorner.isHidden = false
+            cell.bottomCorner.isHidden = false
+            cell.bubbleBottomConstraint.constant = 1
+            cell.bubbleTopConstraint.constant = 1
+        case .firstOfSequence:
+            cell.bottomCorner.isHidden = false
+            cell.bubbleBottomConstraint.constant = 1
+        case .lastOfSequence:
+            cell.topCorner.isHidden = false
+            cell.bubbleTopConstraint.constant = 1
+        default: break
+        }
+    }
+
 }
 
 extension ConversationViewController: UITableViewDataSource {
@@ -199,21 +293,38 @@ extension ConversationViewController: UITableViewDataSource {
     func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
 
         if let messageViewModel = self.messageViewModels?[indexPath.row] {
+            let chaining = self.getBubbleChaining(cellForRowAt: indexPath)
             if messageViewModel.bubblePosition() == .received {
+                // left side (incoming)
                 let cell = tableView.dequeueReusableCell(for: indexPath, cellType: MessageCellReceived.self)
-                cell.messageLabel.text = messageViewModel.content
+
+                // Format cell
+                applyBubbleStyleToCell(toCell: cell, withChaining: chaining, withContent: messageViewModel.content, withType: .received)
+
+                // Special cases where top/bottom margins should be larger
+                if isFirstMessage(cellForRowAt: indexPath) {
+                    cell.bubbleTopConstraint.constant = 16
+                } else if isLastMessage(cellForRowAt: indexPath) {
+                    cell.bubbleBottomConstraint.constant = 16
+                }
+
                 return cell
-            }
+            } else {
+                // right side (outgoing)
+                let cell = tableView.dequeueReusableCell(for: indexPath, cellType: MessageCellSent.self)
+
+                // Format cell
+                applyBubbleStyleToCell(toCell: cell, withChaining: chaining, withContent: messageViewModel.content, withType: .sent)
+
+                // Special cases where top/bottom margins should be larger
+                if isFirstMessage(cellForRowAt: indexPath) {
+                    cell.bubbleTopConstraint.constant = 16
+                } else if isLastMessage(cellForRowAt: indexPath) {
+                    cell.bubbleBottomConstraint.constant = 16
+                }
 
-            if messageViewModel.bubblePosition() == .generated {
-                let cell = tableView.dequeueReusableCell(for: indexPath, cellType: MessageCellGenerated.self)
-                cell.messageLabel.text = messageViewModel.content
                 return cell
             }
-
-            let cell = tableView.dequeueReusableCell(for: indexPath, cellType: MessageCellSent.self)
-            cell.messageLabel.text = messageViewModel.content
-            return cell
         }
 
         return tableView.dequeueReusableCell(for: indexPath, cellType: MessageCellSent.self)
-- 
GitLab