Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
3993646
Added initial classes
dmitrysimkin Aug 5, 2025
fbbae89
Add preview app
dmitrysimkin Aug 5, 2025
7564174
Make VM be main actor
dmitrysimkin Aug 5, 2025
d5e5677
Added model and datasource
dmitrysimkin Aug 5, 2025
22b78bb
Code style
dmitrysimkin Aug 6, 2025
7563f13
Added way to add messages
dmitrysimkin Aug 6, 2025
45ce965
Code style
dmitrysimkin Aug 6, 2025
3f3270d
Generate with domain models and map to VMs
dmitrysimkin Aug 6, 2025
a9aa4ae
Format
dmitrysimkin Aug 6, 2025
a9cdbba
Add more comments and improve code
dmitrysimkin Aug 6, 2025
378a48f
Add some more comments
dmitrysimkin Aug 6, 2025
748c81a
Fixed build error
dmitrysimkin Aug 6, 2025
6029bac
Load real messages
dmitrysimkin Aug 7, 2025
4d96548
Access modificator
dmitrysimkin Aug 7, 2025
d5305e9
Get rid of UICOllectionView custom class
dmitrysimkin Aug 7, 2025
026afa9
Use background context to load messages from DB
dmitrysimkin Aug 7, 2025
9685eb0
Format
dmitrysimkin Aug 7, 2025
9d914d8
Code style
dmitrysimkin Aug 7, 2025
5e93e67
Remove identifiable
dmitrysimkin Aug 8, 2025
b40004f
Fixed memory leak
dmitrysimkin Aug 8, 2025
14b08b7
Format
dmitrysimkin Aug 8, 2025
f23d876
Added new message
dmitrysimkin Aug 11, 2025
f3c3142
Address PR comments
dmitrysimkin Aug 12, 2025
2443d4c
Make possible to pass sender changed publisher
dmitrysimkin Aug 12, 2025
e2deaaf
Format
dmitrysimkin Aug 12, 2025
8262a57
Use sync context to get notified when other send messages
dmitrysimkin Aug 12, 2025
9aa6e34
Fixed crash on scrolling
dmitrysimkin Aug 12, 2025
ecc1511
Code style
dmitrysimkin Aug 12, 2025
4c1316f
Merge branch 'feature/CB-3-handle-message-send-WPB-19373' into featur…
dmitrysimkin Aug 12, 2025
4a1df1f
Reworked to have name publisher and attributed string
dmitrysimkin Aug 12, 2025
4965f87
Subscribe to real sender name changes
dmitrysimkin Aug 12, 2025
ee901e0
Improve signatures
dmitrysimkin Aug 12, 2025
c17716b
Rename
dmitrysimkin Aug 13, 2025
4179871
Optimise making SenderObserver
dmitrysimkin Aug 13, 2025
eceae73
Rename
dmitrysimkin Aug 13, 2025
6d0e7ea
Format
dmitrysimkin Aug 13, 2025
c2f5166
Merge branch 'develop' into feature/CB-load-real-messages-WPB-19328
dmitrysimkin Aug 13, 2025
0d8ae4d
Merge branch 'develop' into feature/CB-load-real-messages-WPB-19328
dmitrysimkin Aug 13, 2025
2a9da12
Merge branch 'feature/CB-load-real-messages-WPB-19328' into feature/C…
dmitrysimkin Aug 13, 2025
3107cd6
Merge branch 'feature/CB-3-handle-message-send-WPB-19373' into featur…
dmitrysimkin Aug 13, 2025
a4b048a
Removed not need complextity
dmitrysimkin Aug 13, 2025
4472d45
Format
dmitrysimkin Aug 13, 2025
bc0b177
Merge branch 'develop' into feature/CB-3-handle-message-send-WPB-19373
dmitrysimkin Aug 14, 2025
56e0cb0
Remove prints
dmitrysimkin Aug 14, 2025
62392a0
Merge branch 'feature/CB-3-handle-message-send-WPB-19373' into featur…
dmitrysimkin Aug 14, 2025
3e2752f
Merge branch 'develop' into feature/CB-3-handle-message-send-WPB-19373
dmitrysimkin Aug 21, 2025
e5356aa
Fixed build issue
dmitrysimkin Aug 21, 2025
c869591
Merge branch 'feature/CB-3-handle-message-send-WPB-19373' into featur…
dmitrysimkin Aug 21, 2025
e12c337
Merge branch 'develop' into feature/CB-4-update-sender-name
dmitrysimkin Aug 21, 2025
419681f
Merge branch 'develop' into feature/CB-4-update-sender-name
netbe Oct 17, 2025
fd8d8e6
Merge branch 'develop' into feature/CB-4-update-sender-name
netbe Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,21 @@
public import UIKit
import WireMessagingUI
public import WireMessagingDomain
import Combine

