浏览代码

imrpove chat view

- better timestamps and status display
- better badge count update
dignifiedquire 6 年之前
父节点
当前提交
0332bf0f10

+ 2 - 2
README.md

@@ -7,7 +7,7 @@ Email-based instant messaging for iOS.
     $ git clone git@github.com:deltachat/deltachat-ios.git
     $ cd deltachat-ios
     $ open deltachat-ios.xcworkspace # do not: open deltachat-ios.xcodeproj
-    
+
 This should open Xcode. Then make sure that at the top left in Xcode there is *deltachat-ios* selected as scheme (see screenshot below).
 
 ![Screenshot](supporting_images/screenshot_scheme_selection.png)
@@ -42,7 +42,7 @@ Now build and run - e.g. by pressing Cmd-r - or click on the triangle at the top
       and taking+sending photos directly from the camera.
       (videos and voice messages
       and other attachments can be done in a later version)
-- [ ] reception of images
+- [x] reception of images
 - [ ] ui-polishing, eg.
   - [ ] improve group creation UI
   - [ ] smarter time/date display

+ 4 - 0
deltachat-ios.xcodeproj/project.pbxproj

@@ -51,6 +51,7 @@
 		7092474120B3869500AF8799 /* ContactProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7092474020B3869500AF8799 /* ContactProfileViewController.swift */; };
 		70B08FCD21073B910097D3EA /* NewGroupMemberChoiceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70B08FCC21073B910097D3EA /* NewGroupMemberChoiceController.swift */; };
 		70B8882E2091B8550074812E /* ContactCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70B8882D2091B8550074812E /* ContactCell.swift */; };
+		785BE16821E247F1003BE98C /* MessageInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 785BE16721E247F1003BE98C /* MessageInfoViewController.swift */; };
 		789E879621D6CB58003ED1C5 /* QrCodeReaderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 789E879521D6CB58003ED1C5 /* QrCodeReaderController.swift */; };
 		789E879D21D6DF86003ED1C5 /* ProgressHud.swift in Sources */ = {isa = PBXBuildFile; fileRef = 789E879C21D6DF86003ED1C5 /* ProgressHud.swift */; };
 		78E45E2921D176C400D4B15E /* dc_jobthread.h in Sources */ = {isa = PBXBuildFile; fileRef = 78E45E2821D176C300D4B15E /* dc_jobthread.h */; };
@@ -179,6 +180,7 @@
 		7092474020B3869500AF8799 /* ContactProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactProfileViewController.swift; sourceTree = "<group>"; };
 		70B08FCC21073B910097D3EA /* NewGroupMemberChoiceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupMemberChoiceController.swift; sourceTree = "<group>"; };
 		70B8882D2091B8550074812E /* ContactCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCell.swift; sourceTree = "<group>"; };
+		785BE16721E247F1003BE98C /* MessageInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInfoViewController.swift; sourceTree = "<group>"; };
 		789E879521D6CB58003ED1C5 /* QrCodeReaderController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrCodeReaderController.swift; sourceTree = "<group>"; };
 		789E879C21D6DF86003ED1C5 /* ProgressHud.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressHud.swift; sourceTree = "<group>"; };
 		78C7036A21D46752005D4525 /* deltachat-ios.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "deltachat-ios.entitlements"; sourceTree = "<group>"; };
@@ -422,6 +424,7 @@
 				7A451D931FB1B1DB00177250 /* wrapper.h */,
 				7A451DBD1FB4AD0700177250 /* Wrapper.swift */,
 				789E879521D6CB58003ED1C5 /* QrCodeReaderController.swift */,
+				785BE16721E247F1003BE98C /* MessageInfoViewController.swift */,
 				70B8882D2091B8550074812E /* ContactCell.swift */,
 				78ED838C21D577D000243125 /* events.swift */,
 				789E879C21D6DF86003ED1C5 /* ProgressHud.swift */,
