【Xcode/Swift/Firebase】時間指定してチャット送信できるチャットアプリの実装

自分のために備忘録として記載してます。

# この記事でやること
- MessageKitを使用したチャット画面の設計・チャット機能の実装
- inputBarAccessoryViewのカスタマイズ
    - 送信ボタンと送信予定ボタンの設置

# 環境
- Xcode 15.0.1
- Swift 5

# 前提
- MessageKitがインストールされていること
- 下記まで完了していること

https://qiita.com/taku-ll/items/b0573a33f6d8b7dd00f7

# 実装方法
## Model
チャットアプリケーションにおけるデータモデルを定義します。それぞれの構造体 (struct) は、アプリ内で使用されるデータの構造と動作を表しています。
```
import Foundation
import FirebaseFirestoreSwift
import MessageKit

struct Chat: Codable {
    var id: String
    var name: String
    var participants: [String]
    
    func toDictionary() -> [String: Any] {
        return [
            "id": id,
            "name": name,
            "participants": participants
        ]
    }
}

struct User: Codable {
    var id: String
    var name: String
}

struct Message: Codable, MessageType {
    var id: String
    var senderId: String
    var senderName: String
    var text: String
    var timestamp: Date

    var sender: SenderType {
        return Sender(senderId: senderId, displayName: senderName)
    }

    var messageId: String {
        return id
    }

    var sentDate: Date {
        return timestamp
    }

    var kind: MessageKind {
        return .text(text)
    }

    func toDictionary() -> [String: Any] {
        return [
            "id": id,
            "senderId": senderId,
            "senderName": senderName,
            "text": text,
            "timestamp": timestamp
        ]
    }
}

struct Sender: SenderType {
    var senderId: String
    var displayName: String
}
```

