diff --git a/WireUI/Package.swift b/WireUI/Package.swift index 819f87a1860..2e369b9f6f4 100644 --- a/WireUI/Package.swift +++ b/WireUI/Package.swift @@ -45,7 +45,8 @@ let package = Package( .target( name: "WireConversationUI", - dependencies: ["WireAccountImageUI", "WireDesign", "WireFoundation", "WireReusableUIComponents"] + dependencies: ["WireAccountImageUI", "WireDesign", "WireFoundation", "WireReusableUIComponents"], + plugins: [.plugin(name: "SwiftGenPlugin", package: "WirePlugins")] ), .testTarget(name: "WireConversationUITests", dependencies: ["WireConversationUI"]), diff --git a/WireUI/Sources/WireConversationUI/.swiftgen.yml b/WireUI/Sources/WireConversationUI/.swiftgen.yml new file mode 100644 index 00000000000..96b24a7dfee --- /dev/null +++ b/WireUI/Sources/WireConversationUI/.swiftgen.yml @@ -0,0 +1,14 @@ +# Every input/output paths in the rest of the config will then be expressed relative to these. + +input_dir: ./ +output_dir: ${GENERATED}/ + +# Generate constants for your localized strings. + +strings: + inputs: + - Resources/Localization/en.lproj/Localizable.strings + filter: + outputs: + - templateName: structured-swift5 + output: Strings+Generated.swift diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel+registerIfNeeded.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel+registerIfNeeded.swift deleted file mode 100644 index e22556e4820..00000000000 --- a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel+registerIfNeeded.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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 UIKit - -public extension ConversationCellModel { - - @MainActor - func registerIfNeeded(in tableView: UITableView) { - guard !tableView.registeredIdentifiers.contains(cellReuseIdentifier) else { return } - - let cellType = switch self { - - case .timeDivider: - ConversationCell.self - } - - tableView.register(cellType, forCellReuseIdentifier: cellReuseIdentifier) - tableView.registeredIdentifiers.insert(cellReuseIdentifier) - } - -} - -private extension UITableView { - - var registeredIdentifiers: Set { - get { objc_getAssociatedObject(self, ®isteredIdentifiersKey) as? Set ?? [] } - set { objc_setAssociatedObject(self, ®isteredIdentifiersKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - } - -} - -@MainActor private var registeredIdentifiersKey = 0 diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel.swift index 75bcba50f49..9e9cd21c0b5 100644 --- a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel.swift @@ -16,9 +16,13 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -public enum ConversationCellModel: Hashable, Sendable { +import SwiftUI + +public enum ConversationCellModel { /// Used to group messages by time. case timeDivider(TimeDividerModel) + /// Text Message + case text(TextMessageViewModel) } diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel+cellReuseIdentifier.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/AvatarViewModel.swift similarity index 79% rename from WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel+cellReuseIdentifier.swift rename to WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/AvatarViewModel.swift index 2e42ebcaa94..898a8569296 100644 --- a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel+cellReuseIdentifier.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/AvatarViewModel.swift @@ -1,4 +1,6 @@ // +import Foundation + // Wire // Copyright (C) 2025 Wire Swiss GmbH // @@ -15,17 +17,11 @@ // 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 -import UIKit - -public extension ConversationCellModel { - - var cellReuseIdentifier: String { - switch self { - - case .timeDivider: - "timeDivider" - } +public struct AvatarViewModel: Hashable, Sendable { + let color: Color + public init(color: Color) { + self.color = color } - } diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Sender/MessageSenderViewModel.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Sender/MessageSenderViewModel.swift new file mode 100644 index 00000000000..61cc6307977 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Sender/MessageSenderViewModel.swift @@ -0,0 +1,165 @@ +// +// 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 SwiftUI +import WireDesign + +public protocol SenderObserverProtocol { + var authorChangedPublisher: AnyPublisher { get } +} + +public enum TeamRoleIndicator { + case guest + case externalPartner + case federated + case service +} + +public class MessageSenderViewModelWrapper: ObservableObject { + + /// State needed here to be able to update view + /// because it's not possible to have optional 'MessageSenderViewModel?' + /// and @Published together + public enum State { + case none + case some(MessageSenderViewModel) + } + + @Published var state: State + + public init(state: State) { + self.state = state + } +} + +public class MessageSenderViewModel: ObservableObject, Identifiable { + + public let id = UUID() + + let avatarViewModel: AvatarViewModel + private var senderModel: UserModel + let isDeleted: Bool + let teamRoleIndicator: TeamRoleIndicator? + @Published var senderAttributed: AttributedString + + private let authorChanged: any SenderObserverProtocol + private var cancellables: Set = [] + + public init( + avatarViewModel: AvatarViewModel, + senderModel: UserModel, + isDeleted: Bool, + teamRoleIndicator: TeamRoleIndicator?, + authorChanged: any SenderObserverProtocol + ) { + self.avatarViewModel = avatarViewModel + self.authorChanged = authorChanged + self.senderModel = senderModel + self.isDeleted = isDeleted + self.teamRoleIndicator = teamRoleIndicator + self.senderAttributed = Self.makeSenderAttributed( + senderModel: senderModel, + isDeleted: isDeleted, + teamRoleIndicator: teamRoleIndicator + ) + + observeChanges() + } + + private func observeChanges() { + authorChanged.authorChangedPublisher + .receive(on: RunLoop.main) + .sink { [weak self] senderString in + guard let self else { return } + print("DS: author ChangedPublisher \(senderString)") + senderModel.name = senderString + senderAttributed = Self.makeSenderAttributed( + senderModel: senderModel, + isDeleted: isDeleted, + teamRoleIndicator: teamRoleIndicator + ) + }.store(in: &cancellables) + } + + private static func makeSenderAttributed( + senderModel: UserModel, + isDeleted: Bool, + teamRoleIndicator: TeamRoleIndicator? + ) -> AttributedString { + + let textColor: UIColor = senderModel.isServiceUser ? SemanticColors.Label.textDefault : senderModel.accentColor + + var result = AttributedString(senderModel.name ?? L10n.Name.unavailable) + result.foregroundColor = Color(textColor) + result.font = Font(UIFont.mediumSemiboldFont) + + // Paragraph style (max line height) // TODO +// let lineHeight = UIFont.mediumSemiboldFont.lineHeight +// let paragraph = NSMutableParagraphStyle() +// paragraph.maximumLineHeight = lineHeight +// result.paragraphStyle = paragraph + + // Attachments (convert UIImage to NSTextAttachment and then NSAttributedString, then embed in AttributedString) + func imageAttachment(_ name: String, size: CGFloat) -> AttributedString? { + guard let image = UIImage(named: name) else { return nil } + let attachment = NSTextAttachment() + attachment.image = image + attachment.bounds = CGRect( + x: 0, + y: (UIFont.mediumSemiboldFont.capHeight - size).rounded() / 2, + width: size, + height: size + ) + return AttributedString(NSAttributedString(attachment: attachment)) + } + + // Indicator icon + if isDeleted { + if let imageAttr = imageAttachment("trash", size: 8) { // TODO: addd resources + result.append(imageAttr) + } + } + + // Team role icons + switch teamRoleIndicator { // TODO: addd resources + case .guest: + if let imageAttr = imageAttachment("guest", size: 14) { + result.append(imageAttr) + } + case .externalPartner: + if let imageAttr = imageAttachment("externalPartner", size: 16) { + result.append(imageAttr) + } + case .federated: + if let imageAttr = imageAttachment("federated", size: 14) { + result.append(imageAttr) + } + case .service: + if let imageAttr = imageAttachment("bot", size: 14) { + result.append(imageAttr) + } + default: + break + } + + return result + + } + +} diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Sender/SenderMessageView.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Sender/SenderMessageView.swift new file mode 100644 index 00000000000..ba81fb81715 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Sender/SenderMessageView.swift @@ -0,0 +1,36 @@ +// +// 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 SenderMessageView: View { + + @ObservedObject var model: MessageSenderViewModelWrapper + + var body: some View { + switch model.state { + case .none: + EmptyView() // nothing when don't need to show sender + case .some(let model): + Text(model.senderAttributed) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .animation(.easeInOut, value: model.senderAttributed) + } + } +} diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Status/MessageStatusView.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Status/MessageStatusView.swift new file mode 100644 index 00000000000..a2611be7a56 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Status/MessageStatusView.swift @@ -0,0 +1,41 @@ +// +// 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 MessageStatusView: View { + + @ObservedObject var model: MessageStatusViewModel + + var body: some View { + switch model.state { + case .none: + EmptyView() // when no need to show status view + case let .sendFailure(_): + EmptyView() // will be implemented later + case let .callList(_): + EmptyView() // will be implemented later + case let .details(statusDetails): + MessageToolboxView( + detailsText: statusDetails.timestamp, + editedString: statusDetails.editedString, + deliveryStatus: statusDetails.deliveryState + ) + } + } +} diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Status/MessageStatusViewModel.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Status/MessageStatusViewModel.swift new file mode 100644 index 00000000000..8ed87b42c37 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Status/MessageStatusViewModel.swift @@ -0,0 +1,90 @@ +// +// 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 + +public protocol StatusObserverProtocol { + var statusChangedPublisher: AnyPublisher { get } +} + +public struct StatusDetails { + let deliveryState: MessageToolboxState? + let editedString: String? + let timestamp: String +} + +public final class MessageStatusViewModel: ObservableObject { + + public enum State { + case none + case sendFailure(String) + case callList(String) + case details(StatusDetails) + } + + @Published var state: State + + private var statusObserver: (any StatusObserverProtocol)? + + private var cancellables: Set = [] + + public init(state: State) { + self.state = state + } + + public init( + messageModel: MessageModel, + statusObserver: any StatusObserverProtocol + ) { + self.statusObserver = statusObserver + self.state = Self.updateState(model: messageModel) + observeChanges() + } + + func observeChanges() { + statusObserver?.statusChangedPublisher + .receive(on: DispatchQueue.main) + .sink { model in + print("DS: status ChangedPublisher: \(model)") + self.state = Self.updateState(model: model) + }.store(in: &cancellables) + } + + private static func updateState(model: MessageModel) -> State { + let datasource = MessageToolboxDataSource(message: model) + switch datasource.content { + case let .sendFailure(string): + return .sendFailure(string) + case let .callList(string): + return .callList(string) + case let .details(timestamp, status, countdown): + return .details(StatusDetails( + deliveryState: status, + editedString: datasource.editedString, + timestamp: timestamp + )) + } + } +} + +public extension MessageStatusViewModel { + static func none() -> MessageStatusViewModel { + .init(state: .none) + } +} diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Status/MessageToolboxView.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Status/MessageToolboxView.swift new file mode 100644 index 00000000000..a88d8a8c858 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/Message Status/MessageToolboxView.swift @@ -0,0 +1,185 @@ +// +// 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 +import WireDesign + +struct MessageToolboxView: View { + + var detailsText: String + var editedString: String? + var deliveryStatus: MessageToolboxState? + var deliveryIcon: Image? + var countdownText: String? + + // var onOpenDetails: (() -> Void)? + + var font: Font = FontSpec.smallRegularFont.swiftUIFont + var textColor: Color = SemanticColors.Label.textMessageDetails.color + + var body: some View { + HStack(spacing: 3) { + + detailsLabel(detailsText) +// .onTapGesture { +// viewModel.openDetails() +// } + + if let editedString { + separator + editedLabel(editedString) + } + + if let deliveryStatus { + if !detailsText.isEmpty { + separator + } + statusContainerView(deliveryStatus) + } + + if let countdown = countdownText { + separator + // TODO: circle view with progress + countDownLabel(countdown) + } + } + } + + @ViewBuilder private func label( + _ text: String, + accessibilityIdentifier: String + ) -> some View { + Text(text) + .lineLimit(1) + .truncationMode(.middle) + .font(font) + .foregroundColor(textColor) + .accessibilityIdentifier(accessibilityIdentifier) + .accessibilityElement() + .layoutPriority(1) + } + + @ViewBuilder private func editedLabel(_ text: String) -> some View { + label(text, accessibilityIdentifier: "Edited") + } + + @ViewBuilder private func statusLabel(_ text: String) -> some View { + label(text, accessibilityIdentifier: "DeliveryStatus") + } + + @ViewBuilder private func detailsLabel(_ text: String) -> some View { + label(text, accessibilityIdentifier: "Details") + } + + @ViewBuilder private var separator: some View { + Text("・") + .font(.body) + .foregroundColor(SemanticColors.Label.baseSecondaryText.color) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + .accessibilityHidden(true) + } + + @ViewBuilder + private func countDownLabel(_ text: String) -> some View { + label(text, accessibilityIdentifier: "EphemeralCountdown") + } + + @ViewBuilder + private func statusImageView(_ image: Image) -> some View { + image + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(SemanticColors.Label.textMessageDetails.color) + .frame(width: 14, height: 14) + .accessibilityIgnoresInvertColors(true) + } + + @ViewBuilder + private func statusContainerView(_ state: MessageToolboxState?) -> some View { + Group { + if let state = state { + HStack(spacing: 3) { + imageForState(state) + if case let .seenByMultiple(count) = state { + statusLabel("\(count)") + } + } + } + } + } + + private func imageForState(_ state: MessageToolboxState) -> some View { + statusImageView(Image(uiImage: image(for: state))) + .accessibilityLabel(accessibilityLabel(for: state)) + } + + private func image(for state: MessageToolboxState) -> UIImage { + switch state { + case .sending: + return UIImage(resource: .sending) + case .sent: + return UIImage(resource: .sent) + case .delivered: + return UIImage(resource: .delivered) + case .seen, .seenByMultiple: + return UIImage(resource: .seen) + } + } + + private func accessibilityLabel(for state: MessageToolboxState) -> String { + switch state { + case .sending: return "sending" + case .sent: return "sent" + case .delivered: return "delivered" + case .seen: return "seen" + case .seenByMultiple(let count): return "seen \(count)" + } + } + +} + +#Preview("Sending") { + MessageToolboxView( + detailsText: "", + deliveryStatus: .sending + ) +} + +#Preview("Sent") { + MessageToolboxView( + detailsText: "18:44", + deliveryStatus: .sent + ) +} + +#Preview("Delivered") { + MessageToolboxView( + detailsText: "18:44", + deliveryStatus: .delivered + ) +} + +#Preview("Edited") { + MessageToolboxView( + detailsText: "18:44", + editedString: "Edited: 14:12", + deliveryStatus: .seenByMultiple(13) + ) +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageErrorHelper.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/MessageErrorHelper.swift similarity index 76% rename from wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageErrorHelper.swift rename to WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/MessageErrorHelper.swift index 8541a351908..4f67d36f511 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageErrorHelper.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/MessageErrorHelper.swift @@ -19,24 +19,24 @@ import Foundation enum MessageErrorHelper { - static func errorMessage(_ message: ConversationMessage) -> String? { + static func errorMessage(_ message: MessageModel) -> String? { - let isSentBySelfUser = message.senderUser?.isSelfUser == true + let isSentBySelfUser = message.sender?.isSelfUser == true let failedToSend = message.deliveryState == .failedToSend && isSentBySelfUser guard failedToSend, isSentBySelfUser else { return nil } - typealias Message = L10n.Localizable.Content.System.FailedtosendMessage + typealias Message = L10n.Content.System.FailedtosendMessage return switch message.expirationReason { case .none, .other, .timeout: Message.generalReason case .federationRemoteError: Message.federationRemoteErrorReason( - message.conversationLike?.domain ?? "", - WireURLs.shared.unreachableBackendInfo.absoluteString + "", // TODO: message.conversationLike?.domain ?? "", + "http://example.com" // WireURLs.shared.unreachableBackendInfo.absoluteString // TODO: pass url ) case .cancelled: Message.userCancelledUploadReason diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/MessageModel.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/MessageModel.swift new file mode 100644 index 00000000000..94a3085d077 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/MessageModel.swift @@ -0,0 +1,95 @@ +// +// 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 struct MessageModel: Equatable { + public let nonce: UUID? + public let sender: UserModel? + public let systemMessageType: SystemMessageTypeModel? + public let updatedAt: Date? + public let receivedAt: Date? + public let expirationReason: ExpirationReasonModel? + public let conversationType: ConversationTypeModel? + public let readReceiptsCount: Int + public let deliveryState: DeliveryStateModel + public let isSent: Bool + + public init( + nonce: UUID?, + sender: UserModel?, + systemMessageType: SystemMessageTypeModel?, + updatedAt: Date?, + receivedAt: Date?, + expirationReason: ExpirationReasonModel?, + conversationType: ConversationTypeModel?, + readReceiptsCount: Int, + deliveryState: DeliveryStateModel, + isSent: Bool + ) { + self.nonce = nonce + self.sender = sender + self.systemMessageType = systemMessageType + self.updatedAt = updatedAt + self.receivedAt = receivedAt + self.expirationReason = expirationReason + self.conversationType = conversationType + self.readReceiptsCount = readReceiptsCount + self.deliveryState = deliveryState + self.isSent = isSent + } +} + +public enum SystemMessageTypeModel: Int, Equatable { + case performedCall + case missedCall + case messageDeletedForEveryone +} + +public enum DeliveryStateModel: Int, Sendable, Equatable, CaseIterable { + case invalid + case pending + case sent + case delivered + case read + case failedToSend +} + +@frozen +public enum ExpirationReasonModel: Int { + case other = 0 + case federationRemoteError + case cancelled + case timeout +} + +public enum ConversationTypeModel: Int, Codable, Sendable { + + /// A conversation with many participants. + case group = 0 + + /// A conversation with only the self user. + case `self` = 1 + + /// A conversation between the two users. + case oneOnOne = 2 + + /// A placeholder conversation for a pending connection + /// to another user. + case connection = 3 +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageToolboxDataSource.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/MessageToolboxDataSource.swift similarity index 58% rename from wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageToolboxDataSource.swift rename to WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/MessageToolboxDataSource.swift index 7568c9ea4e9..57db58f348c 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageToolboxDataSource.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/MessageToolboxDataSource.swift @@ -17,12 +17,11 @@ // import UIKit -import WireCommonComponents -import WireDataModel import WireDesign +import WireReusableUIComponents /// The different contents that can be displayed inside the message toolbox. -enum MessageToolboxContent: Equatable { +public enum MessageToolboxContent: Equatable { /// Display buttons to let the user resend the message. case sendFailure(String) @@ -38,7 +37,7 @@ extension MessageToolboxContent: Comparable { /// Returns whether one content is located above or below the other. /// This is used to determine from which direction to slide, so that we can keep /// the animations logical. - static func < (lhs: MessageToolboxContent, rhs: MessageToolboxContent) -> Bool { + public static func < (lhs: MessageToolboxContent, rhs: MessageToolboxContent) -> Bool { switch (lhs, rhs) { case (.sendFailure, _): true @@ -51,7 +50,7 @@ extension MessageToolboxContent: Comparable { } -enum MessageToolboxState: Equatable { +public enum MessageToolboxState: Equatable { case sending case sent case delivered @@ -63,34 +62,33 @@ enum MessageToolboxState: Equatable { /// An object that determines what content to display for the given message. -typealias ConversationMessage = SwiftConversationMessage & ZMConversationMessage +public final class MessageToolboxDataSource { -final class MessageToolboxDataSource { - - typealias ContentSystem = L10n.Localizable.Content.System + typealias ContentSystem = L10n.Content.System /// The displayed message. - let message: ConversationMessage + public let message: MessageModel - var editedString: String? { + public var editedString: String? { guard message.updatedAt != nil else { return nil } - return L10n.Localizable.Content.Message.edited + return L10n.Content.Message.edited } /// The content to display for the message. - private(set) var content: MessageToolboxContent + public private(set) var content: MessageToolboxContent // MARK: - Formatting Properties - private static let ephemeralTimeFormatter = EphemeralTimeoutFormatter() +// private static let ephemeralTimeFormatter = EphemeralTimeoutFormatter() // MARK: - Initialization /// Creates a toolbox data source for the given message. - init(message: ConversationMessage) { + public init(message: MessageModel) { self.message = message self.content = .details(timestamp: "", status: nil, countdown: "") + _ = shouldUpdateContent() } // MARK: - Content @@ -98,18 +96,18 @@ final class MessageToolboxDataSource { /// Updates the contents of the message toolbox. /// - parameter widthConstraint: The width available to rend the toolbox contents. /// - Returns: A boolean to either update the content of the message toolbox or not - func shouldUpdateContent(widthConstraint: CGFloat) -> Bool { + public func shouldUpdateContent() -> Bool { // Compute the state let previousContent = content // Determine the content by priority // [WPB-6988] removed performed call - if message.systemMessageData?.systemMessageType == .performedCall { + if message.systemMessageType == .performedCall { return false } // 1b) Call list for missed calls - else if message.systemMessageData?.systemMessageType == .missedCall { + else if message.systemMessageType == .missedCall { content = .callList(makeCallList()) } // 2) Failed to send @@ -124,11 +122,7 @@ final class MessageToolboxDataSource { } // Only perform the changes if the content did change. - guard previousContent != content else { - return false - } - - return true + return previousContent != content } // MARK: - Details Text @@ -142,46 +136,47 @@ final class MessageToolboxDataSource { let countdownStatus = makeEphemeralCountdown() let deliveryState = message.shouldShowDeliveryState ? selfMessageState(for: message) : nil let isTimestampVisible = message.isSent && message.deliveryState != .failedToSend - let timestampString = isTimestampVisible ? message.formattedReceivedTime() ?? "" : "" + let timestampString = isTimestampVisible ? MessageFormatter.formattedReceivedTime(message.receivedAt) ?? "" : "" return (timestampString, deliveryState, countdownStatus) } private func makeEphemeralCountdown() -> String { - let showDestructionTimer = message.isEphemeral && - !message.isObfuscated && - message.destructionDate != nil && - message.deliveryState != .pending - - guard let destructionDate = message.destructionDate, showDestructionTimer else { return "" } - - // We need to add one second to start with the correct value - let remaining = destructionDate.timeIntervalSinceNow + 1 - - if remaining > 0 { - if let string = MessageToolboxDataSource.ephemeralTimeFormatter.string(from: remaining) { - return string - } - } else if message.isAudio { - // do nothing, audio messages are allowed to extend the timer - // past the destruction date. - } - return "" + "" // TODO: +// let showDestructionTimer = message.isEphemeral && +// !message.isObfuscated && +// message.destructionDate != nil && +// message.deliveryState != .pending +// +// guard let destructionDate = message.destructionDate, showDestructionTimer else { return "" } +// +// // We need to add one second to start with the correct value +// let remaining = destructionDate.timeIntervalSinceNow + 1 +// +// if remaining > 0 { +// if let string = MessageToolboxDataSource.ephemeralTimeFormatter.string(from: remaining) { +// return string +// } +// } else if message.isAudio { +// // do nothing, audio messages are allowed to extend the timer +// // past the destruction date. +// } +// return "" } // MARK: - message delivery state /// Returns the status for the sender of the message. - private func selfMessageState(for message: ZMConversationMessage) -> MessageToolboxState? { - guard let sender = message.senderUser, sender.isSelfUser else { + private func selfMessageState(for message: MessageModel) -> MessageToolboxState? { + guard let sender = message.sender, sender.isSelfUser else { return nil } switch message.deliveryState { case .pending: return .sending - case .read where message.conversationLike?.conversationType == .group: - return .seenByMultiple(message.readReceipts.count) - case .read where message.conversationLike?.conversationType == .oneOnOne: + case .read where message.conversationType == .group: + return .seenByMultiple(message.readReceiptsCount) + case .read where message.conversationType == .oneOnOne: return .seen case .delivered: return .delivered @@ -193,12 +188,12 @@ final class MessageToolboxDataSource { } /// Creates the status for the read receipts. - private func readDeliveryStateAttributedString(for message: ZMConversationMessage) -> MessageToolboxState? { - guard let conversationType = message.conversationLike?.conversationType else { return nil } + private func readDeliveryStateAttributedString(for message: MessageModel) -> MessageToolboxState? { + guard let conversationType = message.conversationType else { return nil } switch conversationType { case .group: - return .seenByMultiple(message.readReceipts.count) + return .seenByMultiple(message.readReceiptsCount) case .oneOnOne: return .seen @@ -212,30 +207,31 @@ final class MessageToolboxDataSource { /// Create a timestamp list for all calls associated with a call system message private func makeCallList() -> String { - guard let childMessages = message.systemMessageData?.childMessages, !childMessages.isEmpty, - let timestamp = timestampString(message) else { - return timestampString(message) ?? "-" - } - - let childrenTimestamps = childMessages - .compactMap { $0 as? ZMConversationMessage } - .sortedAscendingPrependingNil(by: \.serverTimestamp) - .compactMap(timestampString) - - return childrenTimestamps.reduce(timestamp) { text, current in - "\(text)\n\(current)" - } + // TODO: + "" +// guard let childMessages = message.systemMessageData?.childMessages, !childMessages.isEmpty, +// let timestamp = timestampString(message) else { +// return timestampString(message) ?? "-" +// } +// +// let childrenTimestamps = childMessages +// .compactMap { $0 as? ZMConversationMessage } +// .sortedAscendingPrependingNil(by: \.serverTimestamp) +// .compactMap(timestampString) +// +// return childrenTimestamps.reduce(timestamp) { text, current in +// "\(text)\n\(current)" +// } } /// Creates the timestamp text. - private func timestampString(_ message: ZMConversationMessage) -> String? { + private func timestampString(_ message: MessageModel) -> String? { var timestampString: String? - if let editedTimeString = message.formattedEditedDate() { + if let editedTimeString = MessageFormatter.formattedEditedDate(message.updatedAt) { timestampString = ContentSystem.editedMessagePrefixTimestamp(editedTimeString) - } else if let dateTimeString = message.formattedReceivedDateTime(), - let systemMessage = message as? ZMSystemMessage, - systemMessage.systemMessageType == .messageDeletedForEveryone { + } else if let dateTimeString = MessageFormatter.formattedReceivedDateTime(message.receivedAt), + message.systemMessageType == .messageDeletedForEveryone { timestampString = ContentSystem.deletedMessagePrefixTimestamp(dateTimeString) } @@ -243,3 +239,9 @@ final class MessageToolboxDataSource { } } + +extension MessageModel { + var shouldShowDeliveryState: Bool { + systemMessageType != .missedCall + } +} diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/TextMessageView.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/TextMessageView.swift new file mode 100644 index 00000000000..8677b83cfe9 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/TextMessageView.swift @@ -0,0 +1,104 @@ +// +// 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 SwiftUI +import UIKit +import WireDesign + +public struct TextMessageView: ConversationCellContentViewProtocol { + + @ObservedObject var model: TextMessageViewModel + + public init(model: TextMessageViewModel) { + self.model = model + } + + public var body: some View { + VStack(alignment: .leading, spacing: 2) { + SenderMessageView(model: model.senderViewModelWrapper) + HStack(spacing: 0) { + Text(model.text) + .multilineTextAlignment(.center) + .font(.footnote) + .fontWeight(.semibold) + .layoutPriority(1) + } + MessageStatusView(model: model.statusViewModel) + } + .padding(.vertical, 4) + } +} + +// MARK: - Previews + +#Preview("Simple") { + let model = TextMessageViewModel( + text: "Test message", + senderViewModelWrapper: .init(state: .some(MessageSenderViewModel( + avatarViewModel: AvatarViewModel(color: .red), + senderModel: UserModel( + name: "Test", + isSelfUser: true, + isServiceUser: false, + accentColor: .purple + ), + isDeleted: false, + teamRoleIndicator: nil, + authorChanged: MockSenderObserver() + ))), + statusViewModel: MessageStatusViewModel( + state: .details(StatusDetails( + deliveryState: .seen, + editedString: "Edited: 2 mins ago", + timestamp: "2 mins ago" + )) + ) + ) + TextMessageView(model: model) +} + +extension MessageToolboxState { + var text: String { // TODO: migrate strings + + switch self { + case .sending: + "Sending" + case .sent: + "Sent" + case .delivered: + "Delivered" + case .seen: + "Seen" + case let .seenByMultiple(int): + "Seen \(int)" + } + } +} + +struct MockSenderObserver: SenderObserverProtocol { + var authorChangedPublisher: AnyPublisher { + Empty().eraseToAnyPublisher() + } +} + +struct MockStatusObserver: StatusObserverProtocol { + var statusChangedPublisher: AnyPublisher { + Empty().eraseToAnyPublisher() + } +} diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/TextMessageViewModel.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/TextMessageViewModel.swift new file mode 100644 index 00000000000..29ff34bd7b5 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/TextMessageViewModel.swift @@ -0,0 +1,100 @@ +// +// 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 SwiftUI +import WireDesign + +public class TextMessageViewModel: ObservableObject, Identifiable, ConversationCellModelProtocol { + + public let id = UUID() + + public typealias ContentView = TextMessageView + + @ObservedObject var senderViewModelWrapper: MessageSenderViewModelWrapper + @ObservedObject var statusViewModel: MessageStatusViewModel + + public func buildView() -> ContentView { + ContentView(model: self) + } + +// public var id: AnyHashable { self } + +// private var timer: AnyCancellable? + +// public var significantChangeSubject = PassthroughSubject() + + @Published var text: String + + public init( + text: String, + senderViewModelWrapper: MessageSenderViewModelWrapper?, + statusViewModel: MessageStatusViewModel + ) { + self.text = text // TODO: format + self.senderViewModelWrapper = senderViewModelWrapper! + self.statusViewModel = statusViewModel +// startRandomStateTimer() + } + +// private func startRandomStateTimer() { +// timer = Timer.publish(every: 1.0, on: .main, in: .common) +// .autoconnect() +// .sink { [weak self] _ in +// guard let self else { return } +// let (newText, lines) = self.randomMultilineText() +// if lines >= 2 { +// self.significantChangeSubject.send(()) +// } else { +// self.text = newText +// } +// } +// } + +// func randomMultilineText() -> (String, Int) { +// let lines = [ +// "Hello!", +// "This is a second line.", +// "Here comes the third one." +// ] +// +// let numberOfLines = Int.random(in: 1...3) +// return (lines.prefix(numberOfLines).joined( +// separator: "\n" +// ), numberOfLines) +// } +// +// deinit { +// timer?.cancel() +// } +} + +extension ConversationCellModel { + +// static func timeDivider( +// text: String, +// isUnread: Bool +// ) -> Self { +// let model = TimeDividerModel( +// text: text, +// isUnreadIndicatorVisible: isUnread +// ) +// return .timeDivider(model) +// } +} diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel+configureCell.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/UserModel.swift similarity index 59% rename from WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel+configureCell.swift rename to WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/UserModel.swift index ec326e098a9..78fa0f0300d 100644 --- a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/ConversationCellModel+configureCell.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TextMessage/UserModel.swift @@ -18,18 +18,25 @@ import UIKit -public extension ConversationCellModel { +public struct UserModel: Equatable { - @MainActor - func configureCell(_ cell: UITableViewCell) { - switch self { + /// The full name + var name: String? - case let .timeDivider(timeDivider): - guard let cell = cell as? ConversationCell else { break } - return cell.model = timeDivider - } + /// Whether this is a service user (bot) + let isServiceUser: Bool + let isSelfUser: Bool + let accentColor: UIColor - assertionFailure("unexpected cell: \(cell)") + public init( + name: String?, + isSelfUser: Bool, + isServiceUser: Bool, + accentColor: UIColor + ) { + self.name = name + self.isServiceUser = isServiceUser + self.isSelfUser = isSelfUser + self.accentColor = accentColor } - } diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TimeDivider/TimeDividerContentView.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TimeDivider/TimeDividerContentView.swift index e926d34a3ae..43674183c79 100644 --- a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TimeDivider/TimeDividerContentView.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TimeDivider/TimeDividerContentView.swift @@ -19,13 +19,17 @@ import SwiftUI import WireDesign -struct TimeDividerContentView: ConversationCellContentViewProtocol { +public struct TimeDividerContentView: ConversationCellContentViewProtocol { private let dividerColor = ColorTheme.Strokes.outline.color private(set) var model: TimeDividerModel - var body: some View { + public init(model: TimeDividerModel) { + self.model = model + } + + public var body: some View { HStack(spacing: 0) { if model.isUnreadIndicatorVisible { @@ -103,7 +107,7 @@ struct TimeDividerContentView: ConversationCellContentViewProtocol { } #Preview("no text") { - let model = TimeDividerModel() + let model = TimeDividerModel(text: "", isUnreadIndicatorVisible: false) TimeDividerContentView(model: model) } diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TimeDivider/TimeDividerModel.swift b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TimeDivider/TimeDividerModel.swift index 4196f3ffb06..a8477e452ad 100644 --- a/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TimeDivider/TimeDividerModel.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/CellTypes/TimeDivider/TimeDividerModel.swift @@ -17,9 +17,9 @@ // public struct TimeDividerModel: ConversationCellModelProtocol { - typealias ContentView = TimeDividerContentView + public typealias ContentView = TimeDividerContentView - public var id: AnyHashable { self } +// public var id: AnyHashable { self } var text: String var isUnreadIndicatorVisible: Bool @@ -31,14 +31,6 @@ public struct TimeDividerModel: ConversationCellModelProtocol { self.text = text self.isUnreadIndicatorVisible = isUnreadIndicatorVisible } - - init() { - self.init( - text: "", - isUnreadIndicatorVisible: false - ) - } - } extension ConversationCellModel { diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/ConversationCell.swift b/WireUI/Sources/WireConversationUI/ConversationCell/ConversationCell.swift index 58c1b390e1c..53ae7ea8467 100644 --- a/WireUI/Sources/WireConversationUI/ConversationCell/ConversationCell.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/ConversationCell.swift @@ -18,17 +18,37 @@ import SwiftUI -final class ConversationCell: UITableViewCell { - - var model = Model() { - didSet { updateConfiguration() } +public struct HorizontalMargins { + var left: CGFloat + var right: CGFloat + + public init(left: CGFloat, right: CGFloat) { + self.left = left + self.right = right + } + + static var `default`: HorizontalMargins { + .init(left: 56, right: 16) } +} - private func updateConfiguration() { +public final class ConversationCell: UITableViewCell { + + public var model: ConversationCellModel? + + public func configure(model: ConversationCellModel?, horizontalMargins: HorizontalMargins) { + guard let model else { return } contentConfiguration = UIHostingConfiguration { - model.buildView() + switch model { + case let .timeDivider(model): + TimeDividerContentView(model: model) + case let .text(model): + TextMessageView(model: model) + } } - .margins(.all, 0) + .margins(.vertical, 0) + .margins(.leading, horizontalMargins.left) + .margins(.trailing, horizontalMargins.right) .minSize(width: 0, height: 0) .background(.clear) } @@ -37,13 +57,13 @@ final class ConversationCell: UITableViewC // MARK: - Previews -@available(iOS 17, *) -#Preview { - ConversationCellsPreview( - itemIdentifiers: [ - .timeDivider(text: "Friday", isUnread: false), - .timeDivider(text: "Saturday", isUnread: false), - .timeDivider(text: "Today", isUnread: true) - ] - ) -} +// @available(iOS 17, *) +// #Preview { +// ConversationCellsPreview( +// itemIdentifiers: [ +// .timeDivider(text: "Friday", isUnread: false), +// .timeDivider(text: "Saturday", isUnread: false), +// .timeDivider(text: "Today", isUnread: true) +// ] +// ) +// } diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/ConversationCellPreview.swift b/WireUI/Sources/WireConversationUI/ConversationCell/ConversationCellPreview.swift index 14070698372..f7d8099e998 100644 --- a/WireUI/Sources/WireConversationUI/ConversationCell/ConversationCellPreview.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/ConversationCellPreview.swift @@ -18,58 +18,59 @@ import SwiftUI -final class ConversationCellsPreview: UITableViewController { - - enum SectionIdentifier { - case single - } - - typealias ItemIdentifier = ConversationCellModel - - private let itemIdentifiers: [ItemIdentifier] - private var dataSource: UITableViewDiffableDataSource! - - init(itemIdentifiers: [ItemIdentifier]) { - self.itemIdentifiers = itemIdentifiers - super.init(style: .plain) - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) is not supported") - } - - override func viewDidLoad() { - super.viewDidLoad() - setupTableView() - loadItems() - } - - private func setupTableView() { - registerCellTypes() - setupDataSource() - tableView.separatorStyle = .none - } - - private func registerCellTypes() { - for itemIdentifier in itemIdentifiers { - itemIdentifier.registerIfNeeded(in: tableView) - } - } - - private func setupDataSource() { - dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, itemIdentifier in - let cell = tableView.dequeueReusableCell(withIdentifier: itemIdentifier.cellReuseIdentifier, for: indexPath) - itemIdentifier.configureCell(cell) - return cell - } - } - - private func loadItems() { - var snapshot = dataSource.snapshot() - snapshot.appendSections([.single]) - snapshot.appendItems(itemIdentifiers) - dataSource.applySnapshotUsingReloadData(snapshot) - } - -} +// final class ConversationCellsPreview: UITableViewController { +// +// enum SectionIdentifier { +// case single +// } +// +// typealias ItemIdentifier = ConversationCellModel +// +// private let itemIdentifiers: [ItemIdentifier] +// private var dataSource: UITableViewDiffableDataSource! +// +// init(itemIdentifiers: [ItemIdentifier]) { +// self.itemIdentifiers = itemIdentifiers +// super.init(style: .plain) +// } +// +// @available(*, unavailable) +// required init?(coder aDecoder: NSCoder) { +// fatalError("init(coder:) is not supported") +// } +// +// override func viewDidLoad() { +// super.viewDidLoad() +// setupTableView() +// loadItems() +// } +// +// private func setupTableView() { +// registerCellTypes() +// setupDataSource() +// tableView.separatorStyle = .none +// } +// +// private func registerCellTypes() { +// for itemIdentifier in itemIdentifiers { +// itemIdentifier.registerIfNeeded(in: tableView) +// } +// } +// +// private func setupDataSource() { +// dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, itemIdentifier in +// let cell = tableView.dequeueReusableCell(withIdentifier: itemIdentifier.cellReuseIdentifier, for: +// indexPath) +// itemIdentifier.configureCell(cell) +// return cell +// } +// } +// +// private func loadItems() { +// var snapshot = dataSource.snapshot() +// snapshot.appendSections([.single]) +// snapshot.appendItems(itemIdentifiers) +// dataSource.applySnapshotUsingReloadData(snapshot) +// } +// +// } diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/Protocols/ConversationCellContentViewProtocol.swift b/WireUI/Sources/WireConversationUI/ConversationCell/Protocols/ConversationCellContentViewProtocol.swift index c46974a4ab1..2412ef30424 100644 --- a/WireUI/Sources/WireConversationUI/ConversationCell/Protocols/ConversationCellContentViewProtocol.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/Protocols/ConversationCellContentViewProtocol.swift @@ -19,7 +19,7 @@ import SwiftUI @MainActor -protocol ConversationCellContentViewProtocol: View { +public protocol ConversationCellContentViewProtocol: View { associatedtype Model init(model: Model) } diff --git a/WireUI/Sources/WireConversationUI/ConversationCell/Protocols/ConversationCellModelProtocol.swift b/WireUI/Sources/WireConversationUI/ConversationCell/Protocols/ConversationCellModelProtocol.swift index 190a7db44a2..776c5d4849a 100644 --- a/WireUI/Sources/WireConversationUI/ConversationCell/Protocols/ConversationCellModelProtocol.swift +++ b/WireUI/Sources/WireConversationUI/ConversationCell/Protocols/ConversationCellModelProtocol.swift @@ -19,13 +19,13 @@ import SwiftUI // the `Hashable` requirement could be loosened if needed (and moved to conforming types where needed) -protocol ConversationCellModelProtocol: Hashable, Identifiable, Sendable { +public protocol ConversationCellModelProtocol { associatedtype ContentView: ConversationCellContentViewProtocol - init() +// init() - @MainActor - func buildView() -> ContentView +// @MainActor +// func buildView() -> ContentView } diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/CertificateValid.imageset/Certificate valid dark.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/CertificateValid.imageset/Certificate valid dark.svg similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/CertificateValid.imageset/Certificate valid dark.svg rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/CertificateValid.imageset/Certificate valid dark.svg diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/CertificateValid.imageset/Certificate valid light.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/CertificateValid.imageset/Certificate valid light.svg similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/CertificateValid.imageset/Certificate valid light.svg rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/CertificateValid.imageset/Certificate valid light.svg diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/CertificateValid.imageset/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/CertificateValid.imageset/Contents.json similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/CertificateValid.imageset/Contents.json rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/CertificateValid.imageset/Contents.json diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Contents.json similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Contents.json rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Contents.json diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Dropdown.imageset/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Dropdown.imageset/Contents.json similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Dropdown.imageset/Contents.json rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Dropdown.imageset/Contents.json diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Dropdown.imageset/Dropdown.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Dropdown.imageset/Dropdown.svg similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Dropdown.imageset/Dropdown.svg rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Dropdown.imageset/Dropdown.svg diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/LegalHold.imageset/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/LegalHold.imageset/Contents.json similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/LegalHold.imageset/Contents.json rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/LegalHold.imageset/Contents.json diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/LegalHold.imageset/Legal Hold dark.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/LegalHold.imageset/Legal Hold dark.svg similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/LegalHold.imageset/Legal Hold dark.svg rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/LegalHold.imageset/Legal Hold dark.svg diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/LegalHold.imageset/Legal Hold light.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/LegalHold.imageset/Legal Hold light.svg similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/LegalHold.imageset/Legal Hold light.svg rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/LegalHold.imageset/Legal Hold light.svg diff --git a/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Delivered.imageset/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Delivered.imageset/Contents.json new file mode 100644 index 00000000000..3e3352aab87 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Delivered.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Delivered.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Delivered.imageset/Delivered.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Delivered.imageset/Delivered.svg new file mode 100644 index 00000000000..20ec8b92c8f --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Delivered.imageset/Delivered.svg @@ -0,0 +1,3 @@ + + + diff --git a/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Seen.imageset/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Seen.imageset/Contents.json new file mode 100644 index 00000000000..7e64f6fe379 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Seen.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Seen.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Seen.imageset/Seen.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Seen.imageset/Seen.svg new file mode 100644 index 00000000000..73e90c9ad47 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Seen.imageset/Seen.svg @@ -0,0 +1,3 @@ + + + diff --git a/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sending.imageset/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sending.imageset/Contents.json new file mode 100644 index 00000000000..919ab4db31d --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sending.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Sending.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sending.imageset/Sending.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sending.imageset/Sending.svg new file mode 100644 index 00000000000..f1a34a1ff7c --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sending.imageset/Sending.svg @@ -0,0 +1,3 @@ + + + diff --git a/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sent.imageset/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sent.imageset/Contents.json new file mode 100644 index 00000000000..acf21ede511 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sent.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Sent.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sent.imageset/Sent.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sent.imageset/Sent.svg new file mode 100644 index 00000000000..ab1679a3393 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/MessageStatus/Sent.imageset/Sent.svg @@ -0,0 +1,3 @@ + + + diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Verified.imageset/Contents.json b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Verified.imageset/Contents.json similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Verified.imageset/Contents.json rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Verified.imageset/Contents.json diff --git a/WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Verified.imageset/Verified.svg b/WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Verified.imageset/Verified.svg similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/Images.xcassets/Verified.imageset/Verified.svg rename to WireUI/Sources/WireConversationUI/Resources/Images.xcassets/Verified.imageset/Verified.svg diff --git a/WireUI/Sources/WireConversationUI/Resources/Localization/en.lproj/Localizable.strings b/WireUI/Sources/WireConversationUI/Resources/Localization/en.lproj/Localizable.strings new file mode 100644 index 00000000000..c4685fb9ea7 --- /dev/null +++ b/WireUI/Sources/WireConversationUI/Resources/Localization/en.lproj/Localizable.strings @@ -0,0 +1,29 @@ +// + // 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/. + // + +"name.unavailable" = "Name not available"; + +"content.message.edited" = "Edited"; +"content.system.edited_message_prefix_timestamp" = "Edited: %@"; +"content.system.deleted_message_prefix_timestamp" = "Deleted: %@"; + +"content.system.failedtosend_message.general_reason" = "Message could not be sent due to connectivity issues."; +"content.system.failedtosend_message.user_cancelled_upload_reason" = "Message not sent as you canceled the upload."; +"content.system.failedtosend_message.federation_remote_error_reason" = "Message could not be sent as the backend of **%@** could not be reached. [Learn more](%@)"; +"content.system.failedtosend_message.retry" = "Retry"; + diff --git a/WireUI/Sources/WireConversationUI/Resouces/UImage+ImageResource.swift b/WireUI/Sources/WireConversationUI/Resources/UImage+ImageResource.swift similarity index 100% rename from WireUI/Sources/WireConversationUI/Resouces/UImage+ImageResource.swift rename to WireUI/Sources/WireConversationUI/Resources/UImage+ImageResource.swift diff --git a/WireUI/Sources/WireDesign/Typography/Legacy/UIFont+FontSpec.swift b/WireUI/Sources/WireDesign/Typography/Legacy/UIFont+FontSpec.swift new file mode 100644 index 00000000000..cfbfda57029 --- /dev/null +++ b/WireUI/Sources/WireDesign/Typography/Legacy/UIFont+FontSpec.swift @@ -0,0 +1,103 @@ +// +// 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 UIKit + +// Copy of App's fonts +public extension UIFont { + + // MARK: - Small + + class var smallFont: UIFont { + FontSpec(.small, .none).font! + } + + class var smallLightFont: UIFont { + FontSpec(.small, .light).font! + } + + class var smallRegularFont: UIFont { + FontSpec(.small, .regular).font! + } + + class var smallMediumFont: UIFont { + FontSpec(.small, .medium).font! + } + + class var smallSemiboldFont: UIFont { + FontSpec(.small, .semibold).font! + } + + // MARK: - Normal + + class var normalFont: UIFont { + FontSpec(.normal, .none).font! + } + + class var normalLightFont: UIFont { + FontSpec(.normal, .light).font! + } + + class var normalRegularFont: UIFont { + FontSpec(.normal, .regular).font! + } + + class var normalMediumFont: UIFont { + FontSpec(.normal, .medium).font! + } + + class var normalSemiboldFont: UIFont { + FontSpec(.normal, .semibold).font! + } + + // MARK: - Medium + + class var mediumFont: UIFont { + FontSpec(.medium, .none).font! + } + + class var mediumSemiboldFont: UIFont { + FontSpec(.medium, .semibold).font! + } + + class var mediumLightLargeTitleFont: UIFont { + FontSpec(.medium, .light, .largeTitle).font! + } + + // MARK: - Large + + class var largeThinFont: UIFont { + FontSpec(.large, .thin).font! + } + + class var largeLightFont: UIFont { + FontSpec(.large, .light).font! + } + + class var largeRegularFont: UIFont { + FontSpec(.large, .regular).font! + } + + class var largeMediumFont: UIFont { + FontSpec(.large, .medium).font! + } + + class var largeSemiboldFont: UIFont { + FontSpec(.large, .semibold).font! + } +} diff --git a/WireUI/Sources/WireReusableUIComponents/DateFormatter/DateFormatter+Message.swift b/WireUI/Sources/WireReusableUIComponents/DateFormatter/DateFormatter+Message.swift new file mode 100644 index 00000000000..c212349d8d0 --- /dev/null +++ b/WireUI/Sources/WireReusableUIComponents/DateFormatter/DateFormatter+Message.swift @@ -0,0 +1,113 @@ +// +// 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 extension DateFormatter { + + static var shortTimeFormatter: DateFormatter = { + var shortTimeFormatter = DateFormatter() + shortTimeFormatter.dateStyle = .none + shortTimeFormatter.timeStyle = .short + return shortTimeFormatter + }() + + static let shortDateFormatter: DateFormatter = { + var shortDateFormatter = DateFormatter() + shortDateFormatter.dateStyle = .short + shortDateFormatter.timeStyle = .none + return shortDateFormatter + }() + + static let spellOutDateTimeFormatter: DateFormatter = { + var longDateFormatter = DateFormatter() + longDateFormatter.dateStyle = .long + longDateFormatter.timeStyle = .short + longDateFormatter.doesRelativeDateFormatting = true + return longDateFormatter + }() + + static let shortDateTimeFormatter: DateFormatter = { + var longDateFormatter = DateFormatter() + longDateFormatter.dateStyle = .short + longDateFormatter.timeStyle = .short + return longDateFormatter + }() + +} + +public class MessageFormatter { + + public static func formattedOriginalReceivedDate(_ receivedAt: Date?) -> String? { + guard let timestamp = receivedAt else { + return nil + } + + let formattedDate: String + + if Calendar.current.isDateInToday(timestamp) { + formattedDate = DateFormatter.shortTimeFormatter + .string(from: timestamp) + return L10n.Content.Message.Reply.OriginalTimestamp.time(formattedDate) + + } else { + formattedDate = DateFormatter.shortDateFormatter.string(from: timestamp) + return L10n.Content.Message.Reply.OriginalTimestamp.date(formattedDate) + } + } + + public static func formattedReceivedTime(_ receivedAt: Date?) -> String? { + receivedAt.map(DateFormatter.shortTimeFormatter.string(from:)) + } + + public static func formattedReceivedDateTime(_ receivedAt: Date?) -> String? { + receivedAt.map(formattedDate) + } + + public static func formattedEditedDate(_ updatedAt: Date?) -> String? { + updatedAt.map(formattedDate) + } + + public static func formattedDate(_ date: Date) -> String { + if Calendar.current.isDateInToday(date) { + DateFormatter.shortTimeFormatter.string(from: date) + } else { + DateFormatter.shortDateTimeFormatter.string(from: date) + } + } + + public static func formattedAccessibleMessageDetails(receivedAt: Date?, updatedAt: Date?) -> String? { + guard let receivedAt else { + return nil + } + let formattedTimestamp = DateFormatter.spellOutDateTimeFormatter.string(from: receivedAt) + let sendDate = L10n.MessageDetails.subtitleSendDate(formattedTimestamp) + + var accessibleMessageDetails = sendDate + + if let editTimestamp = updatedAt { + let formattedEditTimestamp = DateFormatter.spellOutDateTimeFormatter.string(from: editTimestamp) + let editDate = L10n.MessageDetails.subtitleEditDate(formattedEditTimestamp) + + accessibleMessageDetails += ("\n" + editDate) + } + + return accessibleMessageDetails + } + +} diff --git a/WireUI/Sources/WireReusableUIComponents/Resources/Localization/en.lproj/Localizable.strings b/WireUI/Sources/WireReusableUIComponents/Resources/Localization/en.lproj/Localizable.strings index 94e0f0a82df..1f2c96b30a1 100644 --- a/WireUI/Sources/WireReusableUIComponents/Resources/Localization/en.lproj/Localizable.strings +++ b/WireUI/Sources/WireReusableUIComponents/Resources/Localization/en.lproj/Localizable.strings @@ -21,3 +21,9 @@ "passwordtextfield.preview.passwordrules" = "Use at least 8 characters, with one lowercase letter, one capital letter, a number, and a special character."; "passwordtextfield.hide_password" = "Hide password"; "passwordtextfield.show_password" = "Show password"; + +"content.message.reply.original_timestamp.date" = "Original message from %@"; +"content.message.reply.original_timestamp.time" = "Original message from %@"; + +"message_details.subtitle_send_date" = "Sent: %@"; +"message_details.subtitle_edit_date" = "Edited: %@"; diff --git a/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift b/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift index 0a5adcf6d97..a8f194c653f 100644 --- a/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift +++ b/wire-ios-data-model/Source/Model/Message/ConversationMessage.swift @@ -21,6 +21,8 @@ import WireLinkPreview private var zmLog = ZMSLog(tag: "Message") +public typealias ConversationMessage = SwiftConversationMessage & ZMConversationMessage + @objc public enum ZMDeliveryState: UInt { case invalid = 0 diff --git a/wire-ios-sync-engine/Source/Data Model/MessageChangeInfo+UserSession.swift b/wire-ios-sync-engine/Source/Data Model/MessageChangeInfo+UserSession.swift index 3d27f0bebd6..0c6f038f004 100644 --- a/wire-ios-sync-engine/Source/Data Model/MessageChangeInfo+UserSession.swift +++ b/wire-ios-sync-engine/Source/Data Model/MessageChangeInfo+UserSession.swift @@ -31,6 +31,14 @@ public extension MessageChangeInfo { ) -> NSObjectProtocol { add(observer: observer, for: message, managedObjectContext: userSession.managedObjectContext) } + + static func add( + observer: ZMMessageObserver, + for message: ZMConversationMessage, + context: NSManagedObjectContext + ) -> NSObjectProtocol { + add(observer: observer, for: message, managedObjectContext: context) + } } public extension NewUnreadMessagesChangeInfo { diff --git a/wire-ios-sync-engine/Source/Data Model/UserChangeInfo+UserSession.swift b/wire-ios-sync-engine/Source/Data Model/UserChangeInfo+UserSession.swift index 0c6ffd73a12..ff50b9a0d40 100644 --- a/wire-ios-sync-engine/Source/Data Model/UserChangeInfo+UserSession.swift +++ b/wire-ios-sync-engine/Source/Data Model/UserChangeInfo+UserSession.swift @@ -30,6 +30,10 @@ public extension UserChangeInfo { add(observer: observer, for: user, in: userSession.managedObjectContext) } + static func add(observer: UserObserving, for user: UserType, context: NSManagedObjectContext) -> NSObjectProtocol? { + add(observer: observer, for: user, in: context) + } + // MARK: - Registering UserObservers /// Adds an observer for changes in all ZMUsers. You must hold on to the token until you want to stop observing. diff --git a/wire-ios/Wire-iOS Tests/ArticleViewTests.swift b/wire-ios/Wire-iOS Tests/ArticleViewTests.swift index 28b1f4d7f88..7c09b89863b 100644 --- a/wire-ios/Wire-iOS Tests/ArticleViewTests.swift +++ b/wire-ios/Wire-iOS Tests/ArticleViewTests.swift @@ -69,6 +69,8 @@ final class MockConversationMessageCellDelegate: ConversationMessageCellDelegate // no-op } + func conversationMessageDidRequestToUpdate(nonce: UUID) {} + func perform( action: MessageAction, for message: ZMConversationMessage, diff --git a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift index 263be7780fb..11e03340ed5 100644 --- a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift +++ b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift @@ -1813,16 +1813,6 @@ internal enum L10n { internal static let brokenMessage = L10n.tr("Localizable", "content.message.reply.broken_message", fallback: "You cannot see this message.") /// Edited internal static let editedMessage = L10n.tr("Localizable", "content.message.reply.edited_message", fallback: "Edited") - internal enum OriginalTimestamp { - /// Original message from %@ - internal static func date(_ p1: Any) -> String { - return L10n.tr("Localizable", "content.message.reply.original_timestamp.date", String(describing: p1), fallback: "Original message from %@") - } - /// Original message from %@ - internal static func time(_ p1: Any) -> String { - return L10n.tr("Localizable", "content.message.reply.original_timestamp.time", String(describing: p1), fallback: "Original message from %@") - } - } } } internal enum Ping { diff --git a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings index 21397d68ef1..d9f41f798dd 100644 --- a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings +++ b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings @@ -671,8 +671,6 @@ // Reply "content.message.reply" = "Reply"; "content.message.original_label" = "Original message"; -"content.message.reply.original_timestamp.date" = "Original message from %@"; -"content.message.reply.original_timestamp.time" = "Original message from %@"; "content.message.reply.broken_message" = "You cannot see this message."; "content.message.reply.edited_message" = "Edited"; diff --git a/wire-ios/Wire-iOS/Sources/Helpers/ZMConversationMessage+Date.swift b/wire-ios/Wire-iOS/Sources/Helpers/ZMConversationMessage+Date.swift index 2c1c49290d7..5c65d698d34 100644 --- a/wire-ios/Wire-iOS/Sources/Helpers/ZMConversationMessage+Date.swift +++ b/wire-ios/Wire-iOS/Sources/Helpers/ZMConversationMessage+Date.swift @@ -18,63 +18,31 @@ import Foundation import WireDataModel +import WireReusableUIComponents extension ZMConversationMessage { func formattedOriginalReceivedDate() -> String? { - guard let timestamp = serverTimestamp else { - return nil - } - - let formattedDate: String - - if Calendar.current.isDateInToday(timestamp) { - formattedDate = Message.shortTimeFormatter.string(from: timestamp) - return L10n.Localizable.Content.Message.Reply.OriginalTimestamp.time(formattedDate) - - } else { - formattedDate = Message.shortDateFormatter.string(from: timestamp) - return L10n.Localizable.Content.Message.Reply.OriginalTimestamp.date(formattedDate) - } + MessageFormatter.formattedOriginalReceivedDate(serverTimestamp) } func formattedReceivedTime() -> String? { - serverTimestamp.map(Message.shortTimeFormatter.string(from:)) + MessageFormatter.formattedReceivedTime(serverTimestamp) } func formattedReceivedDateTime() -> String? { - serverTimestamp.map(formattedDate) + MessageFormatter.formattedReceivedDateTime(serverTimestamp) } func formattedEditedDate() -> String? { - updatedAt.map(formattedDate) - } - - func formattedDate(_ date: Date) -> String { - if Calendar.current.isDateInToday(date) { - Message.shortTimeFormatter.string(from: date) - } else { - Message.shortDateTimeFormatter.string(from: date) - } + MessageFormatter.formattedEditedDate(updatedAt) } func formattedAccessibleMessageDetails() -> String? { - guard let serverTimestamp else { - return nil - } - let formattedTimestamp = Message.spellOutDateTimeFormatter.string(from: serverTimestamp) - let sendDate = L10n.Localizable.MessageDetails.subtitleSendDate(formattedTimestamp) - - var accessibleMessageDetails = sendDate - - if let editTimestamp = updatedAt { - let formattedEditTimestamp = Message.spellOutDateTimeFormatter.string(from: editTimestamp) - let editDate = L10n.Localizable.MessageDetails.subtitleEditDate(formattedEditTimestamp) - - accessibleMessageDetails += ("\n" + editDate) - } - - return accessibleMessageDetails + MessageFormatter + .formattedAccessibleMessageDetails( + receivedAt: serverTimestamp, + updatedAt: updatedAt + ) } - } diff --git a/wire-ios/Wire-iOS/Sources/Helpers/syncengine/Message+DateFormatter.swift b/wire-ios/Wire-iOS/Sources/Helpers/syncengine/Message+DateFormatter.swift deleted file mode 100644 index 301b89a2c67..00000000000 --- a/wire-ios/Wire-iOS/Sources/Helpers/syncengine/Message+DateFormatter.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// 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 -import WireDataModel - -extension Message { - - static var shortTimeFormatter: DateFormatter = { - var shortTimeFormatter = DateFormatter() - shortTimeFormatter.dateStyle = .none - shortTimeFormatter.timeStyle = .short - return shortTimeFormatter - }() - - static let shortDateFormatter: DateFormatter = { - var shortDateFormatter = DateFormatter() - shortDateFormatter.dateStyle = .short - shortDateFormatter.timeStyle = .none - return shortDateFormatter - }() - - static let spellOutDateTimeFormatter: DateFormatter = { - var longDateFormatter = DateFormatter() - longDateFormatter.dateStyle = .long - longDateFormatter.timeStyle = .short - longDateFormatter.doesRelativeDateFormatting = true - return longDateFormatter - }() - - static let shortDateTimeFormatter: DateFormatter = { - var longDateFormatter = DateFormatter() - longDateFormatter.dateStyle = .short - longDateFormatter.timeStyle = .short - return longDateFormatter - }() - -} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/BurstTimestampTableViewCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/BurstTimestampTableViewCell.swift index 3fedaf06de9..61f628547c4 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/BurstTimestampTableViewCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/BurstTimestampTableViewCell.swift @@ -24,8 +24,10 @@ import WireSystem final class BurstTimestampSenderMessageCellDescription: ConversationMessageCellDescription { typealias View = BurstTimestampSenderMessageCell - @MainActor var conversationCellModel: ConversationCellModel? { + @MainActor var conversationCellModel: ConversationCellModel? + @MainActor + func makeConversationCellModel() -> ConversationCellModel { let now = currentDateProvider.now let calendar = Calendar.current lazy var isToday = calendar.isDate(now, equalTo: configuration.date, toGranularity: .day) diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationMessageToolboxCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationMessageToolboxCell.swift index e2ad21396e9..993fd1a7e3f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationMessageToolboxCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationMessageToolboxCell.swift @@ -17,14 +17,15 @@ // import UIKit +import WireConversationUI import WireDataModel import WireSyncEngine final class ConversationMessageToolboxCell: UIView, ConversationMessageCell, MessageToolboxViewDelegate { struct Configuration: Equatable { - var message: ZMConversationMessage - var deliveryState: ZMDeliveryState + var message: MessageModel + var deliveryState: DeliveryStateModel /// A message status is considered redundant if it does not provide additional information over a subsequent /// message's status. This basically means that only the last of subsequent messages of the same sender within a /// short time frame will show the status view, if the delivery state is the same. @@ -118,10 +119,11 @@ final class ConversationMessageToolboxCellDescription: ConversationMessageCellDe var message: ZMConversationMessage? { didSet { - if let message { + if let message = message as? ZMMessage { + let uiMessage = message.toUIModel() configuration = View.Configuration( - message: message, - deliveryState: message.deliveryState, + message: uiMessage, + deliveryState: uiMessage.deliveryState, isRedundant: configuration.isRedundant ) } @@ -138,9 +140,10 @@ final class ConversationMessageToolboxCellDescription: ConversationMessageCellDe init(message: ZMConversationMessage, isRedundant: Bool) { self.message = message + let uiMessage = (message as! ZMMessage).toUIModel() self.configuration = View.Configuration( - message: message, - deliveryState: message.deliveryState, + message: uiMessage, + deliveryState: uiMessage.deliveryState, isRedundant: isRedundant ) } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSenderMessageDetailsCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSenderMessageDetailsCell.swift index 40151ca7ec3..9a8e4ee4502 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSenderMessageDetailsCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/ConversationSenderMessageDetailsCell.swift @@ -19,6 +19,7 @@ import UIKit import WireAccountImageUI import WireCommonComponents +import WireConversationUI import WireDataModel import WireDesign import WireReusableUIComponents @@ -28,13 +29,6 @@ enum Indicator: Equatable { case deleted } -enum TeamRoleIndicator { - case guest - case externalPartner - case federated - case service -} - // MARK: - ConversationSenderMessageDetailsCell final class ConversationSenderMessageDetailsCell: UIView, ConversationMessageCell { @@ -354,27 +348,6 @@ final class ConversationSenderMessageCellDescription: ConversationMessageCellDes } -private extension UserType { - - func teamRoleIndicator(selfUser: any UserType) -> TeamRoleIndicator? { - if isServiceUser { - .service - - } else if isExternalPartner { - .externalPartner - - } else if isFederated { - .federated - - } else if !isTeamMember, selfUser.isTeamMember { - .guest - } else { - nil - } - } - -} - extension ConversationSenderMessageDetailsCell: UserObserving { func userDidChange(_ changeInfo: UserChangeInfo) { diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/NewTextCellDescription.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/NewTextCellDescription.swift new file mode 100644 index 00000000000..1dd1074b514 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/NewTextCellDescription.swift @@ -0,0 +1,82 @@ +// +// 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 UIKit +import WireConversationUI +import WireDataModel +import WireDesign +import WireFoundation +import WireSyncEngine +import WireSystem + +protocol NewCellDescription {} +extension NewTextCellDescription: NewCellDescription {} +extension BurstTimestampSenderMessageCellDescription: NewCellDescription {} + +final class NewTextCellDescription: ConversationMessageCellDescription { + + typealias View = NewTextCell + + @MainActor var conversationCellModel: ConversationCellModel? + + var supportsActions: Bool = true + + private var cancellables: Set = [] + + var configuration: View.Configuration { + .init(text: "", author: "", accentColor: .red) + } + + weak var message: ZMConversationMessage? + weak var delegate: ConversationMessageCellDelegate? + weak var actionController: ConversationMessageActionController? + + var topMargin = CGFloat() + var bottomMargin = CGFloat() + + let containsHighlightableContent = false + + let accessibilityIdentifier: String? = nil + let accessibilityLabel: String? = nil + + init( + conversationCellModel: ConversationCellModel + ) { + self.conversationCellModel = conversationCellModel + } +} + +final class NewTextCell: UIView, ConversationMessageCell { + + struct Configuration: Equatable { + let text: String + let author: String + let accentColor: UIColor + } + + weak var delegate: ConversationMessageCellDelegate? + weak var message: ZMConversationMessage? + weak var actionController: ConversationMessageActionController? + + var isSelected: Bool = false + + func configure(with object: Configuration, animated: Bool) {} + +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/SenderObserver.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/SenderObserver.swift new file mode 100644 index 00000000000..b7bf59be557 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/SenderObserver.swift @@ -0,0 +1,52 @@ +// +// 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 WireConversationUI +import WireDataModel + +final class SenderObserver: NSObject, UserObserving, SenderObserverProtocol { + + var observation: Any? + + var author: String? + private let authorChangedSubject = PassthroughSubject() + var authorChangedPublisher: AnyPublisher { + authorChangedSubject + .removeDuplicates() + .eraseToAnyPublisher() + } + + init( + messageID: NSManagedObjectID, + viewContext: NSManagedObjectContext + ) { + super.init() + viewContext.perform { + let message = try! viewContext.existingObject(with: messageID) as! ZMMessage + self.author = message.senderName + if let sender = message.senderUser { + self.observation = UserChangeInfo.add(observer: self, for: sender, context: viewContext) + } + } + } + + func userDidChange(_ changeInfo: UserChangeInfo) { + authorChangedSubject.send(changeInfo.user.name ?? "") + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/StatusObserver.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/StatusObserver.swift new file mode 100644 index 00000000000..85600092aec --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/Components/StatusObserver.swift @@ -0,0 +1,56 @@ +// +// 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 WireConversationUI +import WireDataModel + +final class StatusObserver: NSObject, ZMMessageObserver, StatusObserverProtocol { + + var observation: Any? + + private let statusChangedSubject = PassthroughSubject() + var statusChangedPublisher: AnyPublisher { + statusChangedSubject + .removeDuplicates() +// .debounce(for: .seconds(1), scheduler: RunLoop.main) + .eraseToAnyPublisher() + } + + init( + messageID: NSManagedObjectID, + viewContext: NSManagedObjectContext + ) { + super.init() + viewContext.perform { + let message = try! viewContext.existingObject(with: messageID) as! ZMMessage + self.send(message) + self.observation = MessageChangeInfo + .add(observer: self, for: message, context: viewContext) + } + } + + func messageDidChange(_ changeInfo: MessageChangeInfo) { + send(changeInfo.message) + } + + private func send(_ message: ZMMessage) { + let uiMessage = message.toUIModel() + statusChangedSubject.send(uiMessage) + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageCell.swift index 71e8f183533..1a866483a9f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageCell.swift @@ -54,6 +54,9 @@ protocol ConversationMessageCellDelegate: AnyObject, MessageActionResponder { func conversationMessageShouldUpdate() + // TODO: Remove + func conversationMessageDidRequestToUpdate(nonce: UUID) + } /// A generic view that displays conversation contents. @@ -144,7 +147,8 @@ protocol ConversationMessageCellDescription: AnyObject { /// A new type of model to replace the cell descriptions eventually. /// In order to allow incremental migration to the new approach, the model will be part of the cell description for /// now. - var conversationCellModel: ConversationCellModel? { get } + var conversationCellModel: ConversationCellModel? { get set } +// func makeConversationCellModel() -> ConversationCellModel /// The top margin is used to configure the spacing between the current and the previous cell. var topMargin: CGFloat { get set } @@ -191,7 +195,8 @@ protocol ConversationMessageCellDescription: AnyObject { extension ConversationMessageCellDescription { var conversationCellModel: ConversationCellModel? { - nil + get { fatalError() } + set { fatalError() } } var supportsActions: Bool { diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift index 972349758d6..5cfe088172f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/ConfigurationMessageCell/ConversationMessageSectionController.swift @@ -17,8 +17,21 @@ // import Foundation +import WireConversationUI import WireFoundation import WireSyncEngine +import WireDataModel +import WireDataModel + +protocol MessageViewModelFactory { + func makeTextMessageViewModel( + message: ZMMessage, + selfUser: any UserType, + accentColor: UIColor, + shouldShowSender: Bool, + shouldShowStatus: Bool + ) -> TextMessageViewModel +} struct ConversationMessageContext: Equatable { var isSameSenderAsPrevious: Bool = false @@ -84,7 +97,9 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { didSet { updateDelegates() changeObservers.removeAll() - startObservingChanges(for: message) + if !message.isText { + startObservingChanges(for: message) + } } } @@ -111,6 +126,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { private var changeObservers: [Any] = [] private let userSession: UserSession + private let factory: MessageViewModelFactory private let privateDefaults: PrivateUserDefaults /// width of a container view to calculate whether message should be collapsed @@ -128,7 +144,8 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { userSession: UserSession, useInvertedIndices: Bool, contentWidth: CGFloat, - userDefaults: UserDefaultsProtocol = UserDefaults.standard + userDefaults: UserDefaultsProtocol = UserDefaults.standard, + factory: MessageViewModelFactory ) { self.message = message self.context = context @@ -137,6 +154,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { self.userSession = userSession self.useInvertedIndices = useInvertedIndices self.contentWidth = contentWidth + self.factory = factory self.privateDefaults = PrivateUserDefaults( userID: selfUser.remoteIdentifier, storage: userDefaults @@ -414,9 +432,27 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { private func createCellDescriptions(in context: ConversationMessageContext) { var cellDescriptions = [AnyConversationMessageCellDescription]() - let isBurstTimestampVisible = isBurstTimestampVisible(in: context) + let isToolboxVisible = isToolboxVisible(in: context) let isSenderVisible = shouldShowSenderDetails(in: context) + if message.isText { + self.cellDescriptions = [ + NewTextCellDescription( + conversationCellModel: + .text(factory.makeTextMessageViewModel( + message: message as! ZMMessage, + selfUser: selfUser, + accentColor: selfUser.accentColor, + shouldShowSender: isSenderVisible, + shouldShowStatus: isToolboxVisible + )) + ).eraseToAnyCellDescription() + ] + return + } + + let isBurstTimestampVisible = isBurstTimestampVisible(in: context) + if isBurstTimestampVisible { let description = BurstTimestampSenderMessageCellDescription( message: message, @@ -442,7 +478,7 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { to: &cellDescriptions ) - if isToolboxVisible(in: context) { + if isToolboxVisible { let description = ConversationMessageToolboxCellDescription(message: message, isRedundant: false) cellDescriptions.append(AnyConversationMessageCellDescription(description)) } @@ -618,12 +654,18 @@ final class ConversationMessageSectionController: NSObject, ZMMessageObserver { return // Deletions are handled by the window observer } + if message.isText { + return + } sectionDelegate?.messageSectionController(self, didRequestRefreshForMessage: message) } } extension ConversationMessageSectionController: UserObserving { func userDidChange(_ changeInfo: UserChangeInfo) { + if message.isText { + return + } sectionDelegate?.messageSectionController(self, didRequestRefreshForMessage: message) } } @@ -651,3 +693,9 @@ extension ConversationMessageSectionController { return boundingBox.height > maxHeight } } + +extension ConversationMessageCellDescription { + func eraseToAnyCellDescription() -> AnyConversationMessageCellDescription { + AnyConversationMessageCellDescription(self) + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageToolboxView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageToolboxView.swift index 46e08beb20e..8d02e0a1e41 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageToolboxView.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/Cells/Utility/MessageToolboxView.swift @@ -17,6 +17,7 @@ // import UIKit +import WireConversationUI import WireDataModel import WireDesign import WireSyncEngine @@ -284,18 +285,13 @@ final class MessageToolboxView: UIView { // MARK: - Configuration - private var contentWidth: CGFloat { - bounds.width - conversationHorizontalMargins.left - conversationHorizontalMargins.right - } - func configureForMessage( - _ message: ZMConversationMessage, + _ message: MessageModel, animated: Bool = false ) { noHeightConstraint.isActive = false - if let message = message as? ConversationMessage, - dataSource?.message.nonce != message.nonce { + if dataSource?.message.nonce != message.nonce { dataSource = MessageToolboxDataSource(message: message) } @@ -317,9 +313,7 @@ final class MessageToolboxView: UIView { guard let dataSource else { return } // Do not reload the content if it didn't change. - guard dataSource.shouldUpdateContent( - widthConstraint: contentWidth - ) else { + guard dataSource.shouldUpdateContent() else { return } @@ -356,7 +350,7 @@ final class MessageToolboxView: UIView { timestampSeparatorContainer.isHidden = timestamp.isEmpty || state == nil statusSeparatorContainer.isHidden = (timestamp.isEmpty && state == nil) || countdown.isEmpty - countdownView.setProgress(dataSource.message.countdownProgress ?? 0) + // countdownView.setProgress(dataSource.message.countdownProgress ?? 0) // TODO: countdownContainer.isHidden = countdown.isEmpty countdownLabel.text = countdown countdownLabel.isHidden = countdown.isEmpty @@ -401,12 +395,13 @@ final class MessageToolboxView: UIView { func startCountdownTimer() { stopCountdownTimer() - guard let message = dataSource?.message else { return } - guard message.isEphemeral, !message.hasBeenDeleted, !message.isObfuscated else { return } - - timestampTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - self?.reloadContent(animated: false) - } + // TODO: +// guard let message = dataSource?.message else { return } +// guard message.isEphemeral, !message.hasBeenDeleted, !message.isObfuscated else { return } +// +// timestampTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in +// self?.reloadContent(animated: false) +// } } /// Stops the countdown timer. @@ -446,7 +441,8 @@ extension MessageToolboxView: UIGestureRecognizerDelegate { switch dataSource.content { case .sendFailure: break - case .details where dataSource.message.areReadReceiptsDetailsAvailable: +// case .details where dataSource.message.areReadReceiptsDetailsAvailable: // TODO + case .details: return .receipts default: break diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+ConversationMessageCellDelegate.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+ConversationMessageCellDelegate.swift index ed0c9aa2280..8daebda8bce 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+ConversationMessageCellDelegate.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController+ConversationMessageCellDelegate.swift @@ -128,4 +128,8 @@ extension ConversationContentViewController: ConversationMessageCellDelegate { dataSource.loadMessages(forceRecalculate: true) } + func conversationMessageDidRequestToUpdate(nonce: UUID) { + dataSource.updateMessage(nonce: nonce) + } + } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift index 5678abae7d4..4e8add8eaec 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationContentViewController.swift @@ -93,7 +93,8 @@ final class ConversationContentViewController: UIViewController { actionResponder: self, cellDelegate: self, userSession: userSession, - getUserByIDUseCase: GetUserByIdUseCase() + getUserByIDUseCase: GetUserByIdUseCase(), + factory: MessageViewModelFactoryImpl(userSession: userSession) ) /// Fired regularly in order to always correct time values (like the number of seconds a self-deleting message has @@ -521,7 +522,8 @@ final class ConversationContentViewController: UIViewController { for indexPath in tableView.indexPathsForVisibleRows ?? [] { let section = dataSource.currentSections[indexPath.section] for cellDescription in section.elements { - if let refreshInterval = cellDescription.conversationCellModel?.refreshInterval, refreshInterval > 0 { + if cellDescription is NewCellDescription, + let refreshInterval = cellDescription.conversationCellModel?.refreshInterval, refreshInterval > 0 { timeInterval = timeInterval == .zero ? refreshInterval : min(timeInterval, refreshInterval) @@ -554,7 +556,8 @@ final class ConversationContentViewController: UIViewController { for indexPath in tableView.indexPathsForVisibleRows ?? [] { let section = dataSource.currentSections[indexPath.section] let cellDescription = section.elements[indexPath.row] - if let refreshInterval = cellDescription.conversationCellModel?.refreshInterval, refreshInterval > 0 { + if cellDescription is NewCellDescription, + let refreshInterval = cellDescription.conversationCellModel?.refreshInterval, refreshInterval > 0 { indexPathsToReload += [indexPath] continue } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift index d60f7da30b6..0d6b3e63852 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/ConversationTableViewDataSource.swift @@ -17,6 +17,7 @@ // import DifferenceKit +import WireConversationUI import WireDataModel import WireFoundation import WireLogging @@ -72,6 +73,7 @@ final class ConversationTableViewDataSource: NSObject { weak var conversationCellDelegate: ConversationMessageCellDelegate? weak var messageActionResponder: MessageActionResponder? private let getUserByIDUseCase: GetUserByIDUseCaseProtocol + private let factory: MessageViewModelFactory let debouncer = LeadingTrailingDebouncer(cooldownTime: 0.3) @@ -105,6 +107,13 @@ final class ConversationTableViewDataSource: NSObject { private(set) var currentSections: [Section] = [] + func updateMessage(nonce: UUID) { + guard let sectionController = sectionControllers.get(for: nonce) else { + return + } + reloadSections(newSections: calculateSections(updating: sectionController)) + } + /// calculate cell sections /// /// - Parameter forceRecalculate: true if force recreate cell with context check @@ -113,13 +122,14 @@ final class ConversationTableViewDataSource: NSObject { forceRecalculate: Bool = false, completion: @escaping ([Section]) -> Void ) { + print("DS: calculateSections ALL called") let mainThreadContext = userSession.contextProvider.viewContext let messagesOnMainThread = allMessages let messageIds = messagesOnMainThread.map(\.objectID) let selfUserOnMainThread = userSession.selfUser let selfUserObjectID = selfUserOnMainThread.objectId let firstUnreadMessageNonce = firstUnreadMessage?.nonce - + print("DS: all messages count: \(messagesOnMainThread.count), currentSections count: \(currentSections.count)") // Dispatching to background thread to offload sections calculation let backgroundContext = userSession.contextProvider.newBackgroundContext() @@ -130,21 +140,26 @@ final class ConversationTableViewDataSource: NSObject { try? backgroundContext.existingObject(with: objectID) as? ZMMessage } - // sort if needed - messages = messages.sorted { - ($0.serverTimestamp ?? .distantPast) > ($1.serverTimestamp ?? .distantPast) - } + print("DS: background messages count: \(messages.count)") + + guard messages.count == messageIds.count, // TODO: moving + let selfUserOnBackgroundThread = getUserByIDUseCase.getUserByID( + id: selfUserObjectID, + context: backgroundContext + ) else { + print("DS: calculateSections: exiting early - count mismatch: \(messages.count):\(messageIds.count)") - guard let selfUserOnBackgroundThread = getUserByIDUseCase.getUserByID( - id: selfUserObjectID, - context: backgroundContext - ) else { DispatchQueue.main.async { completion(self.currentSections) } return } + // sort if needed + messages = messages.sorted { + ($0.serverTimestamp ?? .distantPast) > ($1.serverTimestamp ?? .distantPast) + } + // Go through messages and calculate sections let result = messages.enumerated().map { offset, element in let context = self.context( @@ -177,7 +192,6 @@ final class ConversationTableViewDataSource: NSObject { DispatchQueue.main.async { var sections = [Section]() - let allMessages = messagesOnMainThread for (messageObjectId, sectionController, context) in result { // saving calculations result in local cache @@ -207,7 +221,7 @@ final class ConversationTableViewDataSource: NSObject { elements: sectionController.tableViewCellDescriptions )) } - + print("DS: newSections count: \(sections.count)") completion( self.postProcessedSections( sections, @@ -223,6 +237,7 @@ final class ConversationTableViewDataSource: NSObject { func calculateSections( updating sectionController: ConversationMessageSectionController ) -> [Section] { + print("DS: calculateSections: updating sectionController: \(sectionController.message.text ?? "-")") let sectionIdentifier = sectionController.message.nonce! guard let section = currentSections.firstIndex(where: { $0.model == sectionIdentifier }) @@ -271,7 +286,8 @@ final class ConversationTableViewDataSource: NSObject { actionResponder: MessageActionResponder, cellDelegate: ConversationMessageCellDelegate, userSession: UserSession, - getUserByIDUseCase: GetUserByIDUseCaseProtocol + getUserByIDUseCase: GetUserByIDUseCaseProtocol, + factory: MessageViewModelFactory ) { self.messageActionResponder = actionResponder self.conversationCellDelegate = cellDelegate @@ -279,9 +295,13 @@ final class ConversationTableViewDataSource: NSObject { self.tableView = tableView self.userSession = userSession self.getUserByIDUseCase = getUserByIDUseCase + self.factory = factory + super.init() tableView.dataSource = self + + tableView.register(ConversationCell.self, forCellReuseIdentifier: "ConversationCell") } func resetSectionControllers() { @@ -349,7 +369,8 @@ final class ConversationTableViewDataSource: NSObject { selected: message.isEqual(selectedMessage), userSession: userSession, useInvertedIndices: true, - contentWidth: contentWidth + contentWidth: contentWidth, + factory: factory ) sectionController.cellDelegate = conversationCellDelegate sectionController.sectionDelegate = self @@ -575,10 +596,6 @@ final class ConversationTableViewDataSource: NSObject { extension ConversationTableViewDataSource: NSFetchedResultsControllerDelegate { - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - // no-op - } - func controller( _ controller: NSFetchedResultsController, didChange anObject: Any, @@ -586,22 +603,63 @@ extension ConversationTableViewDataSource: NSFetchedResultsControllerDelegate { for changeType: NSFetchedResultsChangeType, newIndexPath: IndexPath? ) { + + print( + "DS: controller didChange object \(indexPath), sections count: \(currentSections.count), all Messages count: \(allMessages.count)" + ) + if let message = anObject as? ZMConversationMessage, changeType == .insert { /// VoiceOver will output the announcement string from the message message.postAnnouncementIfNeeded() } - } - func controller( - _ controller: NSFetchedResultsController, - didChange sectionInfo: NSFetchedResultsSectionInfo, - atSectionIndex sectionIndex: Int, - for changeType: NSFetchedResultsChangeType - ) { - // no-op + switch changeType { + case .insert: + print("DS: Inserted at \(newIndexPath!)") + case .delete: + print("DS: Deleted at \(indexPath!)") + case .update: + print("DS: Updated at \(indexPath!)") + case .move: + print("DS: Moved from \(indexPath!) to \(newIndexPath!)") + @unknown default: + break + } + + switch changeType { + case .insert, .delete, .move: +// guard let indexPath else { break } +// let sectionController = sectionController( +// at: indexPath.section, +// selfUser: userSession.selfUser, +// messages: allMessages +// ) +// let message = sectionController.message +// +// debouncer.call(id: message.nonce!) { [weak self] in +// guard let self else { return } +// reloadSections(newSections: calculateSections(updating: sectionController)) +// } +// break + debouncer.call(id: nil) { [weak self] in + self?.calculateSections { sections in + self?.reloadSections(newSections: sections) + } + } + case .update: + break + @unknown default: + break + } } func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + // no - op + print( + "DS: controllerDidChangeContent: sections count: \(currentSections.count), all Messages count: \(allMessages.count)" + ) + guard currentSections.count != allMessages.count else { return } + // TODO: moving (retry message send) debouncer.call(id: nil) { [weak self] in self?.calculateSections { sections in self?.reloadSections(newSections: sections) @@ -693,13 +751,20 @@ extension ConversationTableViewDataSource: UITableViewDataSource { } let cellDescription = section.elements[indexPath.row] - if let model = cellDescription.conversationCellModel { - - model.registerIfNeeded(in: tableView) - let cell = tableView.dequeueReusableCell(withIdentifier: model.cellReuseIdentifier, for: indexPath) - model.configureCell(cell) + if cellDescription.instance is NewCellDescription, + let model = cellDescription.conversationCellModel { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: "ConversationCell", + for: indexPath + ) as? ConversationCell else { + return UITableViewCell() + } + let margins = cell.conversationHorizontalMargins + cell.configure(model: model, horizontalMargins: .init( + left: margins.left, + right: margins.right + )) return cell - } else { registerCellIfNeeded(with: cellDescription, in: tableView) @@ -715,6 +780,7 @@ extension ConversationTableViewDataSource: ConversationMessageSectionControllerD _ controller: ConversationMessageSectionController, didRequestRefreshForMessage message: ZMConversationMessage ) { + guard !message.isText else { return } debouncer.call(id: message.nonce!) { [weak self] in guard let self else { return } reloadSections(newSections: calculateSections(updating: controller)) @@ -837,17 +903,35 @@ extension ConversationTableViewDataSource { // collapse the status view's height let previousMessage = messages[previousSectionIndex] - let newCellDescription = ConversationMessageToolboxCellDescription( - message: previousMessage, - isRedundant: true - ) - newCellDescription.topMargin = 0 - newCellDescription.bottomMargin = 0 // we notify the table view by creating a new cell description - let previousStatus = statusCellDescription(for: previousSectionIndex, in: sections) - newCellDescription.delegate = previousStatus?.cellDescription.delegate - previousStatus?.replace(newCellDescription, §ions) + if let previousStatus = statusCellDescription(for: previousSectionIndex, in: sections) { + let newCellDescription = ConversationMessageToolboxCellDescription( + message: previousMessage, + isRedundant: true + ) + newCellDescription.topMargin = 0 + newCellDescription.bottomMargin = 0 + + newCellDescription.delegate = previousStatus.cellDescription.delegate + previousStatus.replace(newCellDescription, §ions) + } + + // we notify the table view by creating a new cell description +// if let newPreviousStatus = newStatusCellDescription(for: previousSectionIndex, in: sections) { +// let newNewTextDescription = NewTextCellDescription( +// conversationCellModel: +// .text(factory.makeTextMessageViewModel( +// message: previousMessage, +// selfUser: selfUser, +// accentColor: selfUser.accentColor, +// shouldShowStatus: false +// )) +// ) +// newNewTextDescription.message = previousMessage +// newNewTextDescription.delegate = newPreviousStatus.cellDescription.delegate +// newPreviousStatus.replace(newNewTextDescription, §ions) +// } // for collapsing the space we will refer to the cell description before the previous message's status if let newPreviousSectionLastElement = sections[previousSectionIndex].elements.first?.instance { @@ -901,6 +985,36 @@ extension ConversationTableViewDataSource { return nil } + // TRY MAKE GENERIC +// private func newStatusCellDescription( +// for sectionIndex: Int, +// in sections: [Section] +// ) -> ( +// cellDescription: NewTextCellDescription, +// replace: (_ cellDescription: NewTextCellDescription, _ sections: inout [Section]) -> Void +// )? { +// +// for elementIndex in sections[sectionIndex].elements.indices { +// let cellDescription = sections[sectionIndex].elements[elementIndex].instance +// +// if let cellDescription = cellDescription as? NewTextCellDescription { +// +// func replace( +// by newCellDescription: NewTextCellDescription, +// in sections: inout [Section] +// ) { +// sections[sectionIndex] +// .elements[elementIndex] = AnyConversationMessageCellDescription(newCellDescription) +// } +// +// return (cellDescription, replace) +// } +// +// } +// +// return nil +// } + private func isMessageStatus( of previousIndex: Int, redundantTo currentIndex: Int, diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/MessageViewModelFactory.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/MessageViewModelFactory.swift new file mode 100644 index 00000000000..0aad80fdb57 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Content/MessageViewModelFactory.swift @@ -0,0 +1,164 @@ +// +// 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 +import WireConversationUI +import WireDataModel +import WireSyncEngine + +struct MessageViewModelFactoryImpl: MessageViewModelFactory { + + private let userSession: UserSession + + init(userSession: UserSession) { + self.userSession = userSession + } + + func makeTextMessageViewModel( + message: ZMMessage, + selfUser: any UserType, + accentColor: UIColor, + shouldShowSender: Bool, + shouldShowStatus: Bool + ) -> TextMessageViewModel { + let context = userSession.contextProvider.viewContext + let messagedObjectID = message.objectID + + var senderViewModelWrapper: MessageSenderViewModelWrapper? = .init(state: .none) + if shouldShowSender, let sender = message.sender { + senderViewModelWrapper = MessageSenderViewModelWrapper.init(state: .some( + MessageSenderViewModel( + avatarViewModel: AvatarViewModel( + color: accentColor.color + ), + senderModel: sender.toUIModel(), + isDeleted: message.isDeletion, + teamRoleIndicator: sender.teamRoleIndicator(selfUser: selfUser), + authorChanged: SenderObserver( + messageID: messagedObjectID, + viewContext: context + ) + ) + )) + } + + let statusViewModel = if shouldShowStatus { + MessageStatusViewModel( + messageModel: message.toUIModel(), + statusObserver: StatusObserver( + messageID: messagedObjectID, + viewContext: context + ) + ) + } else { + MessageStatusViewModel.none() + } + + return TextMessageViewModel( + text: message.textMessageData?.messageText ?? "", + senderViewModelWrapper: senderViewModelWrapper, + statusViewModel: statusViewModel + ) + } +} + +extension UserType { + func toUIModel() -> UserModel { + UserModel( + name: name, + isSelfUser: true, + isServiceUser: isServiceUser, + accentColor: accentColor + ) + } +} + +extension UserType { + + func teamRoleIndicator(selfUser: any UserType) -> TeamRoleIndicator? { + if isServiceUser { + .service + + } else if isExternalPartner { + .externalPartner + + } else if isFederated { + .federated + + } else if !isTeamMember, selfUser.isTeamMember { + .guest + } else { + nil + } + } + +} + +extension ZMMessage { + func toUIModel() -> MessageModel { + .init( + nonce: nonce, + sender: sender?.toUIModel(), + systemMessageType: systemMessageData?.systemMessageType.toUIModel(), + updatedAt: updatedAt, + receivedAt: serverTimestamp, + expirationReason: expirationReason?.toUIModel(), + conversationType: conversation?.conversationType.toUIModel(), + readReceiptsCount: readReceipts.count, + deliveryState: deliveryState.toUIModel(), + isSent: isSent + ) + } +} + +extension ZMDeliveryState { + func toUIModel() -> DeliveryStateModel { + switch self { + case .invalid: + .invalid + case .pending: + .pending + case .sent: + .sent + case .delivered: + .delivered + case .read: + .read + case .failedToSend: + .failedToSend + } + } +} + +extension ZMSystemMessageType { + func toUIModel() -> SystemMessageTypeModel? { + .init(rawValue: Int(rawValue)) + } +} + +extension ExpirationReason { + func toUIModel() -> ExpirationReasonModel? { + .init(rawValue: Int(rawValue)) + } +} + +extension ZMConversationType { + func toUIModel() -> ConversationTypeModel? { + .init(rawValue: Int(rawValue)) + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Message Details/MessageDetailsCellDescription.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Message Details/MessageDetailsCellDescription.swift index 76b86aa026a..dc13110233f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Message Details/MessageDetailsCellDescription.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/Message Details/MessageDetailsCellDescription.swift @@ -72,8 +72,10 @@ extension MessageDetailsCellDescription { static func makeReceiptCell(_ receipts: [ReadReceipt]) -> [MessageDetailsCellDescription] { receipts.map { - let formattedDate = $0.serverTimestamp.map(Message.shortDateTimeFormatter.string) - let formattedAccessibleDate = $0.serverTimestamp.map(Message.spellOutDateTimeFormatter.string) + let formattedDate = $0.serverTimestamp.map( + DateFormatter.shortDateTimeFormatter.string + ) + let formattedAccessibleDate = $0.serverTimestamp.map(DateFormatter.spellOutDateTimeFormatter.string) return MessageDetailsCellDescription( user: $0.userType,