-1

I have a chat but i don't want to show all the messages at once because it's laggy. When i click on the chat i want to show the last 20 messages and everytime i scroll i want to fetch 20 older messages, that's why i'm using a query limitation. When i start loading the view, the last 20 messages are showing fine but everytime i scroll, nothing happens, it shows and print the same last 20 messages instead of displaying the 20 older one. I don't know how to insert the new messages correctly inside my collectionView, here's what i've tried so far:

[UPDATED]RoomMessageViewController

    var lastDocumentSnapshot: DocumentSnapshot!
    var fetchingMore = false
   private var messages = [RoomMessage]()
   private var chatMessages = [[RoomMessage]]()

override func viewDidLoad() {
        super.viewDidLoad()
        loadMessages()
}
// MARK: - Helpers

    fileprivate func attemptToAssembleGroupedMessages(completion: (Bool) -> ()){
            chatMessages.removeAll()
            let groupedMessages = Dictionary(grouping: messages) { (element) -> Date in
                return element.timestampDate.reduceToMonthDayYear() }
            // provide a sorting for the keys
            let sortedKeys = groupedMessages.keys.sorted()
            sortedKeys.forEach { (key) in
                let values = groupedMessages[key]
                chatMessages.append(values ?? [])
                self.collectionView.reloadData()
            }
        completion(true)
        }
 // MARK: - API

       func loadMessages() {
           var query: Query!
           guard let room = room else{return}
           guard let roomID = room.recentMessage.roomID else{return}
           
           showLoader(true)
           fetchingMore = true

           if messages.isEmpty {
               query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).limit(toLast: 20)
               print("First 10 msg loaded")
           } else {
               query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).end(beforeDocument: lastDocumentSnapshot).limit(toLast: 20)
               print("Next 10 msg loaded")
           }
           query.addSnapshotListener { querySnapshot, error in
               guard let snapshot = querySnapshot else {
                   print("Error fetching snapshots: \(error!)")
                   return
               }
               guard let lastSnap = snapshot.documents.first else {return}
               self.lastDocumentSnapshot = lastSnap

               snapshot.documentChanges.forEach({ (change) in
                   if change.type == .added {
                       let dictionary = change.document.data()
                       let timestamp = dictionary["timestamp"] as? Timestamp
                       var message = RoomMessage(dictionary: dictionary)
     
                       self.messages.append(message)
                       self.messages.sort(by: { $0.timeStamp.compare($1.timeStamp) == .orderedAscending })
                       self.collectionView.reloadData()
                   }
                   self.attemptToAssembleGroupedMessages { (assembled) in
                       if assembled {
                       }
                   }
                   self.lastDocumentSnapshot = snapshot.documents.first
               })
           }
       }
}
extension RoomMessageViewController {
    override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    let contentOffset = scrollView.contentOffset.y
    if contentOffset <= -40 {
        loadMessages()
    }
}
dohoudjann
  • 67
  • 1
  • 8

2 Answers2