@@ -785,6 +788,7 @@
 				7070FB6C20FF345F000DC258 /* dc_keyring.c in Sources */,
 				7A79236A1FB0A2C800BC2DE5 /* compress.c in Sources */,
 				7A451DB01FB1F84900177250 /* AppCoordinator.swift in Sources */,
+				785BE16821E247F1003BE98C /* MessageInfoViewController.swift in Sources */,
 				AEACE2E31FB32B5C00DCDD78 /* Constants.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;

+ 194 - 27
deltachat-ios/ChatViewController.swift

@@ -10,6 +10,7 @@ import ALCameraViewController
 import MapKit
 import MessageInputBar
 import MessageKit
+import QuickLook
 import UIKit
 
 class ChatViewController: MessagesViewController {
@@ -26,6 +27,7 @@ class ChatViewController: MessagesViewController {
     var disableWriting = false
 
     var previewView: UIView?
+    var previewController: PreviewController?
 
     init(chatId: Int, title: String? = nil) {
         self.chatId = chatId
@@ -94,10 +96,6 @@ class ChatViewController: MessagesViewController {
     override func viewWillAppear(_ animated: Bool) {
         super.viewWillAppear(animated)
 
-        let cnt = Int(dc_get_fresh_msg_cnt(mailboxPointer, UInt32(chatId)))
-        logger.info("updating count for chat \(cnt)")
-        UIApplication.shared.applicationIconBadgeNumber = cnt
-
         if #available(iOS 11.0, *) {
             if disableWriting {
                 navigationController?.navigationBar.prefersLargeTitles = true
@@ -141,6 +139,10 @@ class ChatViewController: MessagesViewController {
     override func viewWillDisappear(_ animated: Bool) {
         super.viewWillDisappear(animated)
 
+        let cnt = Int(dc_get_fresh_msg_cnt(mailboxPointer, UInt32(chatId)))
+        logger.info("updating count for chat \(cnt)")
+        UIApplication.shared.applicationIconBadgeNumber = cnt
+
         if #available(iOS 11.0, *) {
             if disableWriting {
                 navigationController?.navigationBar.prefersLargeTitles = false
@@ -170,6 +172,7 @@ class ChatViewController: MessagesViewController {
     }
 
     override func viewDidLoad() {
+        messagesCollectionView.register(CustomCell.self)
         super.viewDidLoad()
 
         if !MRConfig.configured {
@@ -211,7 +214,8 @@ class ChatViewController: MessagesViewController {
         // Set outgoing avatar to overlap with the message bubble
         layout?.setMessageIncomingMessageTopLabelAlignment(LabelAlignment(textAlignment: .left, textInsets: UIEdgeInsets(top: 0, left: 18, bottom: outgoingAvatarOverlap, right: 0)))
         layout?.setMessageIncomingAvatarSize(CGSize(width: 30, height: 30))
-        layout?.setMessageIncomingMessagePadding(UIEdgeInsets(top: -outgoingAvatarOverlap, left: -18, bottom: outgoingAvatarOverlap, right: 18))
+        layout?.setMessageIncomingMessagePadding(UIEdgeInsets(top: -outgoingAvatarOverlap, left: -18, bottom: outgoingAvatarOverlap / 2, right: 18))
+        layout?.setMessageIncomingMessageBottomLabelAlignment(LabelAlignment(textAlignment: .left, textInsets: UIEdgeInsets(top: -7, left: 38, bottom: 0, right: 0)))
 
         layout?.setMessageIncomingAccessoryViewSize(CGSize(width: 30, height: 30))
         layout?.setMessageIncomingAccessoryViewPadding(HorizontalEdgeInsets(left: 8, right: 0))
@@ -220,6 +224,13 @@ class ChatViewController: MessagesViewController {
 
         messagesCollectionView.messagesLayoutDelegate = self
         messagesCollectionView.messagesDisplayDelegate = self
+
+        // Configures the UIMenu which is shown when selecting a message
+        let infoMenuItem = UIMenuItem(title: "Info", action: #selector(MessageCollectionViewCell.messageInfo(_:)))
+
+        UIMenuController.shared.menuItems = [
+            infoMenuItem,
+        ]
     }
 
     func configureMessageInputBar() {
@@ -295,21 +306,56 @@ class ChatViewController: MessagesViewController {
     // MARK: - UICollectionViewDataSource
 
     public override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
-        guard let messagesDataSource = messagesCollectionView.messagesDataSource else {
-            fatalError("Ouch. nil data source for messages")
+        guard let messagesCollectionView = collectionView as? MessagesCollectionView else {
+            fatalError("notMessagesCollectionView")
         }
 
-        //        guard !isSectionReservedForTypingBubble(indexPath.section) else {
-        //            return super.collectionView(collectionView, cellForItemAt: indexPath)
-        //        }
+        guard let messagesDataSource = messagesCollectionView.messagesDataSource else {
+            fatalError("nilMessagesDataSource")
+        }
 
         let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView)
-        if case .custom = message.kind {
+
+        switch message.kind {
+        case .text, .attributedText, .emoji:
+            let cell = messagesCollectionView.dequeueReusableCell(TextMessageCell.self, for: indexPath)
+            cell.configure(with: message, at: indexPath, and: messagesCollectionView)
+            return cell
+        case .photo, .video:
+            let cell = messagesCollectionView.dequeueReusableCell(MediaMessageCell.self, for: indexPath)
+            cell.configure(with: message, at: indexPath, and: messagesCollectionView)
+            return cell
+        case .location:
+            let cell = messagesCollectionView.dequeueReusableCell(LocationMessageCell.self, for: indexPath)
+            cell.configure(with: message, at: indexPath, and: messagesCollectionView)
+            return cell
+        case .custom:
             let cell = messagesCollectionView.dequeueReusableCell(CustomCell.self, for: indexPath)
             cell.configure(with: message, at: indexPath, and: messagesCollectionView)
             return cell
         }
-        return super.collectionView(collectionView, cellForItemAt: indexPath)
+    }
+
+    override func collectionView(_ collectionView: UICollectionView, canPerformAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) -> Bool {
+        if action == NSSelectorFromString("messageInfo:") {
+            return true
+        } else {
+            return super.collectionView(collectionView, canPerformAction: action, forItemAt: indexPath, withSender: sender)
+        }
+    }
+
+    override func collectionView(_ collectionView: UICollectionView, performAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) {
+        if action == NSSelectorFromString("messageInfo:") {
+            let msg = messageList[indexPath.section]
+            logger.info("View info \(msg.messageId)")
+
+            let msgViewController = MessageInfoViewController(message: msg)
+            if let ctrl = navigationController {
+                ctrl.pushViewController(msgViewController, animated: true)
+            }
+        } else {
+            super.collectionView(collectionView, performAction: action, forItemAt: indexPath, withSender: sender)
+        }
     }
 }
 
@@ -336,6 +382,10 @@ extension ChatViewController: MessagesDataSource {
     }
 
     func cellTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
+        if isInfoMessage(at: indexPath) {
+            return nil
+        }
+
         if isTimeLabelVisible(at: indexPath) {
             return NSAttributedString(string: MessageKitDateFormatter.shared.string(from: message.sentDate), attributes: [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 10), NSAttributedString.Key.foregroundColor: UIColor.darkGray])
         }
@@ -346,19 +396,45 @@ extension ChatViewController: MessagesDataSource {
     func messageTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
         if !isPreviousMessageSameSender(at: indexPath) {
             let name = message.sender.displayName
-            return NSAttributedString(string: name, attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .caption1)])
+            let m = messageList[indexPath.section]
+            return NSAttributedString(string: name, attributes: [
+                .font: UIFont.systemFont(ofSize: 14),
+                .foregroundColor: m.fromContact.color,
+            ])
         }
         return nil
     }
 
     func isTimeLabelVisible(at indexPath: IndexPath) -> Bool {
-        // TODO: better heuristic when to show the time label
-        return indexPath.section % 3 == 0 && !isPreviousMessageSameSender(at: indexPath)
+        guard indexPath.section + 1 < messageList.count else { return false }
+
+        let messageA = messageList[indexPath.section]
+        let messageB = messageList[indexPath.section + 1]
+
+        if messageA.fromContactId == messageB.fromContactId {
+            return false
+        }
+
+        let calendar = NSCalendar(calendarIdentifier: NSCalendar.Identifier.gregorian)
+        let dateA = messageA.sentDate
+        let dateB = messageB.sentDate
+
+        let dayA = (calendar?.component(.day, from: dateA))
+        let dayB = (calendar?.component(.day, from: dateB))
+
+        return dayA != dayB
     }
 
     func isPreviousMessageSameSender(at indexPath: IndexPath) -> Bool {
         guard indexPath.section - 1 >= 0 else { return false }
-        return messageList[indexPath.section].fromContactId == messageList[indexPath.section - 1].fromContactId
+        let messageA = messageList[indexPath.section - 1]
+        let messageB = messageList[indexPath.section]
+
+        if messageA.isInfo {
+            return false
+        }
+
+        return messageA.fromContactId == messageB.fromContactId
     }
 
     func isInfoMessage(at indexPath: IndexPath) -> Bool {
@@ -367,16 +443,45 @@ extension ChatViewController: MessagesDataSource {
 
     func isNextMessageSameSender(at indexPath: IndexPath) -> Bool {
         guard indexPath.section + 1 < messageList.count else { return false }
-        return messageList[indexPath.section].fromContactId == messageList[indexPath.section + 1].fromContactId
+        let messageA = messageList[indexPath.section]
+        let messageB = messageList[indexPath.section + 1]
+
+        if messageA.isInfo {
+            return false
+        }
+
+        return messageA.fromContactId == messageB.fromContactId
     }
 
     func messageBottomLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
         guard indexPath.section < messageList.count else { return nil }
         let m = messageList[indexPath.section]
-        if !isNextMessageSameSender(at: indexPath), isFromCurrentSender(message: message) {
-            return NSAttributedString(string: m.stateOutDescription(), attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .caption1)])
+
+        if m.isInfo || isNextMessageSameSender(at: indexPath) {
+            return nil
         }
-        return nil
+
+        let timestampAttributes: [NSAttributedString.Key: Any] = [
+            .font: UIFont.systemFont(ofSize: 12),
+            .foregroundColor: UIColor.lightGray,
+        ]
+
+        if isFromCurrentSender(message: message) {
+            let text = NSMutableAttributedString()
+            text.append(NSAttributedString(string: m.formattedSentDate(), attributes: timestampAttributes))
+
+            text.append(NSAttributedString(
+                string: " - " + m.stateDescription(),
+                attributes: [
+                    .font: UIFont.systemFont(ofSize: 12),
+                    .foregroundColor: UIColor.darkText,
+                ]
+            ))
+
+            return text
+        }
+
+        return NSAttributedString(string: m.formattedSentDate(), attributes: timestampAttributes)
     }
 
     func updateMessage(_ messageId: Int) {
@@ -446,7 +551,12 @@ extension ChatViewController: MessagesDisplayDelegate {
         if isInfoMessage(at: indexPath) {
             return .custom { view in
                 view.style = .none
-                view.backgroundColor = UIColor(alpha: 0, red: 0, green: 0, blue: 0)
+                view.backgroundColor = UIColor(alpha: 10, red: 0, green: 0, blue: 0)
+                let radius: CGFloat = 16
+                let path = UIBezierPath(roundedRect: view.bounds, byRoundingCorners: UIRectCorner.allCorners, cornerRadii: CGSize(width: radius, height: radius))
+                let mask = CAShapeLayer()
+                mask.path = path.cgPath
+                view.layer.mask = mask
                 view.center.x = self.view.center.x
             }
         }
@@ -506,15 +616,31 @@ extension ChatViewController: MessagesLayoutDelegate {
     }
 
     func messageTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in _: MessagesCollectionView) -> CGFloat {
+        if isInfoMessage(at: indexPath) {
+            return 0
+        }
+
         if isFromCurrentSender(message: message) {
-            return !isPreviousMessageSameSender(at: indexPath) ? 20 : 0
+            return !isPreviousMessageSameSender(at: indexPath) ? 40 : 0
         } else {
-            return !isPreviousMessageSameSender(at: indexPath) ? (20 + outgoingAvatarOverlap) : 0
+            return !isPreviousMessageSameSender(at: indexPath) ? (40 + outgoingAvatarOverlap) : 0
         }
     }
 
     func messageBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in _: MessagesCollectionView) -> CGFloat {
-        return (!isNextMessageSameSender(at: indexPath) && isFromCurrentSender(message: message)) && !isInfoMessage(at: indexPath) ? 16 : 0
+        if isInfoMessage(at: indexPath) {
+            return 0
+        }
+
+        if !isNextMessageSameSender(at: indexPath) {
+            return 16
+        }
+
+        if isFromCurrentSender(message: message) {
+            return 0
+        }
+
+        return 9
     }
 
     func heightForLocation(message _: MessageType, at _: IndexPath, with _: CGFloat, in _: MessagesCollectionView) -> CGFloat {
@@ -522,7 +648,7 @@ extension ChatViewController: MessagesLayoutDelegate {
     }
 
     func footerViewSize(for _: MessageType, at _: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGSize {
-        return CGSize(width: messagesCollectionView.bounds.width, height: 10)
+        return CGSize(width: messagesCollectionView.bounds.width, height: 20)
     }
 
     @objc func didPressPhotoButton() {
@@ -560,8 +686,15 @@ extension ChatViewController: MessagesLayoutDelegate {
 // MARK: - MessageCellDelegate
 
 extension ChatViewController: MessageCellDelegate {
-    func didTapMessage(in _: MessageCollectionViewCell) {
-        logger.info("Message tapped")
+    func didTapMessage(in cell: MessageCollectionViewCell) {
+        if let indexPath = messagesCollectionView.indexPath(for: cell) {
+            let message = messageList[indexPath.section]
+
+            if let url = message.fileURL {
+                previewController = PreviewController(urls: [url])
+                present(previewController!.qlController, animated: true)
+            }
+        }
     }
 
     func didTapAvatar(in _: MessageCollectionViewCell) {
@@ -577,6 +710,25 @@ extension ChatViewController: MessageCellDelegate {
     }
 }
 
+class PreviewController: QLPreviewControllerDataSource {
+    var urls: [URL]
+    var qlController: QLPreviewController
+
+    init(urls: [URL]) {
+        self.urls = urls
+        qlController = QLPreviewController()
+        qlController.dataSource = self
+    }
+
+    func numberOfPreviewItems(in _: QLPreviewController) -> Int {
+        return urls.count
+    }
+
+    func previewController(_: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
+        return urls[index] as QLPreviewItem
+    }
+}
+
 // MARK: - MessageLabelDelegate
 
 extension ChatViewController: MessageLabelDelegate {
@@ -646,3 +798,18 @@ extension ChatViewController: MessageInputBarDelegate {
         inputBar.inputTextView.text = String()
     }
 }
+
+// MARK: - MessageCollectionViewCell
+
+extension MessageCollectionViewCell {
+    @objc func messageInfo(_ sender: Any?) {
+        // Get the collectionView
+        if let collectionView = self.superview as? UICollectionView {
+            // Get indexPath
+            if let indexPath = collectionView.indexPath(for: self) {
+                // Trigger action
+                collectionView.delegate?.collectionView?(collectionView, performAction: #selector(MessageCollectionViewCell.messageInfo(_:)), forItemAt: indexPath, withSender: sender)
+            }
+        }
+    }
+}

+ 107 - 0
deltachat-ios/MessageInfoViewController.swift

@@ -0,0 +1,107 @@
+//
+//  MessageInfoViewController.swift
+//  deltachat-ios
+//
+//  Created by Friedel Ziegelmayer on 06.01.19.
+//  Copyright © 2019 Jonas Reinsch. All rights reserved.
+//
+
+import UIKit
+
+class MessageInfoViewController: UITableViewController {
+    var message: MRMessage
+
+    init(message: MRMessage) {
+        self.message = message
+
+        super.init(style: .plain)
+    }
+
+    required init?(coder _: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        title = "Message Info"
+        // Uncomment the following line to preserve selection between presentations
+        // self.clearsSelectionOnViewWillAppear = false
+
+        // Uncomment the following line to display an Edit button in the navigation bar for this view controller.
+        // self.navigationItem.rightBarButtonItem = self.editButtonItem
+    }
+
+    // MARK: - Table view data source
+
+    override func numberOfSections(in _: UITableView) -> Int {
+        // #warning Incomplete implementation, return the number of sections
+        return 1
+    }
+
+    override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
+        // #warning Incomplete implementation, return the number of rows
+        return 1
+    }
+
+    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+        let cell: UITableViewCell
+        if let c = tableView.dequeueReusableCell(withIdentifier: "MessageInfoCell") {
+            cell = c
+        } else {
+            cell = UITableViewCell(style: .default, reuseIdentifier: "MessageInfoCell")
+        }
+
+        if indexPath.section == 0 {
+            if indexPath.row == 0 {
+                cell.textLabel?.text = message.text
+            }
+        }
+
+        return cell
+    }
+
+    /*
+     // Override to support conditional editing of the table view.
+     override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
+     // Return false if you do not want the specified item to be editable.
+     return true
+     }
+     */
+
+    /*
+     // Override to support editing the table view.
+     override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
+     if editingStyle == .delete {
+     // Delete the row from the data source
+     tableView.deleteRows(at: [indexPath], with: .fade)
+     } else if editingStyle == .insert {
+     // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view
+     }
+     }
+     */
+
+    /*
+     // Override to support rearranging the table view.
+     override func tableView(_ tableView: UITableView, moveRowAt fromIndexPath: IndexPath, to: IndexPath) {
+
+     }
+     */
+
+    /*
+     // Override to support conditional rearranging of the table view.
+     override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
+     // Return false if you do not want the item to be re-orderable.
+     return true
+     }
+     */
+
+    /*
+     // MARK: - Navigation
+
+     // In a storyboard-based application, you will often want to do a little preparation before navigation
+     override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
+     // Get the new view controller using segue.destination.
+     // Pass the selected object to the new view controller.
+     }
+     */
+}

