Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,18 @@ public enum WireMessagingAssembly {
@MainActor
public static func makeConversationScreen(
loadMessagesRepo: any (LoadConversationMessagesRepositoryProtocol & MonitorMessagesRepositoryProtocol),
senderNameObserverProvider: SenderNameObserverProvider?
senderNameObserverProvider: SenderNameObserverProvider?,
reactionsObserverProvider: ReactionsObserverProvider?
Comment on lines +29 to +30
Copy link
Contributor

Choose a reason for hiding this comment

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

question: I'm not sure why these are optional but maybe I'm missing something?

) -> UIViewController {
ConversationMessagesViewController(
viewModel: ConversationMessagesViewModel(
dataSource: ConversationDataSource(
loadMessagesUseCase: LoadConversationMessagesUseCase(repo: loadMessagesRepo),
monitorMessagesUseCase: MonitorMessagesUseCase(repo: loadMessagesRepo),
senderNameObserverProvider: AnySenderNameObserverProvider(senderNameObserverProvider)
observersProvider: AnyObserverProvider(
senderNameObserverProvider: senderNameObserverProvider,
reactionsObserverProvider: reactionsObserverProvider
)
)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public protocol LoadConversationMessagesRepositoryProtocol: Sendable {
func loadMessages(offset: Int, limit: Int) async -> [MessageModel]
}

private let kLoadMessagesDefaultBatchSize = 30 // Magic number: amount of messages per screen (upper bound).
private let kLoadMessagesDefaultBatchSize = 30

package protocol LoadConversationMessagesUseCaseProtocol: Sendable {
func loadMessages(offset: Int) async -> [MessageModel]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
// along with this program. If not, see http://www.gnu.org/licenses/.
//

public typealias ReactionsModel = [String: [UserModel]]

public struct MessageModel: Sendable {

public enum Kind: Sendable {
Expand All @@ -24,12 +26,25 @@ public struct MessageModel: Sendable {
case text(TextMessageModel)
}

public let objectID: any Sendable
public let sender: UserModel?
public let kind: Kind
public let reactions: ReactionsModel

public init(sender: UserModel?, kind: Kind) {
public init(
objectID: any Sendable,
sender: UserModel?,
kind: Kind,
reactions: ReactionsModel
) {
self.objectID = objectID
self.sender = sender
self.kind = kind
self.reactions = reactions
}

public func hasReactions() -> Bool {
reactions.hasReactions()
}
}

Expand All @@ -45,3 +60,11 @@ public struct TextMessageModel: Sendable {
self.text = text
}
}

public extension ReactionsModel {
func hasReactions() -> Bool {
contains { _, users in
!users.isEmpty
}
}
}
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 ReactionsObserverProtocol {
var reactionsPublisher: AnyPublisher<ReactionsModel, Never>? { get }
Copy link
Contributor

Choose a reason for hiding this comment

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

question: Same question here. Why is the result optional?

}

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

import Combine
package import WireMessagingDomain

// 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 {
package struct AnyObserverProvider: @unchecked Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Rename ObserverProviderbecause:

  1. I think the fact that it is type erasing is kind of an implementation detail.
  2. I imagine Any type erasers to be very generic objects but this isn't.


private var observerProvider: SenderNameObserverProvider?
package let senderNameObserverProvider: SenderNameObserverProvider?
package let reactionsObserverProvider: ReactionsObserverProvider?

package init(
_ observerProvider: SenderNameObserverProvider?
senderNameObserverProvider: SenderNameObserverProvider?,
reactionsObserverProvider: ReactionsObserverProvider?
) {
self.observerProvider = observerProvider
self.senderNameObserverProvider = senderNameObserverProvider
self.reactionsObserverProvider = reactionsObserverProvider
}

func get(for model: UserModel?) -> (any SenderNameObserverProtocol)? {
observerProvider?(model)
func get(for message: MessageModel) -> AnyPublisher<ReactionsModel, Never>? {
reactionsObserverProvider?(message)?.reactionsPublisher
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,18 @@ package actor ConversationDataSource: @preconcurrency ConversationDataSourceProt

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

// 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,
senderNameObserverProvider: AnySenderNameObserverProvider
observersProvider: AnyObserverProvider
) {
self.loadMessagesUseCase = loadMessagesUseCase
self.monitorMessagesUseCase = monitorMessagesUseCase
self.senderNameObserverProvider = senderNameObserverProvider
self.observersProvider = observersProvider
}

// store cached message view models
Expand Down Expand Up @@ -108,13 +108,18 @@ package actor ConversationDataSource: @preconcurrency ConversationDataSourceProt
} else {
.empty
}

return ConversationElement.text(
TextMessageViewModel(
content: AttributedString(stringLiteral: textModel.text ?? ""),
senderViewModel: SenderViewModel(
state: senderState,
namePublisher: senderNameObserverProvider
.get(for: model.sender)?.authorChangedPublisher
namePublisher: observersProvider
.senderNameObserverProvider?(model.sender)?.authorChangedPublisher
),
reactionsViewModel: ReactionsViewModel(
state: ReactionsViewModel.state(from: model.reactions),
publisher: observersProvider.get(for: model)
)
)
)
Expand Down Expand Up @@ -158,8 +163,6 @@ package actor ConversationDataSource: @preconcurrency ConversationDataSourceProt

// MARK: - Handle notifications about something changed

// here will be subscribed to any messages updates notifications
// and start processing them
private func subscribeToNotifications() {
observeTask = Task { [weak self] in
guard let self else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,10 @@ private struct ConversationMessagesViewControllerPreview: UIViewControllerRepres
dataSource: ConversationDataSource(
loadMessagesUseCase: MockLoadConversationMessagesUseCaseProtocol(),
monitorMessagesUseCase: MockMonitorMessagesUseCaseProtocol(),
senderNameObserverProvider: AnySenderNameObserverProvider { _ in
nil
}
observersProvider: AnyObserverProvider(
senderNameObserverProvider: { _ in nil },
reactionsObserverProvider: { _ in nil }
)
)
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// 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 Combine
import Foundation
import WireMessagingDomain

class ReactionsViewModel: ObservableObject {
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Make final


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

@Published var state: State
Copy link
Contributor

Choose a reason for hiding this comment

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

nitpick: This could be @Published var state: ReactionsModel. It saves some boilerplate although if there are going to be more states the current version is certainly better.


private var cancellables: Set<AnyCancellable> = []

init(
state: State,
publisher: AnyPublisher<ReactionsModel, Never>?
) {
self.state = state
publisher?.sink { [weak self] reactions in
self?.state = Self.state(from: reactions)
}.store(in: &cancellables)
}

static func state(from reactions: ReactionsModel) -> State {
reactions.hasReactions() ? .exists(reactions) : .empty
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// 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 SwiftUI

struct ReactionsView: View {

@ObservedObject var viewModel: ReactionsViewModel

var body: some View {
HStack(spacing: 0) {
switch viewModel.state {
case .empty:
EmptyView()
case let .exists(reactions):
Text(reactions.map { "\($0.0): \($0.1.count)"
}.joined(separator: " "))
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}

}
}

import WireMessagingDomain

#Preview {
ReactionsView(
viewModel: .init(
state: .exists(
["😂": [
UserModel(
objectID: UUID(),
remoteIdentifier: UUID(),
name: "User",
handle: "@handle"
)
]]
),
publisher: nil
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ import SwiftUI

struct SenderMessageView: View {

@ObservedObject var model: SenderViewModel
@ObservedObject var viewModel: SenderViewModel

var body: some View {
HStack(spacing: 0) {
switch model.state {
switch viewModel.state {
case .empty:
EmptyView()
case let .exists(name):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ struct TextMessageView: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {

SenderMessageView(model: viewModel.senderViewModel)
SenderMessageView(viewModel: viewModel.senderViewModel)

text

ReactionsView(viewModel: viewModel.reactionsViewModel)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@ package class TextMessageViewModel: ObservableObject, Hashable, @unchecked Senda
@Published var content: AttributedString

@Published var senderViewModel: SenderViewModel
@Published var reactionsViewModel: ReactionsViewModel

init(
content: AttributedString,
senderViewModel: SenderViewModel
senderViewModel: SenderViewModel,
reactionsViewModel: ReactionsViewModel
) {
self.content = content
self.senderViewModel = senderViewModel
self.reactionsViewModel = reactionsViewModel
}

package static func == (lhs: TextMessageViewModel, rhs: TextMessageViewModel) -> Bool {
Expand Down
11 changes: 10 additions & 1 deletion WirePreviewApps/WIreChatBubbles/GenerateMessagesRepo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,27 @@ import WireMessagingDomain

struct GenerateMessagesRepo: LoadConversationMessagesRepositoryProtocol {

var messagesUpdatesStream: AsyncStream<MessagesUpdate> = AsyncStream { _ in

}

func loadMessages(offset: Int, limit: Int) async -> [MessageModel] {
let base = "This is a line. "
return (0 ..< 7).map { _ in
let repeatCount = Int.random(in: 1 ... 5)
return MessageModel(
objectID: UUID(),
sender: .init(
objectID: UUID(),
remoteIdentifier: .init(),
name: "Sender",
handle: nil
),
kind: .text(.init(text: String(repeating: base, count: repeatCount)))
kind: .text(.init(text: String(repeating: base, count: repeatCount))),
reactions: [:]
)
}
}
}

extension GenerateMessagesRepo: MonitorMessagesRepositoryProtocol {}
4 changes: 3 additions & 1 deletion WirePreviewApps/WIreChatBubbles/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
window = UIWindow(windowScene: windowScene)
window?.rootViewController = UINavigationController(
rootViewController: WireMessagingAssembly.makeConversationScreen(
loadMessagesRepo: GenerateMessagesRepo()
loadMessagesRepo: GenerateMessagesRepo(),
senderNameObserverProvider: { _ in nil },
reactionsObserverProvider: { _ in nil }
)
)
window?.makeKeyAndVisible()
Expand Down
Loading
Loading