## ChatViewController
### 全量
```
import UIKit
import MessageKit
import InputBarAccessoryView
import FirebaseFirestore
import FirebaseAuth

class ChatViewController: MessagesViewController {

    // MARK: - Properties
    var chat: Chat?
    var messages: [Message] = []
    var messageListener: ListenerRegistration?
    let db = Firestore.firestore()
    var currentUsername: String = "Unknown"

    // MARK: - Lifecycle Methods
    override func viewDidLoad() {
        super.viewDidLoad()

        setupNavigationBar()
        setupMessageCollectionView()
        setupInputBar()
        addKeyboardObservers()

        fetchCurrentUser()
        fetchMessages()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        tabBarController?.tabBar.isHidden = true
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        messageListener?.remove()
        tabBarController?.tabBar.isHidden = false
        removeKeyboardObservers()
    }

    // MARK: - Setup Methods
    func setupNavigationBar() {
        navigationItem.title = chat?.name
    }

    func setupMessageCollectionView() {
        messagesCollectionView.messagesDataSource = self
        messagesCollectionView.messagesLayoutDelegate = self
        messagesCollectionView.messagesDisplayDelegate = self

        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
        messagesCollectionView.addGestureRecognizer(tapGesture)
    }

    func setupInputBar() {
        messageInputBar.delegate = self
        messageInputBar.inputTextView.placeholder = "メッセージを入力"
        messageInputBar.separatorLine.isHidden = true

        let sendButton = createSendButton()
        let sendLaterButton = createSendLaterButton()

        let bottomStackView = InputStackView(arrangedSubviews: [sendLaterButton, sendButton])
        bottomStackView.axis = .horizontal
        bottomStackView.alignment = .center
        bottomStackView.distribution = .fillProportionally
        bottomStackView.spacing = 8
        messageInputBar.bottomStackView.addArrangedSubview(bottomStackView)

        messageInputBar.setStackViewItems([], forStack: .right, animated: false)
        messageInputBar.setRightStackViewWidthConstant(to: 0, animated: false)
    }

    func createSendButton() -> InputBarButtonItem {
        let sendButton = InputBarButtonItem()
        sendButton.setTitle("送信", for: .normal)
        sendButton.setSize(CGSize(width: 60, height: 40), animated: false)
        sendButton.onTouchUpInside { [weak self] _ in
            if let text = self?.messageInputBar.inputTextView.text, !text.isEmpty {
                self?.sendMessage(text: text)
                self?.messageInputBar.inputTextView.text = ""
            }
        }
        return sendButton
    }

    func createSendLaterButton() -> InputBarButtonItem {
        let sendLaterButton = InputBarButtonItem()
        sendLaterButton.setTitle("送信予定", for: .normal)
        sendLaterButton.setSize(CGSize(width: 80, height: 40), animated: false)
        sendLaterButton.onTouchUpInside { [weak self] _ in
            self?.showDatePicker()
        }
        return sendLaterButton
    }

    func addKeyboardObservers() {
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
    }

    func removeKeyboardObservers() {
        NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
        NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
    }

    // MARK: - Fetch Methods
    func fetchCurrentUser() {
        guard let userId = Auth.auth().currentUser?.uid else { return }
        db.collection("users").document(userId).getDocument { (document, error) in
            if let document = document, document.exists {
                self.currentUsername = document.data()?["username"] as? String ?? "Unknown"
            } else {
                print("User does not exist")
            }
        }
    }

    func fetchMessages() {
        guard let chatId = chat?.id else { return }

        messageListener = db.collection("chats").document(chatId).collection("messages")
            .order(by: "timestamp", descending: false)
            .addSnapshotListener { [weak self] (querySnapshot, error) in
                guard let self = self else { return }

                if let error = error {
                    print("Error getting messages: \(error)")
                    return
                }

                let now = Date()
                self.messages = querySnapshot?.documents.compactMap { document in
                    if let message = try? document.data(as: Message.self) {
                        return message.timestamp <= now ? message : nil
                    }
                    return nil
                } ?? []

                self.messagesCollectionView.reloadData()
                self.scrollToBottom(animated: false)
            }
    }

    // MARK: - Helper Methods
    func sendMessage(text: String, scheduledDate: Date? = nil) {
        guard let chatId = chat?.id, let userId = Auth.auth().currentUser?.uid else { return }

        let timestamp = scheduledDate ?? Date()
        let message = Message(id: UUID().uuidString, senderId: userId, senderName: currentUsername, text: text, timestamp: timestamp)

        db.collection("chats").document(chatId).collection("messages").document(message.id).setData(message.toDictionary()) { error in
            if let error = error {
                print("Error sending message: \(error)")
            }
        }

        if timestamp <= Date() {
            messages.append(message)
            messagesCollectionView.reloadData()
            scrollToBottom(animated: true)
        }
    }

    func scrollToBottom(animated: Bool) {
        guard !messages.isEmpty else { return }
        let lastMessageIndex = IndexPath(item: 0, section: messages.count - 1)
        messagesCollectionView.scrollToItem(at: lastMessageIndex, at: .bottom, animated: animated)
    }

    @objc func keyboardWillShow(notification: NSNotification) {
        if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
            let keyboardHeight = keyboardFrame.cgRectValue.height
            UIView.animate(withDuration: 0.3) {
                self.view.frame.origin.y = -keyboardHeight
            }
        }
    }

    @objc func keyboardWillHide(notification: NSNotification) {
        UIView.animate(withDuration: 0.3) {
            self.view.frame.origin.y = 0
        }
    }

    @objc func dismissKeyboard() {
        view.endEditing(true)
    }

    // MARK: - DatePicker
    func showDatePicker() {
        let alert = UIAlertController(title: "送信予定時間を選択", message: nil, preferredStyle: .actionSheet)

        let datePicker = UIDatePicker()
        datePicker.datePickerMode = .dateAndTime
        datePicker.preferredDatePickerStyle = .wheels

        let height: NSLayoutConstraint = NSLayoutConstraint(item: alert.view!, attribute: NSLayoutConstraint.Attribute.height, relatedBy: NSLayoutConstraint.Relation.equal, toItem: nil, attribute: NSLayoutConstraint.Attribute.notAnAttribute, multiplier: 1, constant: 330)
        alert.view.addConstraint(height)

        alert.view.addSubview(datePicker)

        datePicker.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            datePicker.centerXAnchor.constraint(equalTo: alert.view.centerXAnchor),
            datePicker.bottomAnchor.constraint(equalTo: alert.view.bottomAnchor, constant: -100),
            datePicker.widthAnchor.constraint(equalTo: alert.view.widthAnchor),
            datePicker.heightAnchor.constraint(equalToConstant: 200)
        ])

        alert.addAction(UIAlertAction(title: "キャンセル", style: .cancel, handler: nil))
        alert.addAction(UIAlertAction(title: "設定", style: .default, handler: { [weak self] _ in
            let scheduledDate = datePicker.date
            if let text = self?.messageInputBar.inputTextView.text, !text.isEmpty {
                self?.sendMessage(text: text, scheduledDate: scheduledDate)
                self?.messageInputBar.inputTextView.text = ""
            }
        }))

        present(alert, animated: true, completion: nil)
    }
}

// MARK: - Date Extension
extension Date {
    func timeAgoDisplay() -> String {
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.second, .minute, .hour, .day, .weekOfMonth]
        formatter.maximumUnitCount = 1
        formatter.unitsStyle = .abbreviated
        return formatter.string(from: self, to: Date()) ?? ""
    }
}

// MARK: - MessageKit Protocols
extension ChatViewController: MessagesDataSource, MessagesDisplayDelegate, MessagesLayoutDelegate {

    var currentSender: MessageKit.SenderType {
        guard let userId = Auth.auth().currentUser?.uid else {
            return Sender(senderId: "unknown", displayName: "Unknown")
        }
        return Sender(senderId: userId, displayName: currentUsername)
    }

    func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int {
        return messages.count
    }

    func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType {
        return messages[indexPath.section]
    }

    func cellTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
        return NSAttributedString(
            string: message.sentDate.timeAgoDisplay(),
            attributes: [.font: UIFont.preferredFont(forTextStyle: .caption1)]
        )
    }

    func messageTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
        return NSAttributedString(
            string: message.sender.displayName,
            attributes: [.font: UIFont.preferredFont(forTextStyle: .caption1)]
        )
    }

    func textColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor {
        return isFromCurrentSender(message: message) ? .white : .darkText
    }

    func detectorAttributes(for detector: DetectorType, and message: MessageType, at indexPath: IndexPath) -> [NSAttributedString.Key : Any] {
        switch detector {
        case .hashtag, .mention: return [.foregroundColor: UIColor.blue]
        default: return MessageLabel.defaultAttributes
        }
    }

    func enabledDetectors(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> [DetectorType] {
        return [.url, .address, .phoneNumber, .date, .transitInformation, .mention, .hashtag]
    }

    func messageStyle(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageStyle {
        let tail: MessageStyle.TailCorner = isFromCurrentSender(message: message) ? .bottomRight : .bottomLeft
        return .bubbleTail(tail, .curved)
    }

    func cellTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 0
    }

    func cellBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 20
    }

    func messageBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 20
    }

    func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) {
        let sender = message.sender
        let initials = sender.displayName.components(separatedBy: " ").compactMap({ $0.first }).map({ String($0) }).joined()
        avatarView.set(avatar: Avatar(initials: initials))
    }
}

// MARK: - InputBarAccessoryViewDelegate
extension ChatViewController: InputBarAccessoryViewDelegate {

    func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
        sendMessage(text: text)
        inputBar.inputTextView.text = ""
    }
}
```

