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
@@ -108,13 +108,10 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
Benchmark.startTrackingForIdentifier("Login", message: "Started new login")
let loginTask: Task<Client, Error> = Task.detached {
#warning("Use new api on next SDK release.")
return try self.authenticationService.login(username: username,
password: password)
// try self.authenticationService.login(username: username,
// password: password,
// initialDeviceName: initialDeviceName,
// deviceId: deviceId)
try self.authenticationService.login(username: username,
password: password,
initialDeviceName: initialDeviceName,
deviceId: deviceId)
}
switch await loginTask.result {
@@ -16,7 +16,6 @@
import Foundation
import GZIP
import MatrixRustSDK
import Sentry
import UIKit
@@ -19,64 +19,91 @@ import Foundation
import MatrixRustSDK
import UIKit
private class WeakClientProxyWrapper: ClientDelegate {
private class WeakClientProxyWrapper: ClientDelegate, SlidingSyncObserver {
private weak var clientProxy: ClientProxy?
init(clientProxy: ClientProxy) {
self.clientProxy = clientProxy
}
func didReceiveSyncUpdate() {
clientProxy?.didReceiveSyncUpdate()
}
// MARK: - ClientDelegate
func didReceiveSyncUpdate() { }
func didReceiveAuthError(isSoftLogout: Bool) {
clientProxy?.didReceiveAuthError(isSoftLogout: isSoftLogout)
Task {
await clientProxy?.didReceiveAuthError(isSoftLogout: isSoftLogout)
}
}
func didUpdateRestoreToken() {
clientProxy?.didUpdateRestoreToken()
Task {
await clientProxy?.didUpdateRestoreToken()
}
}
// MARK: - SlidingSyncDelegate
func didReceiveSyncUpdate(summary: UpdateSummary) {
Task {
await self.clientProxy?.didReceiveSlidingSyncUpdate(summary: summary)
}
}
}
class ClientProxy: ClientProxyProtocol {
/// The maximum number of timeline events required during a sync request.
static let syncLimit: UInt16 = 20
static let syncLimit: UInt16 = 50
private let client: Client
private let client: ClientProtocol
private let backgroundTaskService: BackgroundTaskServiceProtocol
private var sessionVerificationControllerProxy: SessionVerificationControllerProxy?
private(set) var rooms: [RoomProxy] = [] {
didSet {
callbacks.send(.updatedRoomsList)
}
}
private var slidingSyncObserverToken: StoppableSpawn?
private let slidingSync: SlidingSync
private var roomProxies = [String: RoomProxyProtocol]()
let roomSummaryProvider: RoomSummaryProviderProtocol
deinit {
// These need to be inlined instead of using stopSync()
// as we can't call async methods safely from deinit
client.setDelegate(delegate: nil)
slidingSyncObserverToken?.cancel()
slidingSync.setObserver(observer: nil)
}
let callbacks = PassthroughSubject<ClientProxyCallback, Never>()
init(client: Client,
init(client: ClientProtocol,
backgroundTaskService: BackgroundTaskServiceProtocol) {
self.client = client
self.backgroundTaskService = backgroundTaskService
do {
let slidingSyncBuilder = try client.slidingSync().homeserver(url: BuildSettings.slidingSyncProxyBaseURL.absoluteString)
let slidingSyncView = try SlidingSyncViewBuilder()
.timelineLimit(limit: 10)
.requiredState(requiredState: [RequiredState(key: "m.room.avatar", value: "")])
.name(name: "HomeScreenView")
.syncMode(mode: .fullSync)
.build()
slidingSync = try slidingSyncBuilder
.addView(view: slidingSyncView)
// .withCommonExtensions()
.build()
roomSummaryProvider = RoomSummaryProvider(slidingSyncController: slidingSync,
slidingSyncView: slidingSyncView,
roomMessageFactory: RoomMessageFactory())
} catch {
fatalError("Failed configuring sliding sync")
}
client.setDelegate(delegate: WeakClientProxyWrapper(clientProxy: self))
#warning("Use isSoftLogout() api on next SDK release.")
Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.")
client.startSync(timelineLimit: ClientProxy.syncLimit)
Task { await updateRooms() }
// if !client.isSoftLogout() {
// Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.")
// client.startSync(timelineLimit: ClientProxy.syncLimit)
//
// Task { await updateRooms() }
// }
}
var userIdentifier: String {
@@ -89,9 +116,7 @@ class ClientProxy: ClientProxyProtocol {
}
var isSoftLogout: Bool {
#warning("Use isSoftLogout() api on next SDK release.")
return false
// client.isSoftLogout()
client.isSoftLogout()
}
var deviceId: String? {
@@ -116,6 +141,46 @@ class ClientProxy: ClientProxyProtocol {
}
}
func startSync() {
guard !client.isSoftLogout() else {
return
}
slidingSync.setObserver(observer: WeakClientProxyWrapper(clientProxy: self))
slidingSyncObserverToken = slidingSync.sync()
}
func stopSync() {
client.setDelegate(delegate: nil)
slidingSyncObserverToken?.cancel()
slidingSync.setObserver(observer: nil)
}
func roomForIdentifier(_ identifier: String) -> RoomProxyProtocol? {
if let roomProxy = roomProxies[identifier] {
return roomProxy
}
do {
guard let slidingSyncRoom = try slidingSync.getRoom(roomId: identifier),
let room = slidingSyncRoom.fullRoom() else {
MXLog.error("Failed retrieving room with identifier: \(identifier)")
return nil
}
let roomProxy = RoomProxy(slidingSyncRoom: slidingSyncRoom,
room: room,
backgroundTaskService: backgroundTaskService)
roomProxies[identifier] = roomProxy
return roomProxy
} catch {
MXLog.error("Failed retrieving room with identifier: \(identifier)")
return nil
}
}
func loadUserDisplayName() async -> Result<String, ClientProxyError> {
await Task.detached {
do {
@@ -152,9 +217,18 @@ class ClientProxy: ClientProxyProtocol {
MatrixRustSDK.mediaSourceFromUrl(url: urlString)
}
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data {
let bytes = try client.getMediaContent(source: source)
return Data(bytes: bytes, count: bytes.count)
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data {
try await Task.detached {
let bytes = try self.client.getMediaContent(source: source)
return Data(bytes: bytes, count: bytes.count)
}.value
}
func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data {
try await Task.detached {
let bytes = try self.client.getMediaThumbnail(source: source, width: UInt64(width), height: UInt64(height))
return Data(bytes: bytes, count: bytes.count)
}.value
}
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError> {
@@ -171,8 +245,7 @@ class ClientProxy: ClientProxyProtocol {
func logout() async {
do {
#warning("Use logout() api on next SDK release.")
// try client.logout()
try client.logout()
} catch {
MXLog.error("Failed logging out with error: \(error)")
}
@@ -180,16 +253,6 @@ class ClientProxy: ClientProxyProtocol {
// MARK: Private
fileprivate func didReceiveSyncUpdate() {
Benchmark.logElapsedDurationForIdentifier("ClientSync", message: "Received sync update")
callbacks.send(.receivedSyncUpdate)
Task.detached {
await self.updateRooms()
}
}
fileprivate func didReceiveAuthError(isSoftLogout: Bool) {
callbacks.send(.receivedAuthError(isSoftLogout: isSoftLogout))
}
@@ -198,32 +261,9 @@ class ClientProxy: ClientProxyProtocol {
callbacks.send(.updatedRestoreToken)
}
private func updateRooms() async {
var currentRooms = rooms
Benchmark.startTrackingForIdentifier("ClientRooms", message: "Fetching available rooms")
let sdkRooms = client.rooms()
Benchmark.endTrackingForIdentifier("ClientRooms", message: "Retrieved \(sdkRooms.count) rooms")
fileprivate func didReceiveSlidingSyncUpdate(summary: UpdateSummary) {
roomSummaryProvider.updateRoomsWithIdentifiers(summary.rooms)
Benchmark.startTrackingForIdentifier("ProcessingRooms", message: "Started processing \(sdkRooms.count) rooms")
let diff = sdkRooms.map { $0.id() }.difference(from: currentRooms.map(\.id))
for change in diff {
switch change {
case .insert(_, let id, _):
guard let sdkRoom = sdkRooms.first(where: { $0.id() == id }) else {
MXLog.error("Failed retrieving sdk room with id: \(id)")
break
}
currentRooms.append(RoomProxy(room: sdkRoom,
roomMessageFactory: RoomMessageFactory(),
backgroundTaskService: backgroundTaskService))
case .remove(_, let id, _):
currentRooms.removeAll { $0.id == id }
}
}
Benchmark.endTrackingForIdentifier("ProcessingRooms", message: "Finished processing \(sdkRooms.count) rooms")
rooms = currentRooms
// callbacks.send(.receivedSyncUpdate)
}
}
@@ -19,7 +19,6 @@ import Foundation
import MatrixRustSDK
enum ClientProxyCallback {
case updatedRoomsList
case receivedSyncUpdate
case receivedAuthError(isSoftLogout: Bool)
case updatedRestoreToken
@@ -34,6 +33,7 @@ enum ClientProxyError: Error {
case failedLoadingMedia
}
@MainActor
protocol ClientProxyProtocol {
var callbacks: PassthroughSubject<ClientProxyCallback, Never> { get }
@@ -47,7 +47,13 @@ protocol ClientProxyProtocol {
var restoreToken: String? { get }
var rooms: [RoomProxy] { get }
var roomSummaryProvider: RoomSummaryProviderProtocol { get }
func startSync()
func stopSync()
func roomForIdentifier(_ identifier: String) -> RoomProxyProtocol?
func loadUserDisplayName() async -> Result<String, ClientProxyError>
@@ -59,7 +65,9 @@ protocol ClientProxyProtocol {
func mediaSourceForURLString(_ urlString: String) -> MatrixRustSDK.MediaSource
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data
func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError>
@@ -26,10 +26,18 @@ struct MockClientProxy: ClientProxyProtocol {
let homeserver = ""
let restoreToken: String? = nil
let rooms = [RoomProxy]()
let roomSummaryProvider: RoomSummaryProviderProtocol = MockRoomSummaryProvider()
func startSync() { }
func stopSync() { }
func roomForIdentifier(_ identifier: String) -> RoomProxyProtocol? {
nil
}
func loadUserDisplayName() async -> Result<String, ClientProxyError> {
.failure(.failedRetrievingDisplayName)
.success("User display name")
}
func loadUserAvatarURLString() async -> Result<String, ClientProxyError> {
@@ -48,7 +56,11 @@ struct MockClientProxy: ClientProxyProtocol {
MatrixRustSDK.mediaSourceFromUrl(url: urlString)
}
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data {
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data {
throw ClientProxyError.failedLoadingMedia
}
func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data {
throw ClientProxyError.failedLoadingMedia
}
@@ -21,7 +21,6 @@ struct MediaProvider: MediaProviderProtocol {
private let clientProxy: ClientProxyProtocol
private let imageCache: Kingfisher.ImageCache
private let backgroundTaskService: BackgroundTaskServiceProtocol
private let processingQueue: DispatchQueue
init(clientProxy: ClientProxyProtocol,
imageCache: Kingfisher.ImageCache,
@@ -29,31 +28,30 @@ struct MediaProvider: MediaProviderProtocol {
self.clientProxy = clientProxy
self.imageCache = imageCache
self.backgroundTaskService = backgroundTaskService
processingQueue = DispatchQueue(label: "MediaProviderProcessingQueue", attributes: .concurrent)
}
func imageFromSource(_ source: MediaSource?) -> UIImage? {
func imageFromSource(_ source: MediaSource?, size: CGSize?) -> UIImage? {
guard let source = source else {
return nil
}
return imageCache.retrieveImageInMemoryCache(forKey: source.underlyingSource.url(), options: nil)
let cacheKey = cacheKeyForURLString(source.underlyingSource.url(), size: size)
return imageCache.retrieveImageInMemoryCache(forKey: cacheKey, options: nil)
}
func imageFromURLString(_ urlString: String?) -> UIImage? {
func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage? {
guard let urlString = urlString else {
return nil
}
return imageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)))
return imageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), size: size)
}
func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError> {
await loadImageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)))
func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
await loadImageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), size: size)
}
func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError> {
if let image = imageFromSource(source) {
func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
if let image = imageFromSource(source, size: size) {
return .success(image)
}
@@ -62,21 +60,30 @@ struct MediaProvider: MediaProviderProtocol {
loadImageBgTask?.stop()
}
let cacheKey = cacheKeyForURLString(source.underlyingSource.url(), size: size)
return await Task.detached { () -> Result<UIImage, MediaProviderError> in
if case let .success(cacheResult) = await imageCache.retrieveImage(forKey: source.underlyingSource.url()),
if case let .success(cacheResult) = await imageCache.retrieveImage(forKey: cacheKey),
let image = cacheResult.image {
return .success(image)
}
do {
let imageData = try clientProxy.loadMediaContentForSource(source.underlyingSource)
let imageData = try await Task.detached { () -> Data in
if let size = size {
return try await clientProxy.loadMediaThumbnailForSource(source.underlyingSource, width: UInt(size.width), height: UInt(size.height))
} else {
return try await clientProxy.loadMediaContentForSource(source.underlyingSource)
}
}.value
guard let image = UIImage(data: imageData) else {
MXLog.error("Invalid image data")
return .failure(.invalidImageData)
}
imageCache.store(image, forKey: source.underlyingSource.url())
imageCache.store(image, forKey: cacheKey)
return .success(image)
} catch {
@@ -86,6 +93,16 @@ struct MediaProvider: MediaProviderProtocol {
}
.value
}
// MARK: - Private
private func cacheKeyForURLString(_ urlString: String, size: CGSize?) -> String {
if let size = size {
return "\(urlString){\(size.width),\(size.height)}"
} else {
return urlString
}
}
}
private extension ImageCache {
@@ -22,13 +22,33 @@ enum MediaProviderError: Error {
case invalidImageData
}
let MediaProviderDefaultAvatarSize = CGSize(width: 44.0, height: 44.0)
@MainActor
protocol MediaProviderProtocol {
func imageFromSource(_ source: MediaSource?) -> UIImage?
func imageFromSource(_ source: MediaSource?, size: CGSize?) -> UIImage?
func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError>
@discardableResult func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result<UIImage, MediaProviderError>
func imageFromURLString(_ urlString: String?) -> UIImage?
func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage?
func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError>
@discardableResult func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result<UIImage, MediaProviderError>
}
extension MediaProviderProtocol {
func imageFromSource(_ source: MediaSource?) -> UIImage? {
imageFromSource(source, size: nil)
}
@discardableResult func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError> {
await loadImageFromSource(source, size: nil)
}
func imageFromURLString(_ urlString: String?) -> UIImage? {
imageFromURLString(urlString, size: nil)
}
@discardableResult func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError> {
await loadImageFromURLString(urlString, size: nil)
}
}
@@ -18,19 +18,23 @@ import Foundation
import UIKit
struct MockMediaProvider: MediaProviderProtocol {
func imageFromSource(_ source: MediaSource?) -> UIImage? {
func imageFromSource(_ source: MediaSource?, size: CGSize?) -> UIImage? {
nil
}
func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError> {
func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
.failure(.failedRetrievingImage)
}
func imageFromURLString(_ urlString: String?) -> UIImage? {
nil
func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage? {
if urlString != nil {
return UIImage(systemName: "photo")
}
return nil
}
func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError> {
func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
.failure(.failedRetrievingImage)
}
}
@@ -1,32 +0,0 @@
//
// 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 Foundation
@MainActor
class MemberDetailProviderManager {
private var memberDetailProviders: [String: MemberDetailProviderProtocol] = [:]
func memberDetailProviderForRoomProxy(_ roomProxy: RoomProxyProtocol) -> MemberDetailProviderProtocol {
if let memberDetailProvider = memberDetailProviders[roomProxy.id] {
return memberDetailProvider
}
let memberDetailProvider = MemberDetailProvider(roomProxy: roomProxy)
memberDetailProviders[roomProxy.id] = memberDetailProvider
return memberDetailProvider
}
}
@@ -1,63 +0,0 @@
//
// 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 Foundation
class MemberDetailProvider: MemberDetailProviderProtocol {
private let roomProxy: RoomProxyProtocol
private var memberAvatars = [String: String]()
private var memberDisplayNames = [String: String]()
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
}
func avatarURLStringForUserId(_ userId: String) -> String? {
memberAvatars[userId]
}
func loadAvatarURLStringForUserId(_ userId: String) async -> Result<String?, MemberDetailProviderError> {
if let avatarURL = avatarURLStringForUserId(userId) {
return .success(avatarURL)
}
switch await roomProxy.loadAvatarURLForUserId(userId) {
case .success(let avatarURL):
memberAvatars[userId] = avatarURL
return .success(avatarURL)
case .failure:
return .failure(.failedRetrievingUserAvatarURL)
}
}
func displayNameForUserId(_ userId: String) -> String? {
memberDisplayNames[userId]
}
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, MemberDetailProviderError> {
if let displayName = displayNameForUserId(userId) {
return .success(displayName)
}
switch await roomProxy.loadDisplayNameForUserId(userId) {
case .success(let displayName):
memberDisplayNames[userId] = displayName
return .success(displayName)
case .failure:
return .failure(.failedRetrievingUserDisplayName)
}
}
}
@@ -1,32 +0,0 @@
//
// 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 Foundation
enum MemberDetailProviderError: Error {
case invalidRoomProxy
case failedRetrievingUserAvatarURL
case failedRetrievingUserDisplayName
}
@MainActor
protocol MemberDetailProviderProtocol {
func avatarURLStringForUserId(_ userId: String) -> String?
func loadAvatarURLStringForUserId(_ userId: String) async -> Result<String?, MemberDetailProviderError>
func displayNameForUserId(_ userId: String) -> String?
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, MemberDetailProviderError>
}
@@ -1,47 +0,0 @@
//
// 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 Foundation
import MatrixRustSDK
import UIKit
struct EmoteRoomMessage: RoomMessageProtocol {
private let message: MatrixRustSDK.EmoteMessage
init(message: MatrixRustSDK.EmoteMessage) {
self.message = message
}
var id: String {
message.baseMessage().id()
}
var body: String {
message.baseMessage().body()
}
var htmlBody: String? {
message.htmlBody()
}
var sender: String {
message.baseMessage().sender()
}
var originServerTs: Date {
Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs()))
}
}
@@ -1,66 +0,0 @@
//
// 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
import UIKit
struct ImageRoomMessage: RoomMessageProtocol {
private let message: MatrixRustSDK.ImageMessage
init(message: MatrixRustSDK.ImageMessage) {
self.message = message
}
var id: String {
message.baseMessage().id()
}
var body: String {
message.baseMessage().body()
}
var sender: String {
message.baseMessage().sender()
}
var originServerTs: Date {
Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs()))
}
var source: MediaSource? {
MediaSource(source: message.source())
}
var width: CGFloat? {
guard let width = message.width() else {
return nil
}
return CGFloat(width)
}
var height: CGFloat? {
guard let height = message.height() else {
return nil
}
return CGFloat(height)
}
var blurhash: String? {
message.blurhash()
}
}
@@ -1,47 +0,0 @@
//
// 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 Foundation
import MatrixRustSDK
import UIKit
struct TextRoomMessage: RoomMessageProtocol {
private let message: MatrixRustSDK.TextMessage
init(message: MatrixRustSDK.TextMessage) {
self.message = message
}
var id: String {
message.baseMessage().id()
}
var body: String {
message.baseMessage().body()
}
var htmlBody: String? {
message.htmlBody()
}
var sender: String {
message.baseMessage().sender()
}
var originServerTs: Date {
Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs()))
}
}
@@ -15,8 +15,7 @@
//
import Combine
import Foundation
import UIKit
import MatrixRustSDK
struct MockRoomProxy: RoomProxyProtocol {
let id = UUID().uuidString
@@ -24,7 +23,6 @@ struct MockRoomProxy: RoomProxyProtocol {
let displayName: String?
let topic: String? = nil
let messages: [RoomMessageProtocol] = []
let avatarURL: String? = nil
@@ -34,24 +32,34 @@ struct MockRoomProxy: RoomProxyProtocol {
let isEncrypted = Bool.random()
let isTombstoned = Bool.random()
var callbacks = PassthroughSubject<RoomProxyCallback, Never>()
let timelineProvider: RoomTimelineProviderProtocol = MockRoomTimelineProvider()
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
.failure(.failedRetrievingMemberDisplayName)
}
func avatarURLStringForUserId(_ userId: String) -> String? {
nil
}
func loadAvatarURLForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
.failure(.failedRetrievingMemberAvatarURL)
}
func displayNameForUserId(_ userId: String) -> String? {
nil
}
func loadDisplayName() async -> Result<String, RoomProxyError> {
.failure(.failedRetrievingDisplayName)
}
func startLiveEventListener() { }
func addTimelineListener(listener: TimelineListener) { }
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError> {
.failure(.backwardStreamNotAvailable)
.failure(.failedPaginatingBackwards)
}
func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result<Void, RoomProxyError> {
@@ -17,18 +17,15 @@
import Foundation
import MatrixRustSDK
struct SomeRoomMessage: RoomMessageProtocol {
let id: String
let body: String
let sender: String
let originServerTs: Date
}
struct RoomMessageFactory: RoomMessageFactoryProtocol {
func buildRoomMessageFrom(_ message: AnyMessage) -> RoomMessageProtocol {
if let textMessage = message.textMessage() {
return TextRoomMessage(message: textMessage)
} else if let imageMessage = message.imageMessage() {
return ImageRoomMessage(message: imageMessage)
} else if let noticeMessage = message.noticeMessage() {
return NoticeRoomMessage(message: noticeMessage)
} else if let emoteMessage = message.emoteMessage() {
return EmoteRoomMessage(message: emoteMessage)
} else {
fatalError("One of these must exist")
}
func buildRoomMessageFrom(_ eventItem: EventTimelineItem) -> RoomMessageProtocol {
SomeRoomMessage(id: eventItem.id, body: eventItem.body ?? "", sender: eventItem.sender, originServerTs: eventItem.originServerTs)
}
}
@@ -18,5 +18,5 @@ import Foundation
import MatrixRustSDK
protocol RoomMessageFactoryProtocol {
func buildRoomMessageFrom(_ message: AnyMessage) -> RoomMessageProtocol
func buildRoomMessageFrom(_ message: EventTimelineItem) -> RoomMessageProtocol
}
+87 -82
View File
@@ -20,55 +20,43 @@ import UIKit
import MatrixRustSDK
private class WeakRoomProxyWrapper: RoomDelegate {
private weak var roomProxy: RoomProxy?
init(roomProxy: RoomProxy) {
self.roomProxy = roomProxy
}
// MARK: - RoomDelegate
func didReceiveMessage(message: AnyMessage) {
roomProxy?.appendMessage(message)
}
}
class RoomProxy: RoomProxyProtocol {
private let room: Room
private let roomMessageFactory: RoomMessageFactoryProtocol
private let slidingSyncRoom: SlidingSyncRoomProtocol
private let room: RoomProtocol
private let backgroundTaskService: BackgroundTaskServiceProtocol
private var backwardStream: BackwardsStreamProtocol?
private let concurrentDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy", attributes: .concurrent)
private var sendMessageBgTask: BackgroundTaskProtocol?
private var memberAvatars = [String: String]()
private var memberDisplayNames = [String: String]()
private(set) var displayName: String?
let callbacks = PassthroughSubject<RoomProxyCallback, Never>()
private(set) var messages: [RoomMessageProtocol]
init(room: Room,
roomMessageFactory: RoomMessageFactoryProtocol,
backgroundTaskService: BackgroundTaskServiceProtocol) {
self.room = room
self.roomMessageFactory = roomMessageFactory
self.backgroundTaskService = backgroundTaskService
messages = []
room.setDelegate(delegate: WeakRoomProxyWrapper(roomProxy: self))
backwardStream = room.startLiveEventListener()
}
private var backPaginationOutcome: PaginationOutcome?
private(set) lazy var timelineProvider: RoomTimelineProviderProtocol = {
let provider = RoomTimelineProvider(roomProxy: self)
addTimelineListener(listener: WeakRoomTimelineProviderWrapper(timelineProvider: provider))
return provider
}()
deinit {
room.setDelegate(delegate: nil)
#warning("Should any timeline listeners be removed??")
}
init(slidingSyncRoom: SlidingSyncRoomProtocol,
room: RoomProtocol,
backgroundTaskService: BackgroundTaskServiceProtocol) {
self.slidingSyncRoom = slidingSyncRoom
self.room = room
self.backgroundTaskService = backgroundTaskService
}
lazy var id: String = room.id()
var name: String? {
room.name()
slidingSyncRoom.name()
}
var topic: String? {
@@ -112,10 +100,15 @@ class RoomProxy: RoomProxyProtocol {
return Asset.Images.encryptionTrusted.image
}
func avatarURLStringForUserId(_ userId: String) -> String? {
memberAvatars[userId]
}
func loadAvatarURLForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
await Task.detached { () -> Result<String?, RoomProxyError> in
do {
let avatarURL = try self.room.memberAvatarUrl(userId: userId)
await self.update(avatarURL: avatarURL, forUserId: userId)
return .success(avatarURL)
} catch {
return .failure(.failedRetrievingMemberAvatarURL)
@@ -124,10 +117,15 @@ class RoomProxy: RoomProxyProtocol {
.value
}
func displayNameForUserId(_ userId: String) -> String? {
memberDisplayNames[userId]
}
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
await Task.detached { () -> Result<String?, RoomProxyError> in
do {
let displayName = try self.room.memberDisplayName(userId: userId)
await self.update(displayName: displayName, forUserId: userId)
return .success(displayName)
} catch {
return .failure(.failedRetrievingMemberDisplayName)
@@ -138,14 +136,13 @@ class RoomProxy: RoomProxyProtocol {
func loadDisplayName() async -> Result<String, RoomProxyError> {
await Task.detached { () -> Result<String, RoomProxyError> in
if let displayName = self.displayName {
if let displayName = await self.displayName {
return .success(displayName)
}
do {
let displayName = try self.room.displayName()
self.displayName = displayName
await self.update(displayName: displayName)
return .success(displayName)
} catch {
return .failure(.failedRetrievingDisplayName)
@@ -154,23 +151,27 @@ class RoomProxy: RoomProxyProtocol {
.value
}
private func addTimelineListener(listener: TimelineListener) {
room.addTimelineListener(listener: listener)
}
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError> {
await Task.detached { () -> Result<Void, RoomProxyError> in
guard let backwardStream = self.backwardStream else {
return .failure(RoomProxyError.backwardStreamNotAvailable)
guard backPaginationOutcome?.moreMessages != false else {
return .failure(.noMoreMessagesToBackPaginate)
}
MXLog.debug("BackPagination")
return await Task.detached {
do {
let id = await self.id
Benchmark.startTrackingForIdentifier("BackPagination \(id)", message: "Backpaginating \(count) message(s) in room \(id)")
await self.update(backPaginationOutcome: try self.room.paginateBackwards(limit: UInt16(count)))
Benchmark.endTrackingForIdentifier("BackPagination \(id)", message: "Finished backpaginating \(count) message(s) in room \(id)")
return .success(())
} catch {
return .failure(.failedPaginatingBackwards)
}
Benchmark.startTrackingForIdentifier("BackPagination \(self.id)", message: "Backpaginating \(count) message(s) in room \(self.id)")
let sdkMessages = backwardStream.paginateBackwards(count: UInt64(count))
Benchmark.endTrackingForIdentifier("BackPagination \(self.id)", message: "Finished backpaginating \(count) message(s) in room \(self.id)")
let messages = sdkMessages.map { message in
self.roomMessageFactory.buildRoomMessageFrom(message)
}.reversed()
self.messages.insert(contentsOf: messages, at: 0)
return .success(())
}
.value
}
@@ -180,48 +181,52 @@ class RoomProxy: RoomProxyProtocol {
defer {
sendMessageBgTask?.stop()
}
let transactionId = genTransactionId()
return await Task(priority: .high) { () -> Result<Void, RoomProxyError> in
return await Task.dispatched(on: concurrentDispatchQueue, operation: {
do {
// Disabled until available in Rust
// if let inReplyToEventId = inReplyToEventId {
// #warning("Markdown support when available in Ruma")
// try self.room.sendReply(msg: message, inReplyToEventId: inReplyToEventId, txnId: transactionId)
// } else {
let messageContent = messageEventContentFromMarkdown(md: message)
try self.room.send(msg: messageContent, txnId: transactionId)
// }
if let inReplyToEventId = inReplyToEventId {
try self.room.sendReply(msg: message, inReplyToEventId: inReplyToEventId, txnId: transactionId)
} else {
let messageContent = messageEventContentFromMarkdown(md: message)
try self.room.send(msg: messageContent, txnId: transactionId)
}
return .success(())
} catch {
return .failure(.failedSendingMessage)
}
})
.value
}
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> {
let transactionID = genTransactionId()
return await Task {
do {
try room.redact(eventId: eventID, reason: nil, txnId: transactionID)
return .success(())
} catch {
return .failure(.failedRedactingEvent)
}
}
.value
}
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> {
#warning("Redactions to be enabled on next SDK release.")
return .failure(.failedRedactingEvent)
// let transactionID = genTransactionId()
//
// return await Task {
// do {
// try room.redact(eventId: eventID, reason: nil, txnId: transactionID)
// return .success(())
// } catch {
// return .failure(.failedRedactingEvent)
// }
// }
// .value
func update(avatarURL: String?, forUserId userId: String) {
memberAvatars[userId] = avatarURL
}
// MARK: - Private
func update(displayName: String?, forUserId userId: String) {
memberDisplayNames[userId] = displayName
}
fileprivate func appendMessage(_ message: AnyMessage) {
let message = roomMessageFactory.buildRoomMessageFrom(message)
messages.append(message)
callbacks.send(.updatedMessages)
func update(displayName: String) {
self.displayName = displayName
}
func update(backPaginationOutcome: PaginationOutcome) {
self.backPaginationOutcome = backPaginationOutcome
}
}
@@ -15,22 +15,20 @@
//
import Combine
import UIKit
import MatrixRustSDK
enum RoomProxyError: Error {
case failedRetrievingDisplayName
case failedRetrievingAvatar
case backwardStreamNotAvailable
case noMoreMessagesToBackPaginate
case failedPaginatingBackwards
case failedRetrievingMemberAvatarURL
case failedRetrievingMemberDisplayName
case failedSendingMessage
case failedRedactingEvent
}
enum RoomProxyCallback {
case updatedMessages
}
@MainActor
protocol RoomProxyProtocol {
var id: String { get }
var isDirect: Bool { get }
@@ -43,12 +41,17 @@ protocol RoomProxyProtocol {
var displayName: String? { get }
var topic: String? { get }
var messages: [RoomMessageProtocol] { get }
var avatarURL: String? { get }
var timelineProvider: RoomTimelineProviderProtocol { get }
func avatarURLStringForUserId(_ userId: String) -> String?
func loadAvatarURLForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
func displayNameForUserId(_ userId: String) -> String?
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
func loadDisplayName() async -> Result<String, RoomProxyError>
@@ -58,8 +61,6 @@ protocol RoomProxyProtocol {
func sendMessage(_ message: String, inReplyToEventId: String?) async -> Result<Void, RoomProxyError>
func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
}
extension RoomProxyProtocol {
@@ -1,26 +0,0 @@
//
// 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 Foundation
struct EventBrief {
let eventId: String
let senderId: String
let senderDisplayName: String?
let body: String
let htmlBody: String?
let date: Date
}
@@ -1,67 +0,0 @@
//
// 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 Foundation
struct EventBriefFactory: EventBriefFactoryProtocol {
private let memberDetailProvider: MemberDetailProviderProtocol
init(memberDetailProvider: MemberDetailProviderProtocol) {
self.memberDetailProvider = memberDetailProvider
}
func buildEventBriefFor(message: RoomMessageProtocol?) async -> EventBrief? {
guard let message = message else {
return nil
}
switch message {
case is ImageRoomMessage:
return nil
case let message as TextRoomMessage:
return await buildEventBrief(message: message, htmlBody: message.htmlBody)
case let message as NoticeRoomMessage:
return await buildEventBrief(message: message, htmlBody: message.htmlBody)
case let message as EmoteRoomMessage:
return await buildEventBrief(message: message, htmlBody: message.htmlBody)
default:
fatalError("Unknown room message.")
}
}
// MARK: - Private
private func buildEventBrief(message: RoomMessageProtocol, htmlBody: String?) async -> EventBrief? {
switch await memberDetailProvider.loadDisplayNameForUserId(message.sender) {
case .success(let displayName):
return EventBrief(eventId: message.id,
senderId: message.sender,
senderDisplayName: displayName,
body: message.body,
htmlBody: htmlBody,
date: message.originServerTs)
case .failure(let error):
MXLog.error("Failed fetching sender display name with error: \(error)")
return EventBrief(eventId: message.id,
senderId: message.sender,
senderDisplayName: nil,
body: message.body,
htmlBody: htmlBody,
date: message.originServerTs)
}
}
}
@@ -1,22 +0,0 @@
//
// 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 Foundation
@MainActor
protocol EventBriefFactoryProtocol {
func buildEventBriefFor(message: RoomMessageProtocol?) async -> EventBrief?
}
@@ -1,44 +0,0 @@
//
// 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
import UIKit
struct MockRoomSummary: RoomSummaryProtocol {
var id: String = UUID().uuidString
var name: String?
var displayName: String?
var topic: String?
var isDirect = false
var isEncrypted = false
var isSpace = false
var isTombstoned = false
var lastMessage: EventBrief?
var avatar: UIImage?
func loadDetails() async { }
var callbacks = PassthroughSubject<RoomSummaryCallback, Never>()
}
@@ -0,0 +1,49 @@
//
// 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
import Foundation
class MockRoomSummaryProvider: RoomSummaryProviderProtocol {
var callbacks = PassthroughSubject<RoomSummaryProviderCallback, Never>()
var stateUpdatePublisher = CurrentValueSubject<RoomSummaryProviderState, Never>(.cold)
var countUpdatePublisher = CurrentValueSubject<UInt, Never>(0)
var roomSummaries: [RoomSummary] = [
RoomSummary(id: "1", name: "First room",
isDirect: true,
avatarURLString: nil,
lastMessage: AttributedString("Prosciutto beef ribs pancetta filet mignon kevin hamburger, chuck ham venison picanha. Beef ribs chislic turkey biltong tenderloin."),
lastMessageTimestamp: .now,
unreadNotificationCount: 4),
RoomSummary(id: "2",
name: "Second room",
isDirect: true,
avatarURLString: "mockImageURLString",
lastMessage: nil,
lastMessageTimestamp: nil,
unreadNotificationCount: 1),
RoomSummary(id: "3",
name: "Third room",
isDirect: true,
avatarURLString: nil,
lastMessage: try? AttributedString(markdown: "**@mock:client.com**: T-bone beef ribs bacon"),
lastMessageTimestamp: .now,
unreadNotificationCount: 0)
]
func updateRoomsWithIdentifiers(_ identifiers: [String]) { }
}
@@ -14,147 +14,14 @@
// limitations under the License.
//
import Combine
import Foundation
import UIKit
class RoomSummary: RoomSummaryProtocol {
private let roomProxy: RoomProxyProtocol
private let mediaProvider: MediaProviderProtocol
private let eventBriefFactory: EventBriefFactoryProtocol
private var hasLoadedData = false
private var roomUpdateListeners = Set<AnyCancellable>()
var id: String {
roomProxy.id
}
var name: String? {
roomProxy.name
}
var topic: String? {
roomProxy.topic
}
var isDirect: Bool {
roomProxy.isDirect
}
var isEncrypted: Bool {
roomProxy.isEncrypted
}
var isSpace: Bool {
roomProxy.isSpace
}
var isTombstoned: Bool {
roomProxy.isTombstoned
}
private(set) var avatar: UIImage? {
didSet {
callbacks.send(.updatedAvatar)
}
}
private(set) var displayName: String? {
didSet {
callbacks.send(.updatedDisplayName)
}
}
private(set) var lastMessage: EventBrief? {
didSet {
callbacks.send(.updatedLastMessage)
}
}
let callbacks = PassthroughSubject<RoomSummaryCallback, Never>()
init(roomProxy: RoomProxyProtocol, mediaProvider: MediaProviderProtocol, eventBriefFactory: EventBriefFactoryProtocol) {
self.roomProxy = roomProxy
self.mediaProvider = mediaProvider
self.eventBriefFactory = eventBriefFactory
Task {
lastMessage = await eventBriefFactory.buildEventBriefFor(message: roomProxy.messages.last)
}
roomProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self = self else {
return
}
switch callback {
case .updatedMessages:
Task {
self.lastMessage = await eventBriefFactory.buildEventBriefFor(message: roomProxy.messages.last)
}
}
}
.store(in: &roomUpdateListeners)
}
func loadDetails() async {
if hasLoadedData {
return
}
await withTaskGroup(of: Void.self) { group in
group.addTask {
await self.loadDisplayName()
}
group.addTask {
await self.loadAvatar()
}
group.addTask {
await self.loadLastMessage()
}
}
hasLoadedData = true
}
// MARK: - Private
private func loadDisplayName() async {
switch await roomProxy.loadDisplayName() {
case .success(let displayName):
self.displayName = displayName
case .failure(let error):
MXLog.error("Failed fetching room display name with error: \(error)")
}
}
private func loadAvatar() async {
guard let avatarURLString = roomProxy.avatarURL else {
return
}
switch await mediaProvider.loadImageFromURLString(avatarURLString) {
case .success(let avatar):
self.avatar = avatar
case .failure(let error):
MXLog.error("Failed fetching room avatar with error: \(error)")
}
}
private func loadLastMessage() async {
guard roomProxy.messages.last == nil else {
return
}
// Pre-fill the room with some messages and use the last message in the response.
switch await roomProxy.paginateBackwards(count: UInt(ClientProxy.syncLimit)) {
case .success:
lastMessage = await eventBriefFactory.buildEventBriefFor(message: roomProxy.messages.last)
case .failure(let error):
MXLog.error("Failed back paginating with error: \(error)")
}
}
struct RoomSummary {
let id: String
let name: String
let isDirect: Bool
let avatarURLString: String?
let lastMessage: AttributedString?
let lastMessageTimestamp: Date?
let unreadNotificationCount: UInt
}
@@ -1,43 +0,0 @@
//
// 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
import UIKit
enum RoomSummaryCallback {
case updatedAvatar
case updatedDisplayName
case updatedLastMessage
}
@MainActor
protocol RoomSummaryProtocol {
var id: String { get }
var name: String? { get }
var topic: String? { get }
var isDirect: Bool { get }
var isEncrypted: Bool { get }
var isSpace: Bool { get }
var isTombstoned: Bool { get }
var displayName: String? { get }
var lastMessage: EventBrief? { get }
var avatar: UIImage? { get }
var callbacks: PassthroughSubject<RoomSummaryCallback, Never> { get }
func loadDetails() async
}
@@ -0,0 +1,264 @@
//
// 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
import Foundation
import MatrixRustSDK
private class WeakRoomSummaryProviderWrapper: SlidingSyncViewRoomListObserver, SlidingSyncViewStateObserver, SlidingSyncViewRoomsCountObserver {
private weak var roomSummaryProvider: RoomSummaryProvider?
/// Publishes room list diffs as they come in through sliding sync
let roomListDiffPublisher = PassthroughSubject<SlidingSyncViewRoomsListDiff, Never>()
/// Publishes the current state of sliding sync, such as whether its catching up or live.
let stateUpdatePublisher = CurrentValueSubject<SlidingSyncState, Never>(.cold)
/// Publishes the number of available rooms
let countUpdatePublisher = CurrentValueSubject<UInt, Never>(0)
init(roomSummaryProvider: RoomSummaryProvider) {
self.roomSummaryProvider = roomSummaryProvider
}
// MARK: - SlidingSyncViewRoomListObserver
func didReceiveUpdate(diff: SlidingSyncViewRoomsListDiff) {
roomListDiffPublisher.send(diff)
}
// MARK: - SlidingSyncViewStateObserver
func didReceiveUpdate(newState: SlidingSyncState) {
stateUpdatePublisher.send(newState)
}
// MARK: - SlidingSyncViewRoomsCountObserver
func didReceiveUpdate(count: UInt32) {
countUpdatePublisher.send(UInt(count))
}
}
class RoomSummaryProvider: RoomSummaryProviderProtocol {
private let slidingSyncController: SlidingSyncProtocol
private let slidingSyncView: SlidingSyncViewProtocol
private let roomMessageFactory: RoomMessageFactoryProtocol
private var listUpdateObserverToken: StoppableSpawn?
private var stateUpdateObserverToken: StoppableSpawn?
private var cancellables = Set<AnyCancellable>()
let callbacks = PassthroughSubject<RoomSummaryProviderCallback, Never>()
let stateUpdatePublisher = CurrentValueSubject<RoomSummaryProviderState, Never>(.cold)
let countUpdatePublisher = CurrentValueSubject<UInt, Never>(0)
private(set) var roomSummaries: [RoomSummary] = [] {
didSet {
callbacks.send(.updatedRoomSummaries)
}
}
deinit {
listUpdateObserverToken?.cancel()
stateUpdateObserverToken?.cancel()
}
init(slidingSyncController: SlidingSyncProtocol, slidingSyncView: SlidingSyncViewProtocol, roomMessageFactory: RoomMessageFactoryProtocol) {
self.slidingSyncView = slidingSyncView
self.slidingSyncController = slidingSyncController
self.roomMessageFactory = roomMessageFactory
let weakProvider = WeakRoomSummaryProviderWrapper(roomSummaryProvider: self)
weakProvider.stateUpdatePublisher
.map(RoomSummaryProviderState.init)
.subscribe(stateUpdatePublisher)
.store(in: &cancellables)
weakProvider.countUpdatePublisher
.subscribe(countUpdatePublisher)
.store(in: &cancellables)
weakProvider.roomListDiffPublisher
.collect(.byTime(DispatchQueue.global(qos: .background), 0.5))
.sink { self.updateRoomsWithDiffs($0) }
.store(in: &cancellables)
listUpdateObserverToken = slidingSyncView.observeRoomList(observer: weakProvider)
stateUpdateObserverToken = slidingSyncView.observeState(observer: weakProvider)
}
func updateRoomsWithIdentifiers(_ identifiers: [String]) {
Task.detached {
guard self.stateUpdatePublisher.value == .live else {
return
}
var changes = [CollectionDifference<RoomSummary>.Change]()
for identifier in identifiers {
guard let oldSummary = self.roomSummaries.first(where: { $0.id == identifier }),
let index = self.roomSummaries.firstIndex(where: { $0.id == identifier }) else {
continue
}
let newSummary = self.buildRoomSummaryForIdentifier(identifier)
changes.append(.remove(offset: index, element: oldSummary, associatedWith: nil))
changes.append(.insert(offset: index, element: newSummary, associatedWith: nil))
}
guard let diff = CollectionDifference(changes) else {
MXLog.error("Failed creating diff from changes: \(changes)")
return
}
guard let newSummaries = self.roomSummaries.applying(diff) else {
MXLog.error("Failed applying diff: \(diff)")
return
}
self.roomSummaries = newSummaries
}
}
// MARK: - Private
fileprivate func updateRoomsWithDiffs(_ diffs: [SlidingSyncViewRoomsListDiff]) {
roomSummaries = diffs
.compactMap(buildDiff)
.reduce(roomSummaries) { $0.applying($1) ?? $0 }
}
private func buildEmptyRoomSummary(forIdentifier identifier: String = UUID().uuidString) -> RoomSummary {
RoomSummary(id: identifier,
name: "",
isDirect: false,
avatarURLString: nil,
lastMessage: nil,
lastMessageTimestamp: nil,
unreadNotificationCount: 0)
}
private func buildRoomSummaryForIdentifier(_ identifier: String) -> RoomSummary {
guard let room = try? slidingSyncController.getRoom(roomId: identifier) else {
MXLog.error("Failed finding room with id: \(identifier)")
return buildEmptyRoomSummary(forIdentifier: identifier)
}
var attributedLastMessage: AttributedString?
var lastMessageTimestamp: Date?
if let latestRoomMessage = room.latestRoomMessage() {
let lastMessage = roomMessageFactory.buildRoomMessageFrom(EventTimelineItem(item: latestRoomMessage))
if let lastMessageSender = try? AttributedString(markdown: "**\(lastMessage.sender)**") {
// Don't include the message body in the markdown otherwise it makes tappable links.
attributedLastMessage = lastMessageSender + ": " + AttributedString(lastMessage.body)
}
lastMessageTimestamp = lastMessage.originServerTs
}
return RoomSummary(id: room.roomId(),
name: room.name() ?? room.roomId(),
isDirect: room.isDm() ?? false,
avatarURLString: room.fullRoom()?.avatarUrl(),
lastMessage: attributedLastMessage,
lastMessageTimestamp: lastMessageTimestamp,
unreadNotificationCount: UInt(room.unreadNotifications().notificationCount()))
}
private func buildSummaryForRoomListEntry(_ entry: RoomListEntry) -> RoomSummary {
switch entry {
case .empty:
return buildEmptyRoomSummary()
case .filled(let roomId):
return buildRoomSummaryForIdentifier(roomId)
case .invalidated(let roomId):
return buildRoomSummaryForIdentifier(roomId)
}
}
private func buildDiff(from diff: SlidingSyncViewRoomsListDiff) -> CollectionDifference<RoomSummary>? {
// Invalidations are a no-op for the moment
if diff.isInvalidation {
return nil
}
var changes = [CollectionDifference<RoomSummary>.Change]()
switch diff {
case .push(value: let value):
let summary = buildSummaryForRoomListEntry(value)
changes.append(.insert(offset: Int(roomSummaries.count), element: summary, associatedWith: nil))
case .updateAt(let index, let value):
let summary = buildSummaryForRoomListEntry(value)
changes.append(.remove(offset: Int(index), element: summary, associatedWith: nil))
changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil))
case .insertAt(let index, let value):
let summary = buildSummaryForRoomListEntry(value)
changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil))
case .move(let oldIndex, let newIndex):
let summary = roomSummaries[Int(oldIndex)]
changes.append(.remove(offset: Int(oldIndex), element: summary, associatedWith: nil))
changes.append(.insert(offset: Int(newIndex), element: summary, associatedWith: nil))
case .removeAt(let index):
let summary = roomSummaries[Int(index)]
changes.append(.remove(offset: Int(index), element: summary, associatedWith: nil))
case .replace(let values):
for (index, summary) in roomSummaries.enumerated() {
changes.append(.remove(offset: index, element: summary, associatedWith: nil))
}
values
.reversed()
.map { buildSummaryForRoomListEntry($0) }
.forEach { summary in
changes.append(.insert(offset: 0, element: summary, associatedWith: nil))
}
}
return CollectionDifference(changes)
}
}
extension SlidingSyncViewRoomsListDiff {
var isInvalidation: Bool {
switch self {
case .push(let value), .updateAt(_, let value), .insertAt(_, let value):
switch value {
case .invalidated:
return true
default:
return false
}
default:
return false
}
}
}
extension RoomSummaryProviderState {
init(slidingSyncState: SlidingSyncState) {
switch slidingSyncState {
case .catchingUp:
self = .live
case .live:
self = .live
default:
self = .cold
}
}
}
@@ -0,0 +1,45 @@
//
// 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
import Foundation
enum RoomSummaryProviderCallback {
case updatedRoomSummaries
}
enum RoomSummaryProviderState {
case cold
case live
}
protocol RoomSummaryProviderProtocol {
var callbacks: PassthroughSubject<RoomSummaryProviderCallback, Never> { get }
/// Publishes the current state the summary provider is finding itself in
var stateUpdatePublisher: CurrentValueSubject<RoomSummaryProviderState, Never> { get }
/// Publishes the total number of rooms
var countUpdatePublisher: CurrentValueSubject<UInt, Never> { get }
/// The current list of rooms currently provided by the sliding sync view
var roomSummaries: [RoomSummary] { get }
/// Invoked by the sliding sync controller whenever certain rooms have updated
/// without necessarily changing their position in the list
/// - Parameter identifiers: the identifiers for the rooms that have changed
func updateRoomsWithIdentifiers(_ identifiers: [String])
}
@@ -19,7 +19,6 @@ import Combine
struct MockUserSession: UserSessionProtocol {
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
let sessionVerificationController: SessionVerificationControllerProxyProtocol? = nil
var userID: String { clientProxy.userIdentifier }
var isSoftLogout: Bool { clientProxy.isSoftLogout }
var deviceId: String? { clientProxy.deviceId }
@@ -76,7 +76,7 @@ class UserSession: UserSessionProtocol {
}.store(in: &cancellables)
case .failure(let error):
MXLog.error("Failed getting session verification controller with error: \(error). Will retry on the next sync update.")
MXLog.info("Failed getting session verification controller with error: \(error). Will retry on the next sync update.")
}
}
}
@@ -24,6 +24,7 @@ enum UserSessionCallback {
case updateRestoreTokenNeeded
}
@MainActor
protocol UserSessionProtocol {
var userID: String { get }
var isSoftLogout: Bool { get }
@@ -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)
@@ -14,34 +14,21 @@
// limitations under the License.
//
import Foundation
import MatrixRustSDK
import UIKit
import Combine
struct NoticeRoomMessage: RoomMessageProtocol {
private let message: MatrixRustSDK.NoticeMessage
struct MockRoomTimelineProvider: RoomTimelineProviderProtocol {
var callbacks = PassthroughSubject<RoomTimelineProviderCallback, Never>()
var items = [RoomTimelineProviderItem]()
init(message: MatrixRustSDK.NoticeMessage) {
self.message = message
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError> {
.failure(.failedPaginatingBackwards)
}
var id: String {
message.baseMessage().id()
func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result<Void, RoomTimelineProviderError> {
.failure(.failedSendingMessage)
}
var body: String {
message.baseMessage().body()
}
var htmlBody: String? {
message.htmlBody()
}
var sender: String {
message.baseMessage().sender()
}
var originServerTs: Date {
Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs()))
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
}