Skip to content

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

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from

Conversation

dmitrysimkin
Copy link
Contributor

@dmitrysimkin dmitrysimkin commented Aug 6, 2025

StoryWPB-19058 [iOS] Setup ConversationView new datasource

Issue

Created a skeleton for new conversation screen datasource, for now with mock data.

Key points:

  • Created ConversationMessagesViewModel as MainActor to serve the view on main thread
  • Created ConversationMessagesDataSource as actor so it automatically work on background thread as we need all messages calculations be offloaded from main thread
  • ConversationMessagesDataSource emits updates with NSDiffableDataSourceSnapshot in order to do as much as possible on background thread and give UI ready snapshot to just apply
  • Added initial models MessageModel and UserModel in domain layer
  • Created ChatBubbles preview app for easier development

Testing

Run ChatBubbles preview app (new scheme created)

Checklist

  • Title contains a reference JIRA issue number like [WPB-XXX].
  • Description is filled and free of optional paragraphs.
  • Adds/updates automated tests.

UI accessibility checklist

If your PR includes UI changes, please utilize this checklist:

  • Make sure you use the API for UI elements that support large fonts.
  • All colors are taken from WireDesign.ColorTheme or constructed using WireDesign.BaseColorPalette.
  • New UI elements have Accessibility strings for VoiceOver.

Copy link
Contributor

github-actions bot commented Aug 6, 2025

Test Results

1 951 tests   1 924 ✅  2m 24s ⏱️
  321 suites     27 💤
    2 files        0 ❌

Results for commit 748c81a.

♻️ This comment has been updated with latest results.

Copy link
Contributor

@samwyndham samwyndham left a 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
Copy link
Contributor

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?

Copy link
Contributor Author

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

Copy link
Collaborator

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.

Copy link
Collaborator

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)
  ...

}

Copy link
Contributor Author

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
Copy link
Contributor

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

Copy link
Collaborator

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
Copy link
Contributor

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

Copy link
Contributor Author

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

Copy link
Contributor Author

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
Copy link
Contributor

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.

Copy link
Contributor Author

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 {
Copy link
Contributor

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.

Comment on lines +76 to +87
func loadMessages(
near message: MessageModel,
forceRecalculate: Bool = false,
) async -> IndexPath {
.init()
}

// load older messages
func loadOlderMessages() {}

// load newer messages
func loadNewerMessages() {}
Copy link
Contributor

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.

Copy link
Contributor Author

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

Copy link
Collaborator

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 {
Copy link
Contributor

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.

Copy link
Contributor Author

@dmitrysimkin dmitrysimkin Aug 7, 2025

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()
Copy link
Contributor

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.

Copy link
Contributor Author

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
Copy link
Collaborator

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 {
Copy link
Collaborator

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?

Copy link
Contributor Author

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

Copy link
Contributor Author

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
Copy link
Collaborator

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>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: streamMessageUpdates

Copy link
Contributor Author

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 ?

Comment on lines +76 to +87
func loadMessages(
near message: MessageModel,
forceRecalculate: Bool = false,
) async -> IndexPath {
.init()
}

// load older messages
func loadOlderMessages() {}

// load newer messages
func loadNewerMessages() {}
Copy link
Collaborator

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>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: streamMessageUpdates

Comment on lines +25 to +28
// 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
Copy link
Collaborator

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?

Copy link
Contributor Author

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

Comment on lines +58 to +66
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)
}
}
Copy link
Collaborator

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.

Copy link
Contributor Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants