Sliding Sync + New Timeline API (#189)

* Begin adopting new Timeline API.

* Add edited indicator and reactions.

* vector-im/element-x-ios/issues/65 - Sliding sync support
* Fix missing room display name, wrong placeholder avatar text color and various other warnings that would fail the build on the CI

* Various tweaks:
* using release version of the demo branch of the sdk
* enabled home screen last room messages
* switched debug mode rust logging to warn
* enabled redactions
* enabled new logout flows and soft logout
* enabled replies

* Fix room member display name and avatar crashes / race condition, fix unit tests
* Make the ClientProxy and the UserSession MainActors
* Remove unused MatrixRustSDK imports, we should strive to keep these only in top level services and proxies
* Don't start either of the syncs while in soft logout

* #181: Style the session verification banner to match Figma.
* #181: Update verification modal.
* #181: Update snapshot tests.

* Make session verification state machine less pedantic
* Remove unnecessary weak selfs
* Various tweaks following code review:
* add start and stop sync client proxy methods
* move ss proxy url the build settings
* made media provider load results discardable
* added publishers for the roomSummaryProvider's total number of rooms and state

* Fix when sender details are shown
* Disable sync v2, causes duplicates in the timeline (as expected)
* Move ClientProxy media loading off the main queue and into a detached task
* Another attempt at moving image loading off the main queue
* Moved home screen diffing and latest room fetching to the background
* Prevent the timeline composer from becoming the first responder when not needed

* Bump to a newer version of the RustSDK

* Fixes vector-im/element-x-ios/issues/107 - New home screen design
* Implement thumbnail loading instead of full image avatars.
* Revert "Disable sync v2, causes duplicates in the timeline (as expected)"
* Add support for local echoes, dispatching detached tasks to a concurrenc GCD queue
* Move the session verification banner to a List Section to avoid UI glitches
* Optimise room mapping after sliding sync updates and thumbnail fetching
* Replace home screen List with a LazyVStack in an attempt to fix performance. Moved move summary provider room updating to a background thread

* Fixes vector-im/element-x-ios/issues/177 - New Bubbles Design
* Define in group state for timeline items
* Add replies into the bubble
* Add timeline width environment value
* Add `RoundedCorner` shape with specific corners rounding
* Add in group state for previews
* Implement bubble grouping logic
* Timeline avatar layout changes
* Fix placeholder avatars for dark mode
* New bubbles design
* Update mock timeline items
* Update timeline separator design
* Update room screen reference screenshots
* Add changelog
* Formatting fixes
* Add some space before single or beginning outgoing items

* Redesign the message composer

* Handle the msgtype enum.

* Update room name label line limit and incoming bubble background. Disabled syncv2, ss withCommonExtensions and session verification controller checking

* Increase default back pagination limit.
* Stop parsing links and tidy up composer button.
* Also fix the frame of an image whilst loading.
* Bump SDK package version.

* Remove app states about settings
* Add strings
* Use colors on placeholder avatars
* Tiny changes for placeholder avatars
* Update settings screen design
* Provide a user display name from the mock client
* Settings screen presentation logic
* Add changelog
* Update reference screenshots

Co-authored-by: Doug <douglase@element.io>
Co-authored-by: ismailgulek <ismailgulek@users.noreply.github.com>
Co-authored-by: ismailgulek <ismailg@matrix.org>
This commit is contained in:
Stefan Ceriu
2022-09-21 11:21:58 +03:00
committed by GitHub
parent a88491be1a
commit 5ebe923991
124 changed files with 2502 additions and 1838 deletions
@@ -22,39 +22,74 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
var timelineItems: [RoomTimelineItemProtocol] = [SeparatorRoomTimelineItem(id: UUID().uuidString,
text: "Yesterday"),
TextRoomTimelineItem(id: UUID().uuidString,
text: "You rock!",
timestamp: "10:10 AM",
shouldShowSenderDetails: true,
isOutgoing: false,
senderId: "",
senderDisplayName: "Some user with a really long long long long long display name",
properties: RoomTimelineItemProperties(isEdited: true)),
TextRoomTimelineItem(id: UUID().uuidString,
text: "You also rule!",
timestamp: "10:11 AM",
shouldShowSenderDetails: false,
isOutgoing: false,
senderId: "",
senderDisplayName: "Alice",
properties: RoomTimelineItemProperties(reactions: [
AggregatedReaction(key: "🙌", count: 1, isHighlighted: true)
])),
SeparatorRoomTimelineItem(id: UUID().uuidString,
text: "Today"),
TextRoomTimelineItem(id: UUID().uuidString,
text: "You too!",
timestamp: "5 PM",
shouldShowSenderDetails: false,
isOutgoing: true,
senderId: "",
senderDisplayName: "Bob",
properties: RoomTimelineItemProperties(reactions: [
AggregatedReaction(key: "🙏", count: 1, isHighlighted: false),
AggregatedReaction(key: "😁", count: 3, isHighlighted: false)
]))]
var timelineItems: [RoomTimelineItemProtocol] = [
SeparatorRoomTimelineItem(id: UUID().uuidString,
text: "Yesterday"),
TextRoomTimelineItem(id: UUID().uuidString,
text: "That looks so good!",
timestamp: "10:10 AM",
shouldShowSenderDetails: true,
inGroupState: .single,
isOutgoing: false,
senderId: "",
senderDisplayName: "Jacob",
properties: RoomTimelineItemProperties(isEdited: true)),
TextRoomTimelineItem(id: UUID().uuidString,
text: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗",
timestamp: "10:11 AM",
shouldShowSenderDetails: true,
inGroupState: .beginning,
isOutgoing: false,
senderId: "",
senderDisplayName: "Helena",
properties: RoomTimelineItemProperties(reactions: [
AggregatedReaction(key: "🙌", count: 1, isHighlighted: true)
])),
TextRoomTimelineItem(id: UUID().uuidString,
text: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/",
timestamp: "10:11 AM",
shouldShowSenderDetails: false,
inGroupState: .middle,
isOutgoing: false,
senderId: "",
senderDisplayName: "Helena",
properties: RoomTimelineItemProperties(reactions: [
AggregatedReaction(key: "🙏", count: 1, isHighlighted: false),
AggregatedReaction(key: "🙌", count: 2, isHighlighted: true)
])),
SeparatorRoomTimelineItem(id: UUID().uuidString,
text: "Today"),
TextRoomTimelineItem(id: UUID().uuidString,
text: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!",
timestamp: "5 PM",
shouldShowSenderDetails: false,
inGroupState: .end,
isOutgoing: false,
senderId: "",
senderDisplayName: "Helena",
properties: RoomTimelineItemProperties()),
TextRoomTimelineItem(id: UUID().uuidString,
text: "And John's speech was amazing!",
timestamp: "5 PM",
shouldShowSenderDetails: false,
inGroupState: .single,
isOutgoing: true,
senderId: "",
senderDisplayName: "Bob",
properties: RoomTimelineItemProperties()),
TextRoomTimelineItem(id: UUID().uuidString,
text: "New home office set up!",
timestamp: "5 PM",
shouldShowSenderDetails: false,
inGroupState: .single,
isOutgoing: true,
senderId: "",
senderDisplayName: "Bob",
properties: RoomTimelineItemProperties(reactions: [
AggregatedReaction(key: "🙏", count: 1, isHighlighted: false),
AggregatedReaction(key: "😁", count: 3, isHighlighted: false)
]))
]
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineControllerError> {
.failure(.generic)
@@ -0,0 +1,34 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
struct MockRoomTimelineProvider: RoomTimelineProviderProtocol {
var callbacks = PassthroughSubject<RoomTimelineProviderCallback, Never>()
var items = [RoomTimelineProviderItem]()
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError> {
.failure(.failedPaginatingBackwards)
}
func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result<Void, RoomTimelineProviderError> {
.failure(.failedSendingMessage)
}
func redact(_ eventID: String) async -> Result<Void, RoomTimelineProviderError> {
.failure(.failedRedactingItem)
}
}
@@ -23,7 +23,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private let timelineProvider: RoomTimelineProviderProtocol
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
private let mediaProvider: MediaProviderProtocol
private let memberDetailProvider: MemberDetailProviderProtocol
private let roomProxy: RoomProxyProtocol
private var cancellables = Set<AnyCancellable>()
private var timelineItemsUpdateTask: Task<Void, Never>? {
@@ -42,13 +42,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
timelineProvider: RoomTimelineProviderProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol,
memberDetailProvider: MemberDetailProviderProtocol) {
roomProxy: RoomProxyProtocol) {
self.userId = userId
self.roomId = roomId
self.timelineProvider = timelineProvider
self.timelineItemFactory = timelineItemFactory
self.mediaProvider = mediaProvider
self.memberDetailProvider = memberDetailProvider
self.roomProxy = roomProxy
self.timelineProvider
.callbacks
@@ -133,29 +133,34 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private func asyncUpdateTimelineItems() async {
var newTimelineItems = [RoomTimelineItemProtocol]()
var previousMessage: RoomMessageProtocol?
for message in timelineProvider.messages {
for (index, item) in timelineProvider.items.enumerated() {
if Task.isCancelled {
return
}
let previousItem = timelineProvider.items[safe: index - 1]
let nextItem = timelineProvider.items[safe: index + 1]
let inGroupState = inGroupState(for: item, previousItem: previousItem, nextItem: nextItem)
let areMessagesFromTheSameDay = haveSameDay(lhs: previousMessage, rhs: message)
let shouldAddSectionHeader = !areMessagesFromTheSameDay
if shouldAddSectionHeader {
newTimelineItems.append(SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(),
text: message.originServerTs.formatted(date: .complete, time: .omitted)))
switch item {
case .event(let eventItem):
guard eventItem.isMessage else { break } // To be handled in the future
newTimelineItems.append(await timelineItemFactory.buildTimelineItemFor(eventItem: eventItem,
showSenderDetails: inGroupState.shouldShowSenderDetails,
inGroupState: inGroupState))
case .virtual:
// case .virtual(let virtualItem):
// newTimelineItems.append(SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(),
// text: message.originServerTs.formatted(date: .complete, time: .omitted)))
#warning("Fix the UUID or \"bad things will happen\"")
newTimelineItems.append(SeparatorRoomTimelineItem(id: UUID().uuidString,
text: "The day before"))
case .other:
break
}
let areMessagesFromTheSameSender = (previousMessage?.sender == message.sender)
let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay
newTimelineItems.append(await timelineItemFactory.buildTimelineItemFor(message: message,
isOutgoing: message.sender == userId,
showSenderDetails: shouldShowSenderDetails))
previousMessage = message
}
if Task.isCancelled {
@@ -166,13 +171,50 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
callbacks.send(.updatedTimelineItems)
}
private func haveSameDay(lhs: RoomMessageProtocol?, rhs: RoomMessageProtocol?) -> Bool {
guard let lhs = lhs, let rhs = rhs else {
return false
private func inGroupState(for item: RoomTimelineProviderItem,
previousItem: RoomTimelineProviderItem?,
nextItem: RoomTimelineProviderItem?) -> TimelineItemInGroupState {
guard let previousItem = previousItem else {
// no previous item, check next item
guard let nextItem = nextItem else {
// no next item neither, this is single
return .single
}
guard nextItem.canBeGrouped(with: item) else {
// there is a next item but can't be grouped, this is single
return .single
}
// next will be grouped with this one, this is the start
return .beginning
}
return Calendar.current.isDate(lhs.originServerTs, inSameDayAs: rhs.originServerTs)
guard let nextItem = nextItem else {
// no next item
guard item.canBeGrouped(with: previousItem) else {
// there is a previous item but can't be grouped, this is single
return .single
}
// will be grouped with previous, this is the end
return .end
}
guard item.canBeGrouped(with: previousItem) else {
guard nextItem.canBeGrouped(with: item) else {
// there is a next item but can't be grouped, this is single
return .single
}
// next will be grouped with this one, this is the start
return .beginning
}
guard nextItem.canBeGrouped(with: item) else {
// there is a next item but can't be grouped, this is the end
return .end
}
// next will be grouped with this one, this is the start
return .middle
}
private func loadImageForTimelineItem(_ timelineItem: ImageRoomTimelineItem) async {
@@ -204,13 +246,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
return
}
switch await memberDetailProvider.loadAvatarURLStringForUserId(timelineItem.senderId) {
switch await roomProxy.loadAvatarURLForUserId(timelineItem.senderId) {
case .success(let avatarURLString):
guard let avatarURLString = avatarURLString else {
return
}
switch await mediaProvider.loadImageFromURLString(avatarURLString) {
switch await mediaProvider.loadImageFromURLString(avatarURLString, size: MediaProviderDefaultAvatarSize) {
case .success(let avatar):
guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
var item = timelineItems[index] as? EventBasedTimelineItemProtocol else {
@@ -234,7 +276,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
return
}
switch await memberDetailProvider.loadDisplayNameForUserId(timelineItem.senderId) {
switch await roomProxy.loadDisplayNameForUserId(timelineItem.senderId) {
case .success(let displayName):
guard let displayName = displayName,
let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
@@ -15,7 +15,20 @@
//
import Combine
import Foundation
import MatrixRustSDK
#warning("Rename to RoomTimelineListener???")
class WeakRoomTimelineProviderWrapper: TimelineListener {
private weak var timelineProvider: RoomTimelineProvider?
init(timelineProvider: RoomTimelineProvider) {
self.timelineProvider = timelineProvider
}
func onUpdate(update: TimelineDiff) {
timelineProvider?.onUpdate(update: update)
}
}
class RoomTimelineProvider: RoomTimelineProviderProtocol {
private let roomProxy: RoomProxyProtocol
@@ -23,31 +36,20 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
let callbacks = PassthroughSubject<RoomTimelineProviderCallback, Never>()
private(set) var items: [RoomTimelineProviderItem]
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
self.roomProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .updatedMessages:
self.callbacks.send(.updatedMessages)
}
}.store(in: &cancellables)
}
var messages: [RoomMessageProtocol] {
roomProxy.messages
items = []
}
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError> {
switch await roomProxy.paginateBackwards(count: count) {
case .success:
return .success(())
case .failure:
return .failure(.generic)
case .failure(let error):
if error == .noMoreMessagesToBackPaginate { return .failure(.noMoreMessagesToBackPaginate) }
return .failure(.failedPaginatingBackwards)
}
}
@@ -69,3 +71,73 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
}
}
}
// MARK: - TimelineListener
private extension RoomTimelineProvider {
func onUpdate(update: TimelineDiff) {
let change = update.change()
MXLog.verbose("Change: \(change)")
switch change {
case .replace:
replaceItems(update.replace())
case .insertAt:
insertItem(update.insertAt())
case .updateAt:
updateItem(update.updateAt())
case .removeAt:
removeItem(at: update.removeAt())
case .move:
moveItem(update.move())
case .push:
pushItem(update.push())
case .pop:
popItem()
case .clear:
clearAllItems()
}
callbacks.send(.updatedMessages)
}
private func replaceItems(_ items: [MatrixRustSDK.TimelineItem]?) {
guard let items = items else { return }
self.items = items.map(RoomTimelineProviderItem.init)
}
private func insertItem(_ data: InsertAtData?) {
guard let data = data else { return }
let item = RoomTimelineProviderItem(item: data.item)
items.insert(item, at: Int(data.index))
}
private func updateItem(_ data: UpdateAtData?) {
guard let data = data else { return }
let item = RoomTimelineProviderItem(item: data.item)
items[Int(data.index)] = item
}
private func removeItem(at index: UInt32?) {
guard let index = index else { return }
items.remove(at: Int(index))
}
private func moveItem(_ data: MoveData?) {
guard let data = data else { return }
items.move(fromOffsets: IndexSet(integer: Int(data.oldIndex)), toOffset: Int(data.newIndex))
}
private func pushItem(_ item: MatrixRustSDK.TimelineItem?) {
guard let item = item else { return }
items.append(RoomTimelineProviderItem(item: item))
}
private func popItem() {
items.removeLast()
}
private func clearAllItems() {
items.removeAll()
}
}
@@ -0,0 +1,89 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import MatrixRustSDK
/// A light wrapper around timeline items returned from Rust for use in `RoomTimelineProvider`.
enum RoomTimelineProviderItem {
case event(EventTimelineItem)
case virtual(MatrixRustSDK.VirtualTimelineItem)
case other(MatrixRustSDK.TimelineItem)
init(item: MatrixRustSDK.TimelineItem) {
if let eventItem = item.asEvent() {
self = .event(EventTimelineItem(item: eventItem))
} else if let virtualItem = item.asVirtual() {
self = .virtual(virtualItem)
} else {
self = .other(item)
}
}
func canBeGrouped(with prevItem: RoomTimelineProviderItem) -> Bool {
guard case let .event(selfEventItem) = self, case let .event(prevEventItem) = prevItem else {
return false
}
// can be improved by adding a date threshold
return prevEventItem.reactions.isEmpty && selfEventItem.sender == prevEventItem.sender
}
}
/// A light wrapper around event timeline items returned from Rust, used in `RoomTimelineProviderItem`.
struct EventTimelineItem {
let item: MatrixRustSDK.EventTimelineItem
init(item: MatrixRustSDK.EventTimelineItem) {
self.item = item
}
var id: String {
#warning("Handle txid in a better way")
switch item.key() {
case .transactionId(let txnID):
return txnID
case .eventId(let eventID):
return eventID
}
}
var body: String? {
content.asMessage()?.body()
}
var isMessage: Bool {
content.asMessage() != nil
}
var content: TimelineItemContent {
item.content()
}
var sender: String {
item.sender()
}
var reactions: [Reaction] {
item.reactions()
}
var originServerTs: Date {
if let timestamp = item.originServerTs() {
return Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
} else {
return .now
}
}
}
@@ -22,16 +22,17 @@ enum RoomTimelineProviderCallback {
}
enum RoomTimelineProviderError: Error {
case noMoreMessagesToBackPaginate
case failedPaginatingBackwards
case failedSendingMessage
case failedRedactingItem
case generic
}
@MainActor
protocol RoomTimelineProviderProtocol {
var callbacks: PassthroughSubject<RoomTimelineProviderCallback, Never> { get }
var messages: [RoomMessageProtocol] { get }
var items: [RoomTimelineProviderItem] { get }
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError>
@@ -0,0 +1,111 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import CoreGraphics
import Foundation
import MatrixRustSDK
/// A protocol that contains the base `m.room.message` event content properties.
protocol MessageContentProtocol: RoomMessageEventContentProtocol {
var body: String { get }
}
/// A timeline item that represents an `m.room.message` event.
struct MessageTimelineItem<Content: MessageContentProtocol> {
let item: MatrixRustSDK.EventTimelineItem
let content: Content
var id: String {
#warning("Handle txid properly")
switch item.key() {
case .transactionId(let txnID):
return txnID
case .eventId(let eventID):
return eventID
}
}
var body: String {
content.body
}
var isEdited: Bool {
item.content().asMessage()?.isEdited() == true
}
var inReplyTo: String? {
item.content().asMessage()?.inReplyTo()
}
var reactions: [Reaction] {
item.reactions()
}
var sender: String {
item.sender()
}
var originServerTs: Date {
if let timestamp = item.originServerTs() {
return Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
} else {
return .now
}
}
}
// MARK: - Formatted Text
/// A protocol that contains the expected event content properties for a formatted message.
protocol FormattedMessageContentProtocol: MessageContentProtocol {
var formatted: FormattedBody? { get }
}
extension MatrixRustSDK.TextMessageContent: FormattedMessageContentProtocol { }
extension MatrixRustSDK.EmoteMessageContent: FormattedMessageContentProtocol { }
extension MatrixRustSDK.NoticeMessageContent: FormattedMessageContentProtocol { }
/// A timeline item that represents an `m.room.message` event where
/// the `msgtype` would likely contain a formatted body.
extension MessageTimelineItem where Content: FormattedMessageContentProtocol {
var htmlBody: String? {
#warning("Should this check the formatted type?")
return content.formatted?.body
}
}
// MARK: - Media
extension MatrixRustSDK.ImageMessageContent: MessageContentProtocol { }
/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.image`.
extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent {
var source: MediaSource {
MediaSource(source: content.source)
}
var width: CGFloat? {
content.info?.width.map(CGFloat.init)
}
var height: CGFloat? {
content.info?.height.map(CGFloat.init)
}
var blurhash: String? {
content.info?.blurhash
}
}
@@ -17,10 +17,40 @@
import Foundation
import UIKit
enum TimelineItemInGroupState {
case single
case beginning
case middle
case end
var roundedCorners: UIRectCorner {
switch self {
case .single:
return .allCorners
case .beginning:
return [.topLeft, .topRight]
case .middle:
return []
case .end:
return [.bottomLeft, .bottomRight]
}
}
var shouldShowSenderDetails: Bool {
switch self {
case .single, .beginning:
return true
default:
return false
}
}
}
protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol {
var text: String { get }
var timestamp: String { get }
var shouldShowSenderDetails: Bool { get }
var inGroupState: TimelineItemInGroupState { get }
var isOutgoing: Bool { get }
var senderId: String { get }
@@ -23,6 +23,7 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
var attributedComponents: [AttributedStringBuilderComponent]?
let timestamp: String
let shouldShowSenderDetails: Bool
let inGroupState: TimelineItemInGroupState
let isOutgoing: Bool
let senderId: String
@@ -22,6 +22,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
let text: String
let timestamp: String
let shouldShowSenderDetails: Bool
let inGroupState: TimelineItemInGroupState
let isOutgoing: Bool
let senderId: String
@@ -23,6 +23,7 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equ
var attributedComponents: [AttributedStringBuilderComponent]?
let timestamp: String
let shouldShowSenderDetails: Bool
let inGroupState: TimelineItemInGroupState
let isOutgoing: Bool
let senderId: String
@@ -23,6 +23,7 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equat
var attributedComponents: [AttributedStringBuilderComponent]?
let timestamp: String
let shouldShowSenderDetails: Bool
let inGroupState: TimelineItemInGroupState
let isOutgoing: Bool
let senderId: String
@@ -14,46 +14,85 @@
// limitations under the License.
//
import Foundation
import MatrixRustSDK
import UIKit
struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private let mediaProvider: MediaProviderProtocol
private let memberDetailProvider: MemberDetailProviderProtocol
private let roomProxy: RoomProxyProtocol
private let attributedStringBuilder: AttributedStringBuilderProtocol
init(mediaProvider: MediaProviderProtocol,
memberDetailProvider: MemberDetailProviderProtocol,
/// The Matrix ID of the current user.
private let userID: String
init(userID: String,
mediaProvider: MediaProviderProtocol,
roomProxy: RoomProxyProtocol,
attributedStringBuilder: AttributedStringBuilderProtocol) {
self.userID = userID
self.mediaProvider = mediaProvider
self.memberDetailProvider = memberDetailProvider
self.roomProxy = roomProxy
self.attributedStringBuilder = attributedStringBuilder
}
func buildTimelineItemFor(message: RoomMessageProtocol, isOutgoing: Bool, showSenderDetails: Bool) async -> RoomTimelineItemProtocol {
let displayName = memberDetailProvider.displayNameForUserId(message.sender)
let avatarURL = memberDetailProvider.avatarURLStringForUserId(message.sender)
let avatarImage = mediaProvider.imageFromURLString(avatarURL)
func buildTimelineItemFor(eventItem: EventTimelineItem,
showSenderDetails: Bool,
inGroupState: TimelineItemInGroupState) async -> RoomTimelineItemProtocol {
guard let messageContent = eventItem.content.asMessage() else { fatalError("Must be a message for now.") }
let displayName = roomProxy.displayNameForUserId(eventItem.sender)
let avatarURL = roomProxy.avatarURLStringForUserId(eventItem.sender)
let avatarImage = mediaProvider.imageFromURLString(avatarURL, size: MediaProviderDefaultAvatarSize)
let isOutgoing = eventItem.sender == userID
switch message {
case let message as TextRoomMessage:
return await buildTextTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
case let message as ImageRoomMessage:
return await buildImageTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
case let message as NoticeRoomMessage:
return await buildNoticeTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
case let message as EmoteRoomMessage:
return await buildEmoteTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
default:
fatalError("Unknown room message.")
switch messageContent.msgtype() {
case .text(content: let content):
let message = MessageTimelineItem(item: eventItem.item, content: content)
return await buildTextTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
case .image(content: let content):
let message = MessageTimelineItem(item: eventItem.item, content: content)
return await buildImageTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
case .notice(content: let content):
let message = MessageTimelineItem(item: eventItem.item, content: content)
return await buildNoticeTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
case .emote(content: let content):
let message = MessageTimelineItem(item: eventItem.item, content: content)
return await buildEmoteTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
case .none:
return await buildFallbackTimelineItem(eventItem, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
}
}
// MARK: - Private
private func buildTextTimelineItemFromMessage(_ message: TextRoomMessage,
// swiftformat:disable function_parameter_count
// swiftlint:disable function_parameter_count
private func buildFallbackTimelineItem(_ item: EventTimelineItem,
_ isOutgoing: Bool,
_ showSenderDetails: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
let attributedText = await attributedStringBuilder.fromPlain(item.body)
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
return TextRoomTimelineItem(id: item.id,
text: item.body ?? "",
attributedComponents: attributedComponents,
timestamp: item.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
inGroupState: inGroupState,
isOutgoing: isOutgoing,
senderId: item.sender,
senderDisplayName: displayName,
senderAvatar: avatarImage,
properties: RoomTimelineItemProperties(isEdited: item.content.asMessage()?.isEdited() ?? false,
reactions: aggregateReactions(item.reactions)))
}
private func buildTextTimelineItemFromMessage(_ message: MessageTimelineItem<TextMessageContent>,
_ isOutgoing: Bool,
_ showSenderDetails: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
@@ -64,15 +103,19 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
attributedComponents: attributedComponents,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
inGroupState: inGroupState,
isOutgoing: isOutgoing,
senderId: message.sender,
senderDisplayName: displayName,
senderAvatar: avatarImage)
senderAvatar: avatarImage,
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
reactions: aggregateReactions(message.reactions)))
}
private func buildImageTimelineItemFromMessage(_ message: ImageRoomMessage,
private func buildImageTimelineItemFromMessage(_ message: MessageTimelineItem<ImageMessageContent>,
_ isOutgoing: Bool,
_ showSenderDetails: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat?
@@ -85,6 +128,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
text: message.body,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
inGroupState: inGroupState,
isOutgoing: isOutgoing,
senderId: message.sender,
senderDisplayName: displayName,
@@ -94,12 +138,15 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
width: message.width,
height: message.height,
aspectRatio: aspectRatio,
blurhash: message.blurhash)
blurhash: message.blurhash,
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
reactions: aggregateReactions(message.reactions)))
}
private func buildNoticeTimelineItemFromMessage(_ message: NoticeRoomMessage,
private func buildNoticeTimelineItemFromMessage(_ message: MessageTimelineItem<NoticeMessageContent>,
_ isOutgoing: Bool,
_ showSenderDetails: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
@@ -110,15 +157,19 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
attributedComponents: attributedComponents,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
inGroupState: inGroupState,
isOutgoing: isOutgoing,
senderId: message.sender,
senderDisplayName: displayName,
senderAvatar: avatarImage)
senderAvatar: avatarImage,
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
reactions: aggregateReactions(message.reactions)))
}
private func buildEmoteTimelineItemFromMessage(_ message: EmoteRoomMessage,
private func buildEmoteTimelineItemFromMessage(_ message: MessageTimelineItem<EmoteMessageContent>,
_ isOutgoing: Bool,
_ showSenderDetails: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
@@ -129,9 +180,22 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
attributedComponents: attributedComponents,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
inGroupState: inGroupState,
isOutgoing: isOutgoing,
senderId: message.sender,
senderDisplayName: displayName,
senderAvatar: avatarImage)
senderAvatar: avatarImage,
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
reactions: aggregateReactions(message.reactions)))
}
// swiftlint:enable function_parameter_count
// swiftformat:enable function_parameter_count
private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] {
reactions.map { reaction in
let isHighlighted = false // reaction.details.contains(where: { $0.sender == userID })
return AggregatedReaction(key: reaction.key, count: Int(reaction.count), isHighlighted: isHighlighted)
}
}
}
@@ -18,5 +18,7 @@ import Foundation
@MainActor
protocol RoomTimelineItemFactoryProtocol {
func buildTimelineItemFor(message: RoomMessageProtocol, isOutgoing: Bool, showSenderDetails: Bool) async -> RoomTimelineItemProtocol
func buildTimelineItemFor(eventItem: EventTimelineItem,
showSenderDetails: Bool,
inGroupState: TimelineItemInGroupState) async -> RoomTimelineItemProtocol
}