### インポートセクション
```
import UIKit
import MessageKit
import InputBarAccessoryView
import FirebaseFirestore
import FirebaseAuth
```
- UIKit: iOSアプリのUIコンポーネントを使用するためのフレームワーク。
- MessageKit: チャットUIを構築するためのライブラリ。
- InputBarAccessoryView: メッセージ入力バーのカスタマイズを容易にするライブラリ。
- FirebaseFirestore: FirebaseのNoSQLクラウドデータベースであるFirestoreを使用するためのライブラリ。
- FirebaseAuth: Firebase Authenticationを使用してユーザー認証を行うためのライブラリ。

### プロパティの定義
```
class ChatViewController: MessagesViewController {

    var chat: Chat?
    var messages: [Message] = []
    var messageListener: ListenerRegistration?
    let db = Firestore.firestore()
    var currentUsername: String = "Unknown"
```
- chat: 現在のチャットの情報を格納するプロパティ。
- messages: 取得したメッセージを格納する配列。
- messageListener: Firestoreのリアルタイムリスナー。
- db: Firestoreデータベースのインスタンス。
- currentUsername: 現在のユーザー名を格納するプロパティ。

### ライフサイクルメソッド
```
override func viewDidLoad() {
    super.viewDidLoad()

    setupNavigationBar()
    setupMessageCollectionView()
    setupInputBar()
    addKeyboardObservers()

    fetchCurrentUser()
    fetchMessages()
}
```
- viewDidLoad: ビューがロードされたときに呼ばれるメソッド。ここでは、UIのセットアップやFirestoreからのデータ取得、キーボードの表示に関するオブザーバーの追加などを行っています。