1
func loadMessages() {
           var query: Query!
           guard let room = room else{return}
           guard let roomID = room.recentMessage.roomID else{return}
           
           showLoader(true)
           fetchingMore = true

           if messages.isEmpty {
               query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).limit(toLast: 20)
               print("First 10 msg loaded")
           } else {
               query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).end(beforeDocument: lastDocumentSnapshot).limit(toLast: 20)
               print("Next 10 msg loaded")
           }
           query.addSnapshotListener { querySnapshot, error in
               guard let snapshot = querySnapshot else {
                   print("Error fetching snapshots: \(error!)")
                   return
               }
               guard let lastSnap = snapshot.documents.first else {return}
               self.lastDocumentSnapshot = lastSnap

               snapshot.documentChanges.forEach({ (change) in
                   if change.type == .added {
                       let dictionary = change.document.data()
                       let timestamp = dictionary["timestamp"] as? Timestamp
                       var message = RoomMessage(dictionary: dictionary)
     
                       self.messages.append(message)
                       self.messages.sort(by: { $0.timeStamp.compare($1.timeStamp) == .orderedAscending })
                       self.collectionView.reloadData()
                   }
                   self.attemptToAssembleGroupedMessages { (assembled) in
                       if assembled {
                       }
                   }
                   self.lastDocumentSnapshot = snapshot.documents.first
               })
           }
       }
}
dohoudjann
  • 67
  • 1
  • 8
  • This can solve your issue but it is a bad implementation. In a real project, we will not do this. – Neklas Sep 06 '22 at 22:15
  • Why ? It works as intended and doesn't make extra calls – dohoudjann Sep 06 '22 at 22:19
  • As I mentioned above, It solves your issue but it is a bad implementation. In my answer, we will fetch a list and observer a new one, this solution is used for a realtime chat with history loading. - function addSnapshotListener is a observer, not one-time fetching. It worked for you because your [query] variable is deallocated right after loadMessage() ends. So it is just triggered once. - You are using self.lastDocumentSnapshot = lastSnap to save the index, but it is not recommended to save the whole object, you just need to save the [timestamp]. – Neklas Sep 06 '22 at 22:26
  • You can see I am using [weak self] and then unwrapping it by null-checking. This will avoid retaining cycle that causes memory-leak, memory leak is very bad for user if RAM is consumed but app can't control it. System can terminate your app if consume too much RAM. – Neklas Sep 06 '22 at 22:30
  • Wow ok didn't think of this, i will modify my code tomorrow and keep u updated, thank you ! – dohoudjann Sep 06 '22 at 22:37
  • Good luck, man! I am waiting to chat with you using your app. – Neklas Sep 06 '22 at 22:44
  • Thanks man i hope so haha, anyway i tried to make your code work but ran into a lot of error and can't get it to work in my project, i will try later to implement another method following your logic (only observing the new messages instead of fetching them everytime i open the view). – dohoudjann Sep 07 '22 at 13:31
  • I got it. Some blocks of code are used for guiding you about how it will work. It is not used to copy and paste into your code. I pointed out the what are the same things with my codes and your current logic. Please try to understand the mechanic, it will be better for you. – Neklas Sep 07 '22 at 15:38
0

Here is my asset fetching function. You can see I am using limit logic, at the line limit(to: AlbumRepository.assetPageSize). Then you will need an "index" to fetch documents right before/after your "index".

Ex: your message will have a prop named created_date. We consider that asset is your message, albumDocId is your chatRoomId, asset_created is your message's timestamp.

So we will:

  • Query all documents that have created_date older than "index" (index here is a Date in second or TimeStamp). Line: whereField("asset_created", isLessThanOrEqualTo: lastCreatedForQuery)
  • Sort the result from newest to oldest by using created_date. Line: order(by: "asset_created", descending: true) to sort by asset_created ASC. (ASC when you display newest at the bottom, DESC when you display newest at the top)
  • Now we limit the number of returned documents to x items. Line: limit(to: AlbumRepository.assetPageSize), my value is 20 items.

