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
@@ -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 NoticeRoomMessage: RoomMessageProtocol {
private let message: MatrixRustSDK.NoticeMessage
init(message: MatrixRustSDK.NoticeMessage) {
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,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])
}