```
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    tabBarController?.tabBar.isHidden = true
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    messageListener?.remove()
    tabBarController?.tabBar.isHidden = false
    removeKeyboardObservers()
}
```
- viewWillAppear: 画面が表示される直前に呼ばれるメソッド。タブバーを非表示にしています。<font color="Red">（※タブバーを使用していない場合は必要なし）</font>
- viewWillDisappear: 画面が非表示になる直前に呼ばれるメソッド。Firestoreリスナーの解除や、キーボードのオブザーバーを削除しています。

### UIセットアップメソッド
```
func setupNavigationBar() {
    navigationItem.title = chat?.name
}

func setupMessageCollectionView() {
    messagesCollectionView.messagesDataSource = self
    messagesCollectionView.messagesLayoutDelegate = self
    messagesCollectionView.messagesDisplayDelegate = self

    let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
    messagesCollectionView.addGestureRecognizer(tapGesture)
}
```
- setupNavigationBar: ナビゲーションバーのタイトルを設定します。
- setupMessageCollectionView: メッセージコレクションビューのデータソース、レイアウトデリゲート、ディスプレイデリゲートを設定し、タップジェスチャーを追加してキーボードを閉じる機能を実装しています。

```
func setupInputBar() {
    messageInputBar.delegate = self
    messageInputBar.inputTextView.placeholder = "メッセージを入力"
    messageInputBar.separatorLine.isHidden = true

    let sendButton = createSendButton()
    let sendLaterButton = createSendLaterButton()

    let bottomStackView = InputStackView(arrangedSubviews: [sendLaterButton, sendButton])
    bottomStackView.axis = .horizontal
    bottomStackView.alignment = .center
    bottomStackView.distribution = .fillProportionally
    bottomStackView.spacing = 8
    messageInputBar.bottomStackView.addArrangedSubview(bottomStackView)

    messageInputBar.setStackViewItems([], forStack: .right, animated: false)
    messageInputBar.setRightStackViewWidthConstant(to: 0, animated: false)
}
```
- setupInputBar: メッセージ入力バーのセットアップを行います。送信ボタンと送信予定ボタンを作成し、それを入力バーの下部に配置します。

### メッセージ送信ボタンの作成メソッド
```
func createSendButton() -> InputBarButtonItem {
    let sendButton = InputBarButtonItem()
    sendButton.setTitle("送信", for: .normal)
    sendButton.setSize(CGSize(width: 60, height: 40), animated: false)
    sendButton.onTouchUpInside { [weak self] _ in
        if let text = self?.messageInputBar.inputTextView.text, !text.isEmpty {
            self?.sendMessage(text: text)
            self?.messageInputBar.inputTextView.text = ""
        }
    }
    return sendButton
}

func createSendLaterButton() -> InputBarButtonItem {
    let sendLaterButton = InputBarButtonItem()
    sendLaterButton.setTitle("送信予定", for: .normal)
    sendLaterButton.setSize(CGSize(width: 80, height: 40), animated: false)
    sendLaterButton.onTouchUpInside { [weak self] _ in
        self?.showDatePicker()
    }
    return sendLaterButton
}
```
- createSendButton: 送信ボタンを作成し、タップ時にメッセージを送信する処理を設定します。
- createSendLaterButton: 送信予定ボタンを作成し、タップ時に日付ピッカーを表示する処理を設定します。

### キーボード表示/非表示の処理
```
func addKeyboardObservers() {
    NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}

func removeKeyboardObservers() {
    NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
    NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
}
```
- addKeyboardObservers: キーボードの表示と非表示を監視するオブザーバーを追加します。
- removeKeyboardObservers: キーボードのオブザーバーを解除します。