After you get the first batch with index is now, just save the created_date of the last document in the returned list (from query's response). Then put in whereField("asset_created", isLessThanOrEqualTo: {index}). Last item on the list will be the oldest message.

// MARK: - Asset repo functions
static let assetPageSize = 20
func fetchAsset(albumDocId id: String, lastCreated: TimeInterval?, done: @escaping ([FSAssetModel]) -> Void) {
    let lastCreatedForQuery = lastCreated ?? Date().timeIntervalSince1970
    FirebaseFirestoreManager.db
        .collection(tableName)
        .document(id)
        .collection("asset")
        .whereField("asset_created", isLessThan: lastCreatedForQuery)
        .order(by: "asset_created", descending: false)
        .limit(to: AlbumRepository.assetPageSize)
        .getDocuments() { (querySnapshot, err) in
            if let err = err {
                print("Error getting documents: \(err)")
                done([])
            } else {
                var finalResult: [FSAssetModel] = []
                for document in querySnapshot!.documents {
                    if let asset = FSAssetModel(JSON: document.data()) {
                        asset.doc_id = document.documentID
                        finalResult.append(asset)
                    }
                }
                done(finalResult)
            }
        }
}

Next, in your message screen, you have an array messages, to use it as dataSource. And, private var _lastCreatedOfMessage: Date?

// When you open your message screen, you will call this function to fetch last x message, 
// and the first time you call this function, you have no _lastCreatedOfMessage yet, it will be null.
// That means we will last x message from NOW
// You can check this line in the fetch method:
// let lastCreatedForQuery = lastCreated ?? Date().timeIntervalSince1970
// albumDocId: "ABCD1234" -> it will be your room id
// You will call this method anytime the user scrolls to the top/bottom to load history messages, after the `firstLoadMessages()` when you open screen.

fetchAsset(albumDocId: "ABCD1234", lastCreated: self._lastCreatedOfMessage) { [weak self] messages
    guard let _self = self else, !messages.isEmpty { return }
     
    // 1) Save your lastCreated for next fetch/query
    // We order by created_date ASC so the first item will be oldest
    // It will be used for next list [history message] fetching
    _self.lastCreatedOfMessage = items.first!.created_date

    // 2) Now we insert this message list to begin of your message list, that is dataSource for displaying
    _self.yourMessageList.insert(contentsOf: messages, at: 0)

    // 3) Reload your list, then scroll to the top or first indexPath
    // Because you are dragging the listView down to see the older message
    // That is why you use order by created_date [ASC]
    _self.yourListView.reloadData() // ex: UICollectionView
    _self.yourListView.scrollToItem(at: IndexPath.init(item: 0, section: 0), at: .top, animated: true)
    // or more simple trick
    // _self.yourListView.setContentOffset(.zero, animated: true)

}

Finally, to update your observer logic, you just need to observe the last new/changed message. Whenever you have a new incoming message, append it to your message list.

THE MOST REASONABLE LOGIC IS:

  1. Open screen, fetch the list of last x messages,
  2. When you get the list of last x messages, initialize your observer now, we will observe for any new message that has timestamp that is newer than the newest message from the above list.
static func observeNewMessage(roomId: String, lastTimeStamp: TimeInterval, completion: @escaping(RoomMessage?, Error?) -> Void) -> Query {
    
    let now = Date().timeIntervalSince1970
    let query = COLLECTION_ROOMS.document(roomId)
        .collection("messages")
        .whereField("timestamp", isGreaterThan: lastTimeStamp) // only take message that newer than lastTimeStamp
        .order(by: "timestamp", descending: false)
        .limit(toLast: 1)

    // You will need to retain this query instance to keep observing for new message until you close message screen.
    query.addSnapshotListener { querySnapshot, error in
        guard let snapshot = querySnapshot else {
            print("Error fetching snapshots: \(error!)")
            return
        }
        
        snapshot.documentChanges.forEach { change in
            
            let dictionary = change.document.data()
            var message = RoomMessage(dictionary: dictionary)
            
            if (change.type == .added) {
                completion(message, nil)
                print("Added msg: \(message.text)")
            }
            if (change.type == .modified) {
                print("Modified msg: \(message.text)")
            }
            if (change.type == .removed) {
                print("Removed msg: \(message.text)")
            }
        }
    }
    
    return query
}

How to use:

private var newMessageQueryListener: Query? // this variable is used to retain query [observeNewMessage]

// You only call this function once when open screen
func firstLoadMessages() { 
    showLoader(true)
    guard let room = room else, let roomId = room.recentMessage.roomID { return }

    fetchAsset(albumDocId: "ABCD1234", lastCreated: self._lastCreatedOfMessage) { [weak self] messages
    guard let _self = self else, !messages.isEmpty { return }
     
    // 1) Save your lastCreated for next fetch/query
    // We order by created_date ASC so the first item will be oldest
    // It will be used for next list [history message] fetching
    _self.lastCreatedOfMessage = items.first!.timestamp

    // 2) Now we insert this message list to begin of your message list, that is dataSource for displaying
    _self.yourMessageList.insert(contentsOf: messages, at: 0)

    // 3) Reload your list, then scroll to the top or LAST indexPath
    // Because user just open screen not loading older messages
    // ALWAYS call reload data before you do some animation,...
    _self.yourListView.reloadData() // ex: UICollectionView

    // Scroll to last index with position: bottom for newest message
    let lastIndex = _self.messages.count - 1
    _self.yourListView.scrollToItem(at: IndexPath.init(item: lastIndex, section: 0), at: .botom, animated: true)

    // 4) Setup your observer for new message here
    let lastTimeStamp = items.last!.timestamp
    _self.makeNewMessageObserver(roomId: roomId, lastTimeStamp: lastTimeStamp)
    }
}

private func makeNewMessageObserver(roomId: String, lastTimeStamp: TimeInterval) {
    self.newMessageQueryListener = RoomService.observeNewMessage(roomId: roomId, lastTimeStamp: lastTimeStamp) { [weak self] newMess, error in
        // DO NOT CALL self or REFER self in the block/closure IF you dont know its life-cycle.
        // [weak self] will make an optional refering to self to prevent memory-leak.
        guard let _self = self else { return } // here we check if self(MessageScreen) is null or not, if not null we continue
        _self.messages.append(newMess) // ASC so the last item in list will be newest message
        
        // Reload then scroll to the bottom for newest message
        DispatchQueue.main.async {
            _self.collectionView.reloadData()
            let lastIndex = _self.messages.count - 1
            _self.collectionView.scrollToItem(at: IndexPath(item: lastIndex, section: 0), at: .bottom, animated: true)
        }
    }
}

The rule is: Fetch a list, but observe one.

Neklas
  • 504
  • 1
  • 9
  • Hi, ok i tried to reproduce what you said and put a function who query the last 20 msg inside the viewdidLoad. After that i created and placed inside my scrollView another function to fetch the 20 msg before that with the `isLessThanOrEqualTo:timestampDate` but it keep showing the last 20 messages since i base my querying on the same last 20 messages (inside my struct). I don't know how i can append my new list of timestampDate everytime i scroll without erasing the older list... – dohoudjann Sep 02 '22 at 15:23
  • Ok i did some adjustements, now when i scroll i'm able to fetch the 20 oldest messages instead of the 20 before... (i edited my code). – dohoudjann Sep 02 '22 at 16:10
  • @dohoudjann Thank you for letting me know about it. I updated my answer for the complete solution. We will fetch a list but observe one for a new message. – Neklas Sep 05 '22 at 23:55
  • Thank you for your detailed answer, i finally found a solution and it's working, the last messages are loading and when i scroll it loads the older messages. Problem is when i switch view (press back button) then come again inside the chat, either all the messages disappear or it loads the messages of another roomChat (i've updated my code). I think it's because of the scope of my `var messages: [RoomMessage]` but i'm struggling to solve this bug since i'm doing mvvm... – dohoudjann Sep 06 '22 at 12:52
  • @dohoudjann I checked your solution but do not use the whole document object as index, it will cause issues on ordering or if you need to query by specific property/attribute, you need to write all the code again. – Neklas Sep 06 '22 at 21:39
  • 1
    @dohoudjann Sorry I need to separate the comment because it is too long. In your function: fileprivate func attemptToAssembleGroupedMessages(completion: (Bool) -> ()). You call completion() inside for-each, it incorrect logic flow. Call it outside the for-each, after for-each ends. You are using snapshot listener to get a list, but it is not the correct implementation. I mentioned the rule: Fetch list but observe one. So please read my answer carefully. About the bug, It is not your MVVM problem, it is your logic problem. Please review the room object passing. – Neklas Sep 06 '22 at 21:42
  • You're right, i edited the completion and placed a sort just before reloading the collectionView and finally managed to get it working (see the final edit), the only thing is, it's not mvvm :P – dohoudjann Sep 06 '22 at 21:52