+ 98 - 15
deltachat-ios/Wrapper.swift

@@ -10,6 +10,29 @@ import Foundation
 import MessageKit
 import UIKit
 
+enum MessageViewType: CustomStringConvertible {
+    case audio
+    case file
+    case gif
+    case image
+    case text
+    case video
+    case voice
+
+    var description: String {
+        switch self {
+        // Use Internationalization, as appropriate.
+        case .audio: return "Audio"
+        case .file: return "File"
+        case .gif: return "GIF"
+        case .image: return "Image"
+        case .text: return "Text"
+        case .video: return "Video"
+        case .voice: return "Voice"
+        }
+    }
+}
+
 class MRContact {
     private var contactPointer: UnsafeMutablePointer<dc_contact_t>
 
@@ -87,22 +110,41 @@ class MRMessage: MessageType {
         Date(timeIntervalSince1970: Double(timestamp))
     }()
 
+    let localDateFormatter: DateFormatter = {
+        let result = DateFormatter()
+        result.dateStyle = .none
+        result.timeStyle = .short
+        return result
+    }()
+
+    func formattedSentDate() -> String {
+        return localDateFormatter.string(from: sentDate)
+    }
+
     lazy var kind: MessageKind = {
         if isInfo {
             let text = NSAttributedString(string: self.text ?? "", attributes: [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 12), NSAttributedString.Key.foregroundColor: UIColor.darkGray])
             return MessageKind.attributedText(text)
         }
 
-        if let image = self.image {
-            return MessageKind.photo(Media(image: image))
-        }
+        let text = self.text ?? ""
 
-        if let filename = self.filename {
-            // TODO:
-            return MessageKind.text("File: \(self.filename ?? "") (\(self.filesize) bytes)")
+        if self.viewtype == nil {
+            return MessageKind.text(text)
         }
 
-        return MessageKind.text(self.text ?? "- empty -")
+        switch self.viewtype! {
+        case .image:
+            return MessageKind.photo(Media(image: image))
+        case .video:
+            return MessageKind.video(Media(url: fileURL))
+        default:
+            // TODO: custom views for audio, etc
+            if let filename = self.filename {
+                return MessageKind.text("File: \(self.filename ?? "") (\(self.filesize) bytes)")
+            }
+            return MessageKind.text(text)
+        }
     }()
 
     var messageId: String {
@@ -121,6 +163,10 @@ class MRMessage: MessageType {
         MRContact(id: fromContactId)
     }()
 
+    lazy var toContact: MRContact = {
+        MRContact(id: toContactId)
+    }()
+
     var toContactId: Int {
         return Int(messagePointer.pointee.to_id)
     }
@@ -130,22 +176,51 @@ class MRMessage: MessageType {
     }
 
     var text: String? {
-        return String(cString: messagePointer.pointee.text)
+        guard let result = dc_msg_get_text(messagePointer) else { return nil }
+
+        return String(cString: result)
+    }
+
+    var viewtype: MessageViewType? {
+        switch dc_msg_get_viewtype(messagePointer) {
+        case 0:
+            return nil
+        case DC_MSG_AUDIO:
+            return .audio
+        case DC_MSG_FILE:
+            return .file
+        case DC_MSG_GIF:
+            return .gif
+        case DC_MSG_TEXT:
+            return .text
+        case DC_MSG_IMAGE:
+            return .image
+        case DC_MSG_VIDEO:
+            return .video
+        case DC_MSG_VOICE:
+            return .voice
+        default:
+            return nil
+        }
+    }
+
+    var fileURL: URL? {
+        if let file = self.file {
+            return URL(fileURLWithPath: file, isDirectory: false)
+        }
+        return nil
     }
 
     lazy var image: UIImage? = { [unowned self] in
         let filetype = dc_msg_get_viewtype(messagePointer)
-        let file = dc_msg_get_file(messagePointer)
-        if let cFile = file, filetype == DC_MSG_IMAGE {
-            let filename = String(cString: cFile)
-            let path: URL = URL(fileURLWithPath: filename, isDirectory: false)
+        if let path = fileURL, filetype == DC_MSG_IMAGE {
             if path.isFileURL {
                 do {
                     let data = try Data(contentsOf: path)
                     let image = UIImage(data: data)
                     return image
                 } catch {
-                    logger.warning("failed to load image: \(filename), \(error)")
+                    logger.warning("failed to load image: \(path), \(error)")
                     return nil
                 }
             }
@@ -196,11 +271,17 @@ class MRMessage: MessageType {
 
     // MR_STATE_*
     var state: Int {
-        return Int(messagePointer.pointee.state)
+        return Int(dc_msg_get_state(messagePointer))
     }
 
-    func stateOutDescription() -> String {
+    func stateDescription() -> String {
         switch Int32(state) {
+        case DC_STATE_IN_FRESH:
+            return "Fresh"
+        case DC_STATE_IN_NOTICED:
+            return "Noticed"
+        case DC_STATE_IN_SEEN:
+            return "Seen"
         case DC_STATE_OUT_DRAFT:
             return "Draft"
         case DC_STATE_OUT_PENDING:
@@ -209,6 +290,8 @@ class MRMessage: MessageType {
             return "Sent"
         case DC_STATE_OUT_MDN_RCVD:
             return "Read"
+        case DC_STATE_OUT_FAILED:
+            return "Failed"
         default:
             return "Unknown"
         }