public enum WireMessagingAssembly {

@MainActor
public static func makeConversationScreen(
loadMessagesRepo: any (LoadConversationMessagesRepositoryProtocol & MonitorMessagesRepositoryProtocol)
loadMessagesRepo: any (LoadConversationMessagesRepositoryProtocol & MonitorMessagesRepositoryProtocol),
senderNameObserverProvider: SenderNameObserverProvider?
) -> UIViewController {
ConversationMessagesViewController(
viewModel: ConversationMessagesViewModel(
dataSource: ConversationMessagesDataSource(
dataSource: ConversationDataSource(
loadMessagesUseCase: LoadConversationMessagesUseCase(repo: loadMessagesRepo),
monitorMessagesUseCase: MonitorMessagesUseCase(repo: loadMessagesRepo)
monitorMessagesUseCase: MonitorMessagesUseCase(repo: loadMessagesRepo),
senderNameObserverProvider: AnySenderNameObserverProvider(senderNameObserverProvider)
)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,26 @@

public import Foundation

// To be refined later
public struct UserModel: Sendable {
// To be refined later

// 'objectID' to abstract id from data layer hide behind abstract 'any Sendable'
// used as a way to map domain models back to data models
public let objectID: any Sendable

public let remoteIdentifier: UUID
public let name: String?
public let handle: String?

public init(remoteIdentifier: UUID, name: String?, handle: String?) {
public init(
objectID: any Sendable,
remoteIdentifier: UUID,
name: String?,
handle: String?
) {
self.remoteIdentifier = remoteIdentifier
self.name = name
self.handle = handle
self.objectID = objectID
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// Wire
// Copyright (C) 2025 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
public import Combine

public protocol SenderNameObserverProtocol {
var authorChangedPublisher: AnyPublisher<String, Never>? { get }
}

public typealias SenderNameObserverProvider = (UserModel?) -> (any SenderNameObserverProtocol)?
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,22 @@
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import SwiftUI
import UIKit
package import WireMessagingDomain

class MessageCollectionViewCell: UICollectionViewCell {
// Need to be wrapped to type eraser as @unchecked Sendable to be able to pass to datasource actor
// also performs mapping of domain model which is just raw string
// to UI model which is Attributed string
package struct AnySenderNameObserverProvider: @unchecked Sendable {

// reuse identifier for each message type
// one for now, later will be improved
static let reuseIdentifier = "MessageCollectionViewCell"
private var observerProvider: SenderNameObserverProvider?

var messageType: MessageType? {
didSet {
guard let messageType else { return }
switch messageType {
case let .text(viewModel):
let config = UIHostingConfiguration {
TextMessageView(viewModel: viewModel)
}
contentConfiguration = config
}
}
package init(
_ observerProvider: SenderNameObserverProvider?
) {
self.observerProvider = observerProvider
}

func get(for model: UserModel?) -> (any SenderNameObserverProtocol)? {
observerProvider?(model)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,52 +16,55 @@
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Combine
import Foundation
package import UIKit
package import WireMessagingDomain

package enum MessagesSection: Sendable {
package enum ConversationSection: Sendable {
// one section for now, later we'd have probably one section for a day
case main
}

package typealias MessagesSnapshot = NSDiffableDataSourceSnapshot<MessagesSection, MessageType>
package typealias ConversationSnapshot = NSDiffableDataSourceSnapshot<ConversationSection, ConversationElement>

package protocol ConversationMessagesDataSourceProtocol: Sendable {
func updatesStream() async -> AsyncStream<MessagesUpdate>
package protocol ConversationDataSourceProtocol: Sendable {
func makeUpdatesStream() async -> AsyncStream<MessagesUpdate>
func loadInitialMessages() async
func reset() async
}

/// Actor to synchronise access to all that needed to conversation screen
/// Does all calculations in background
package actor ConversationMessagesDataSource: @preconcurrency ConversationMessagesDataSourceProtocol {
package actor ConversationDataSource: @preconcurrency ConversationDataSourceProtocol {

// AsyncStream because Combine's AnyPublisher is not Sendable
// As it's a stream, has to be one subscriber only
private var updatesStreamContinuation: AsyncStream<MessagesUpdate>.Continuation?
package func updatesStream() async -> AsyncStream<MessagesUpdate> {
package func makeUpdatesStream() async -> AsyncStream<MessagesUpdate> {
let (stream, continuation) = AsyncStream.makeStream(of: MessagesUpdate.self)
updatesStreamContinuation = continuation
return stream
}

private let loadMessagesUseCase: any LoadConversationMessagesUseCaseProtocol
private let monitorMessagesUseCase: any MonitorMessagesUseCaseProtocol
private let senderNameObserverProvider: AnySenderNameObserverProvider

// here on later stages will be injected uses cases and
// provider to ask for publishers needed for View Models
package init(
loadMessagesUseCase: any LoadConversationMessagesUseCaseProtocol,
monitorMessagesUseCase: any MonitorMessagesUseCaseProtocol
monitorMessagesUseCase: any MonitorMessagesUseCaseProtocol,
senderNameObserverProvider: AnySenderNameObserverProvider
) {
self.loadMessagesUseCase = loadMessagesUseCase
self.monitorMessagesUseCase = monitorMessagesUseCase
self.senderNameObserverProvider = senderNameObserverProvider
}

// store cached message view models
private var messages: [MessageType] = []
private var snapshot = MessagesSnapshot()
private var snapshot = ConversationSnapshot()

private var observeTask: Task<Void, Never>?

Expand All @@ -76,7 +79,11 @@ package actor ConversationMessagesDataSource: @preconcurrency ConversationMessag
let messages = await loadMessagesUseCase.loadMessages(offset: 0)

snapshot.appendSections([.main])
snapshot.appendItems(messages.reversed().toUIModel())
snapshot.appendItems(
messages
.reversed()
.map { mapToUIModel($0) }
)
updatesStreamContinuation?.yield(.initiallyLoaded(snapshot))

subscribeToNotifications()
Expand All @@ -86,13 +93,36 @@ package actor ConversationMessagesDataSource: @preconcurrency ConversationMessag
for await event in monitorMessagesUseCase.messagesUpdatesStream {
switch event {
case let .inserted(model):
let uiModel = model.toUIModel()
let uiModel = mapToUIModel(model)
snapshot.appendItems([uiModel])
updatesStreamContinuation?.yield(.messageAdded(snapshot))
}
}
}

private func mapToUIModel(_ model: MessageModel) -> ConversationElement {
switch model.kind {
case let .text(textModel):
let senderState: SenderViewModel.State = if let name = model.sender?.name {
.exists(AttributedString(stringLiteral: name))
} else {
.empty
}
return ConversationElement.text(
TextMessageViewModel(
content: AttributedString(stringLiteral: textModel.text ?? ""),
senderViewModel: SenderViewModel(
state: senderState,
namePublisher: senderNameObserverProvider
.get(for: model.sender)?.authorChangedPublisher
)
)
)
default: fatalError()
}

}

package func reset() async {
// Need to be called to clean up subscription and avoid memory leak
observeTask?.cancel()
Expand Down Expand Up @@ -138,29 +168,3 @@ package actor ConversationMessagesDataSource: @preconcurrency ConversationMessag
}

}

extension [MessageModel] {
func toUIModel() -> [MessageType] {
map { $0.toUIModel() }
}
}

extension MessageModel {
func toUIModel() -> MessageType {
switch kind {
case let .text(textModel):
let senderState: SenderViewModel.State = if let name = sender?.name {
.exists(AttributedString(stringLiteral: name))
} else {
.empty
}
return MessageType.text(
TextMessageViewModel(
content: AttributedString(stringLiteral: textModel.text ?? ""),
senderViewModel: SenderViewModel(state: senderState)
)
)
default: fatalError()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import Foundation

package enum MessageType: Hashable, Sendable {
package enum ConversationElement: Hashable, Sendable {

case text(TextMessageViewModel)
// case image, video, system, etc
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ package import SwiftUI

package final class ConversationMessagesViewController: UIViewController {

typealias DataSource = UICollectionViewDiffableDataSource<MessagesSection, MessageType>
typealias DataSource = UICollectionViewDiffableDataSource<ConversationSection, ConversationElement>

let viewModel: any ConversationMessagesViewModelProtocol

Expand Down Expand Up @@ -64,7 +64,7 @@ package final class ConversationMessagesViewController: UIViewController {
}

private func observeUpdates() async {
let stream = await viewModel.updatesStream()
let stream = await viewModel.makeUpdatesStream()
for await update in stream {
switch update {
case let .initiallyLoaded(snapshot):
Expand Down Expand Up @@ -138,7 +138,7 @@ package final class ConversationMessagesViewController: UIViewController {
}
}

private func setContent(cell: UICollectionViewCell, message: MessageType) {
private func setContent(cell: UICollectionViewCell, message: ConversationElement) {
switch message {
case let .text(viewModel):
let config = UIHostingConfiguration {
Expand All @@ -164,9 +164,12 @@ private struct ConversationMessagesViewControllerPreview: UIViewControllerRepres
func makeUIViewController(context: Context) -> ConversationMessagesViewController {
ConversationMessagesViewController(
viewModel: ConversationMessagesViewModel(
dataSource: ConversationMessagesDataSource(
dataSource: ConversationDataSource(
loadMessagesUseCase: MockLoadConversationMessagesUseCaseProtocol(),
monitorMessagesUseCase: MockMonitorMessagesUseCaseProtocol()
monitorMessagesUseCase: MockMonitorMessagesUseCaseProtocol(),
senderNameObserverProvider: AnySenderNameObserverProvider { _ in
nil
}
)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import Foundation

@MainActor
package protocol ConversationMessagesViewModelProtocol {
func updatesStream() async -> AsyncStream<MessagesUpdate>
func makeUpdatesStream() async -> AsyncStream<MessagesUpdate>
func onViewReady()
func onWillDisappear()
}
Expand All @@ -31,14 +31,14 @@ package protocol ConversationMessagesViewModelProtocol {
// since DataSource is actor and works on background thread
package struct ConversationMessagesViewModel: ConversationMessagesViewModelProtocol {

private let dataSource: any ConversationMessagesDataSourceProtocol
private let dataSource: any ConversationDataSourceProtocol

package init(dataSource: any ConversationMessagesDataSourceProtocol) {
package init(dataSource: any ConversationDataSourceProtocol) {
self.dataSource = dataSource
}

package func updatesStream() async -> AsyncStream<MessagesUpdate> {
await dataSource.updatesStream()
package func makeUpdatesStream() async -> AsyncStream<MessagesUpdate> {
await dataSource.makeUpdatesStream()
}

package func onViewReady() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,27 @@
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import Foundation
package import Foundation
package import Combine

class SenderViewModel: ObservableObject {
package class SenderViewModel: ObservableObject {

enum State {
package enum State {
case empty
case exists(AttributedString)
}

@Published var state: State

init(state: State) {
private var cancellables: Set<AnyCancellable> = []

package init(
state: State,
namePublisher: AnyPublisher<String, Never>?
) {
self.state = state
namePublisher?.sink { [weak self] name in
self?.state = .exists(AttributedString(name))
}.store(in: &cancellables)
Copy link
Contributor

Choose a reason for hiding this comment

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

question: no risk of having a binding still active when a view is reused ? probably works differently in SwiftUI but on UIKit we could have these issues with reusable cells if we didn't clear out the subscriptions which could mess the UI.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import Foundation

package enum MessagesUpdate: Sendable {

case initiallyLoaded(MessagesSnapshot)
case messageAdded(MessagesSnapshot)
case initiallyLoaded(ConversationSnapshot)
case messageAdded(ConversationSnapshot)
// later to be added more updates like:
// loaded new messages, new or older
// re-sent failed message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ extension ZMMessage {
extension ZMUser {
func toDomain() -> UserModel {
UserModel(
objectID: objectID,
remoteIdentifier: remoteIdentifier,
name: name,
handle: handle
Expand Down
Loading
Loading