### Firestoreからのデータ取得メソッド
```
func fetchCurrentUser() {
    guard let userId = Auth.auth().currentUser?.uid else { return }
    db.collection("users").document(userId).getDocument { (document, error) in
        if let document = document, document.exists {
            self.currentUsername = document.data()?["username"] as? String ?? "Unknown"
        } else {
            print("User does not exist")
        }
    }
}

func fetchMessages() {
    guard let chatId = chat?.id else { return }

    messageListener = db.collection("chats").document(chatId).collection("messages")
        .order(by: "timestamp", descending: false)
        .addSnapshotListener { [weak self] (querySnapshot, error) in
            guard let self = self else { return }

            if let error = error {
                print("Error getting messages: \(error)")
                return
            }

            let now = Date()
            self.messages = querySnapshot?.documents.compactMap { document in
                if let message = try? document.data(as: Message.self) {
                    return message.timestamp <= now ? message : nil
                }
                return nil
            } ?? []

            self.messagesCollectionView.reloadData()
            self.scrollToBottom(animated: false)
        }
}
```
- fetchCurrentUser: Firestoreから現在のユーザー情報を取得します。
- fetchMessages: Firestoreから現在のチャットに関連するメッセージを取得し、表示するための処理です。リスナーを使ってリアルタイムでデータを取得し、現在時刻より未来のメッセージは除外します。

### メッセージ送信メソッド
```
func sendMessage(text: String, scheduledDate: Date? = nil) {
    guard let chatId = chat?.id, let userId = Auth.auth().currentUser?.uid else { return }

    let timestamp = scheduledDate ?? Date()
    let message = Message(id: UUID().uuidString, senderId: userId, senderName: currentUsername, text: text, timestamp: timestamp)

    db.collection("chats").document(chatId).collection("messages").document(message.id).setData(message.toDictionary()) { error in
        if let error = error {
            print("Error sending message: \(error)")
        }
    }

    if timestamp <= Date() {
        messages.append(message)
        messagesCollectionView.reloadData()
        scrollToBottom(animated: true)
    }
}
```
- sendMessage: メッセージをFirestoreに保存します。もし送信予定時刻が現在時刻より未来でなければ、メッセージを表示します。

### メッセージ表示を調整するメソッド
```
func scrollToBottom(animated: Bool) {
    guard !messages.isEmpty else { return }
    let lastMessageIndex = IndexPath(item: 0, section: messages.count - 1)
    messagesCollectionView.scrollToItem(at: lastMessageIndex, at: .bottom, animated: animated)
}
```
- scrollToBottom: 新しいメッセージが表示されたときにメッセージリストの最後までスクロールします。

### キーボード表示時の処理
```
@objc func keyboardWillShow(notification: NSNotification) {
    if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
        let keyboardHeight = keyboardFrame.cgRectValue.height
        UIView.animate(withDuration: 0.3) {
            self.view.frame.origin.y = -keyboardHeight
        }
    }
}

@objc func keyboardWillHide(notification: NSNotification) {
    UIView.animate(withDuration: 0.3) {
        self.view.frame.origin.y = 0
    }
}
```
- keyboardWillShow: キーボードが表示された際に、ビュー全体を上に移動させて隠れないようにします。
- keyboardWillHide: キーボードが隠れるときに、ビュー全体を元の位置に戻します。

### キーボードを閉じる処理
```
@objc func dismissKeyboard() {
    view.endEditing(true)
}
```
- dismissKeyboard: ビューをタップしたときにキーボードを閉じます。

### DatePickerの表示メソッド
```
func showDatePicker() {
    let alert = UIAlertController(title: "送信予定時間を選択", message: nil, preferredStyle: .actionSheet)

    let datePicker = UIDatePicker()
    datePicker.datePickerMode = .dateAndTime
    datePicker.preferredDatePickerStyle = .wheels

    let height: NSLayoutConstraint = NSLayoutConstraint(item: alert.view!, attribute: NSLayoutConstraint.Attribute.height, relatedBy: NSLayoutConstraint.Relation.equal, toItem: nil, attribute: NSLayoutConstraint.Attribute.notAnAttribute, multiplier: 1, constant: 330)
    alert.view.addConstraint(height)

    alert.view.addSubview(datePicker)

    datePicker.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        datePicker.centerXAnchor.constraint(equalTo: alert.view.centerXAnchor),
        datePicker.bottomAnchor.constraint(equalTo: alert.view.bottomAnchor, constant: -100),
        datePicker.widthAnchor.constraint(equalTo: alert.view.widthAnchor),
        datePicker.heightAnchor.constraint(equalToConstant: 200)
    ])

    alert.addAction(UIAlertAction(title: "キャンセル", style: .cancel, handler: nil))
    alert.addAction(UIAlertAction(title: "設定", style: .default, handler: { [weak self] _ in
        let scheduledDate = datePicker.date
        if let text = self?.messageInputBar.inputTextView.text, !text.isEmpty {
            self?.sendMessage(text: text, scheduledDate: scheduledDate)
            self?.messageInputBar.inputTextView.text = ""
        }
    }))

    present(alert, animated: true, completion: nil)
}
```
- showDatePicker: メッセージの送信予定時間を選択するためのDatePickerを表示します。選択された時間を使用して、メッセージを将来送信するようにスケジュールします。

### Date拡張メソッド
```
extension Date {
    func timeAgoDisplay() -> String {
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.second, .minute, .hour, .day, .weekOfMonth]
        formatter.maximumUnitCount = 1
        formatter.unitsStyle = .abbreviated
        return formatter.string(from: self, to: Date()) ?? ""
    }
}
```
- timeAgoDisplay: 日時を「何分前」「何時間前」のような形式で表示するためのメソッドです。

### MessageKitのプロトコル実装
```
extension ChatViewController: MessagesDataSource, MessagesDisplayDelegate, MessagesLayoutDelegate {

    var currentSender: MessageKit.SenderType {
        guard let userId = Auth.auth().currentUser?.uid else {
            return Sender(senderId: "unknown", displayName: "Unknown")
        }
        return Sender(senderId: userId, displayName: currentUsername)
    }

    func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int {
        return messages.count
    }

    func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType {
        return messages[indexPath.section]
    }

    func messageTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
        return NSAttributedString(
            string: message.sender.displayName,
            attributes: [.font: UIFont.preferredFont(forTextStyle: .caption1)]
        )
    }

    func textColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor {
        return isFromCurrentSender(message: message) ? .white : .darkText
    }

    func detectorAttributes(for detector: DetectorType, and message: MessageType, at indexPath: IndexPath) -> [NSAttributedString.Key : Any] {
        switch detector {
        case .hashtag, .mention: return [.foregroundColor: UIColor.blue]
        default: return MessageLabel.defaultAttributes
        }
    }

    func enabledDetectors(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> [DetectorType] {
        return [.url, .address, .phoneNumber, .date, .transitInformation, .mention, .hashtag]
    }

    func messageStyle(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageStyle {
        let tail: MessageStyle.TailCorner = isFromCurrentSender(message: message) ? .bottomRight : .bottomLeft
        return .bubbleTail(tail, .curved)
    }

    func cellBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 20
    }

    func messageBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return 20
    }

    func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) {
        let sender = message.sender
        let initials = sender.displayName.components(separatedBy: " ").compactMap({ $0.first }).map({ String($0) }).joined()
        avatarView.set(avatar: Avatar(initials: initials))
    }
}
```
- currentSender: 現在のユーザーを取得します。
- numberOfSections: メッセージのセクション数（ここではメッセージの数）を返します。
- messageForItem: 各インデックスに対応するメッセージを返します。
- messageTopLabelAttributedText: メッセージの送信者名を設定します。
- textColor: メッセージのテキスト色を設定します。
- detectorAttributes: メッセージ内のハッシュタグやメンションのスタイルを設定します。
- enabledDetectors: メッセージ内で検出する項目を設定します。
- messageStyle: メッセージのスタイルを設定します。
- cellBottomLabelHeight: メッセージの下部ラベルの高さを設定します。
- messageBottomLabelHeight: メッセージの下部ラベルの高さを設定します。
- configureAvatarView: メッセージの送信者のアバターを設定します。

### InputBarAccessoryViewDelegateの実装
```
extension ChatViewController: InputBarAccessoryViewDelegate {

    func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
        sendMessage(text: text)
        inputBar.inputTextView.text = ""
    }
}
```
- inputBar(_:didPressSendButtonWith:): 送信ボタンが押されたときに呼ばれるメソッドで、メッセージを送信し、入力フィールドをクリアします。

# 改修が必要な点
この記事では時間指定してチャットを送信できる機能を実装しているが、トリガーがfetchMessages()もしくはfirestoreのデータ変更時のみである。
そのため、指定した時間より前から画面を開いたまま待っていて、かつ他ユーザーもチャット送信しなかった場合、指定した時間になってもチャットは表示されない。
なので、完璧にこの機能を実装しようとした場合、別のトリガーを設定する必要がある。
例えば、FirestoreのFunctionsを利用して、サーバー側で送信予定時刻にメッセージが表示されるようにすることも考えられる。