-
Notifications
You must be signed in to change notification settings - Fork 22
feat: Chat bubbles datasource - WPB-19058 #3430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
Test Results1 951 tests 1 924 ✅ 2m 24s ⏱️ Results for commit 748c81a. ♻️ This comment has been updated with latest results. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work @dmitrysimkin! Right now I've added a few comments to think about.
case text(TextMessageModel) | ||
} | ||
|
||
public let sender: UserModel |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: Do all messages have a sender? For example a system message?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True, it's optional in ZMMessage, will update in next PR
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thought: We could consider that even system messages have a sender (the system), but i guess if we are using UserModel as a sender that's not possible (and out of scope). It will make MessageModel a bit more standard and reusable tho. Anyway, i was just a thought that cross my mind when reviewing this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the model here shouldn't be concerned with messages, but rather cells. Cells are mostly for messages, but they can also be for other things like time dividers and even a loading cell at the top of the history. Perhaps we can do something like:
enum CellModel {
case systemMessage(SystemMessageModel)
case error(ErrorModel)
case userMessage(UserMessageModel)
case timeDivider(TimeDividerModel)
...
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Domain it should be about business models. For example if in real work for a generic message can have no sender it should be optional. But for a presentation layer for a specific message type such as text message for instance it should be a sender, so in UI layer it would non-optional
import WireMessagingDomain | ||
|
||
public enum MessagesSection: Sendable { | ||
// one section for now, later we'd have probably one section for a day |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thought: I like the idea that eventually sections will be per day. I was wondering how to do this in a lazy efficient way using NSFetchedResultsController. We can't just group by serverTimestamp
. I wonder it this could be done with a transient property 🤔. Anyway this is just me thinking and is out of scope for now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or perhaps when we set the timestamp we can compute its day and store to make it easier for the fetched results controller to group.
private var updatesStreamContinuation: AsyncStream<MessagesUpdate>.Continuation? | ||
public func updatesStream() async -> AsyncStream<MessagesUpdate> { | ||
AsyncStream { continuation in | ||
self.updatesStreamContinuation = continuation |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Does this creates a strong reference cycle? Anyway there is now a better way (in my opinion) of creating streams without closures:
let (stream, continuation) = AsyncStream.makeStream(of:MessagesUpdate.self)
updatesStreamContinuation = continuation
return stream
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll check, tnx for pointing it out. and I'll improve without closure
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was indeed a retain cycle, fixed in #3439
/// Does all calculations in background | ||
public actor ConversationMessagesDataSource: @preconcurrency ConversationMessagesDataSourceProtocol { | ||
|
||
// AsyncStream because Combine's AnyPublisher is not Sendable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thought: One thing to be careful of with AsyncStream is that it each update can only be read once - this is fine if there is only a single consumer of updatesStream() but if there are multiple they will miss updates. Personally I think it might be better to use a publisher internally. I believe these can easily be converted to streams via the publishers values
property. However behavior here needs to be confirmed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer publisher as well but it does not have Sendable
conformance out of the box. I'll monitor its behaviour while i'd be working on it in next PRs and change/fix is there would be issues. Thanks for the concern
|
||
/// Actor to synchronise access to all that needed to conversation screen | ||
/// Does all calculations in background | ||
public actor ConversationMessagesDataSource: @preconcurrency ConversationMessagesDataSourceProtocol { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
praise: actor is a good choice.
func loadMessages( | ||
near message: MessageModel, | ||
forceRecalculate: Bool = false, | ||
) async -> IndexPath { | ||
.init() | ||
} | ||
|
||
// load older messages | ||
func loadOlderMessages() {} | ||
|
||
// load newer messages | ||
func loadNewerMessages() {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thought: I'm wondering if paging is the right way to go. A properly configured NSFetchedResultsController doesn't need it and will do things lazily and efficiently. One reason where paging can be awkward can be if we want to jump around in history - e.g. a jump to start of conversation feature. This is of course doable but might be a bit more complex to implement. NSFetchedResultsController also does not require diffing but still emits NSDiffableDataSourceSnapshot
These two approaches will be require quite a different API though. The NSFetchedResultsController approach would be more similar to a classic UITableViewDataSource.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah Interesting to think about it. i just made same interface as we have in old datasource. Let me try find more effecient way working with NSFRC at later stages of implementation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@samwyndham are you suggesting to let the fetched results controller load all messages of the conversation? How does it do that efficiently if we only really will look at the last 50 unless we scroll back in time?
import SwiftUI | ||
import UIKit | ||
|
||
class MessageCollectionViewCell: UICollectionViewCell { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: No reason to subclass. Use a regular UICollectionViewCell and configure it withe help methods. That way we also don't need the casting after dequeuing the cell.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got rid of MessageCollectionViewCell in #3439
withReuseIdentifier: MessageCollectionViewCell.reuseIdentifier, | ||
for: indexPath | ||
) as? MessageCollectionViewCell else { | ||
return UICollectionViewCell() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Crashing is preferable if we get a random cell type as we can't do anything with it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No need to crash after using UICollectionviewCell
case text(TextMessageModel) | ||
} | ||
|
||
public let sender: UserModel |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the model here shouldn't be concerned with messages, but rather cells. Cells are mostly for messages, but they can also be for other things like time dividers and even a loading cell at the top of the history. Perhaps we can do something like:
enum CellModel {
case systemMessage(SystemMessageModel)
case error(ErrorModel)
case userMessage(UserMessageModel)
case timeDivider(TimeDividerModel)
...
}
import SwiftUI | ||
public import Foundation | ||
|
||
public class TextMessageViewModel: ObservableObject, Identifiable, Hashable, @unchecked Sendable { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: is Identifiable
and Hashable
necessary for diffable datasource?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hashable
constraint was added by UICollectioViewDiffableDataSource
, but Identifiable looks like an artefact from my POC tries, I'll remove it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed Identifiable in #3439
import WireMessagingDomain | ||
|
||
public enum MessagesSection: Sendable { | ||
// one section for now, later we'd have probably one section for a day |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or perhaps when we set the timestamp we can compute its day and store to make it easier for the fetched results controller to group.
public typealias MessagesSnapshot = NSDiffableDataSourceSnapshot<MessagesSection, MessageType> | ||
|
||
public protocol ConversationMessagesDataSourceProtocol: Sendable { | ||
func updatesStream() async -> AsyncStream<MessagesUpdate> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: streamMessageUpdates
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why stream at the beginning? usually it's prefix like ...Publisher
or ...Subject
and it's not singe message updates but plural messages updates. it feels to me still updatesStream
might be better name, wdyt @johnxnguyen ?
func loadMessages( | ||
near message: MessageModel, | ||
forceRecalculate: Bool = false, | ||
) async -> IndexPath { | ||
.init() | ||
} | ||
|
||
// load older messages | ||
func loadOlderMessages() {} | ||
|
||
// load newer messages | ||
func loadNewerMessages() {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@samwyndham are you suggesting to let the fetched results controller load all messages of the conversation? How does it do that efficiently if we only really will look at the last 50 unless we scroll back in time?
|
||
@MainActor | ||
public protocol ConversationMessagesViewModelProtocol { | ||
func updatesStream() async -> AsyncStream<MessagesUpdate> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: streamMessageUpdates
// later to be added more updates like: | ||
// loaded new messages, new or older | ||
// re-sent failed message | ||
// to be added other updates that happens to a conversation view |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: can you elaborate a bit more on this? For example, how was a failedMessage
update be handled?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to message added, if failed message resent successfully it will update internal data in datasource and will emit new snapshot, where new message will be moved from it's original place to the end of the conversation
For now I added different cases and they all (2) have just snapshot, so probably specific cases might not be need in the future but I'd think in UI we'd might need to introduce some side effect based on specific update case
let stream = await viewModel.updatesStream() | ||
for await update in stream { | ||
switch update { | ||
case let .initiallyLoaded(snapshot): | ||
await dataSource.apply(snapshot) | ||
case let .messageAdded(snapshot): | ||
await dataSource.apply(snapshot) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: how would you envision this working with a SwiftUI list? Just wondering when the time comes, what changes we would make to connect the datasource.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We already have list of view models inside snapshot and in case of SwiftUI we'd just use list of view models. Or we can map steam to some other @published property of a state or smth like this
Issue
Created a skeleton for new conversation screen datasource, for now with mock data.
Key points:
ConversationMessagesViewModel
as MainActor to serve the view on main threadConversationMessagesDataSource
as actor so it automatically work on background thread as we need all messages calculations be offloaded from main threadConversationMessagesDataSource
emits updates withNSDiffableDataSourceSnapshot
in order to do as much as possible on background thread and give UI ready snapshot to just applyMessageModel
andUserModel
in domain layerChatBubbles
preview app for easier developmentTesting
Run ChatBubbles preview app (new scheme created)
Checklist
[WPB-XXX]
.UI accessibility checklist
If your PR includes UI changes, please utilize this checklist: