mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user