Update the SDK and use RoomHero.status and subscribeToOwnProfile. (#5905)

* Update the SDK and hook up the status from Room Heroes.

Also start requesting profiles via sss when the feature flag is enabled.

* Add support for subscribing to your own profile.
This commit is contained in:
Doug
2026-07-22 12:18:02 +01:00
committed by GitHub
parent 4f03acab0c
commit a6466e80e0
20 changed files with 142 additions and 51 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ extension ClientProxyMock {
}
joinRoomAliasReturnValue = .success(())
uploadMediaReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
loadUserProfileReturnValue = .success(())
loadUserProfileIfNeededReturnValue = .success(())
setUserDisplayNameReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
setUserAvatarMediaReturnValue = .success(())
removeUserAvatarReturnValue = .success(())
@@ -3094,33 +3094,33 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return reportRoomForIdentifierReasonReturnValue
}
}
//MARK: - loadUserProfile
//MARK: - loadUserProfileIfNeeded
private let loadUserProfileCallsCountLock = NSLock()
private nonisolated(unsafe) var loadUserProfileUnderlyingCallsCount = 0
var loadUserProfileCallsCount: Int {
get { loadUserProfileCallsCountLock.withLock { loadUserProfileUnderlyingCallsCount } }
set { loadUserProfileCallsCountLock.withLock { loadUserProfileUnderlyingCallsCount = newValue } }
private let loadUserProfileIfNeededCallsCountLock = NSLock()
private nonisolated(unsafe) var loadUserProfileIfNeededUnderlyingCallsCount = 0
var loadUserProfileIfNeededCallsCount: Int {
get { loadUserProfileIfNeededCallsCountLock.withLock { loadUserProfileIfNeededUnderlyingCallsCount } }
set { loadUserProfileIfNeededCallsCountLock.withLock { loadUserProfileIfNeededUnderlyingCallsCount = newValue } }
}
var loadUserProfileCalled: Bool {
return loadUserProfileCallsCount > 0
var loadUserProfileIfNeededCalled: Bool {
return loadUserProfileIfNeededCallsCount > 0
}
private let loadUserProfileReturnValueLock = NSLock()
private nonisolated(unsafe) var loadUserProfileUnderlyingReturnValue: Result<Void, ClientProxyError>!
var loadUserProfileReturnValue: Result<Void, ClientProxyError>! {
get { loadUserProfileReturnValueLock.withLock { loadUserProfileUnderlyingReturnValue } }
set { loadUserProfileReturnValueLock.withLock { loadUserProfileUnderlyingReturnValue = newValue } }
private let loadUserProfileIfNeededReturnValueLock = NSLock()
private nonisolated(unsafe) var loadUserProfileIfNeededUnderlyingReturnValue: Result<Void, ClientProxyError>!
var loadUserProfileIfNeededReturnValue: Result<Void, ClientProxyError>! {
get { loadUserProfileIfNeededReturnValueLock.withLock { loadUserProfileIfNeededUnderlyingReturnValue } }
set { loadUserProfileIfNeededReturnValueLock.withLock { loadUserProfileIfNeededUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var loadUserProfileClosure: (() async -> Result<Void, ClientProxyError>)?
nonisolated(unsafe) var loadUserProfileIfNeededClosure: (() async -> Result<Void, ClientProxyError>)?
@discardableResult
@concurrent func loadUserProfile() async -> Result<Void, ClientProxyError> {
loadUserProfileCallsCountLock.withLock { loadUserProfileUnderlyingCallsCount += 1 }
if let loadUserProfileClosure = loadUserProfileClosure {
return await loadUserProfileClosure()
@concurrent func loadUserProfileIfNeeded() async -> Result<Void, ClientProxyError> {
loadUserProfileIfNeededCallsCountLock.withLock { loadUserProfileIfNeededUnderlyingCallsCount += 1 }
if let loadUserProfileIfNeededClosure = loadUserProfileIfNeededClosure {
return await loadUserProfileIfNeededClosure()
} else {
return loadUserProfileReturnValue
return loadUserProfileIfNeededReturnValue
}
}
//MARK: - setUserDisplayName
@@ -176,7 +176,9 @@ private extension RoomHero {
init(from memberProxy: RoomMemberProxyMock) {
self.init(userId: memberProxy.userID,
displayName: memberProxy.displayName,
avatarUrl: memberProxy.avatarURL?.absoluteString)
avatarUrl: memberProxy.avatarURL?.absoluteString,
status: memberProxy.status.raw?.rustValue,
call: memberProxy.status.call?.rustValue)
}
}
+6
View File
@@ -116,6 +116,12 @@ nonisolated extension SDKListener: VerificationStateListener where T == Verifica
}
}
nonisolated extension SDKListener: ProfileListener where T == MatrixRustSDK.UserProfile {
func onUpdate(profile: MatrixRustSDK.UserProfile) {
onUpdateClosure(profile)
}
}
nonisolated extension SDKListener: IgnoredUsersListener where T == [String] {
func call(ignoredUserIds: [String]) {
onUpdateClosure(ignoredUserIds)
@@ -12,7 +12,7 @@ import SwiftUI
struct RoomHeaderView: View {
struct DMRecipientDetails {
var status: UserStatus?
var statusEmoji: Character?
var verification: UserIdentityVerificationState?
}
@@ -61,7 +61,7 @@ struct RoomHeaderView: View {
.foregroundStyle(.compound.textPrimary)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
if let statusEmoji = dmRecipientDetails.status?.displayed?.emoji {
if let statusEmoji = dmRecipientDetails.statusEmoji {
Text(String(statusEmoji))
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
@@ -179,7 +179,8 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
roomAvatar: .room(id: "1",
name: roomName,
avatarURL: avatarURL),
dmRecipientDetails: .init(status: userStatus, verification: verificationState),
dmRecipientDetails: .init(statusEmoji: userStatus?.displayed?.emoji,
verification: verificationState),
roomHistorySharingState: historySharingState,
mediaProvider: MediaProviderMock(.init())) { }
@@ -348,7 +348,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
// Delay user profile detail loading until after the initial room list loads
if roomListMode == .rooms {
Task {
await self.userSession.clientProxy.loadUserProfile()
await self.userSession.clientProxy.loadUserProfileIfNeeded()
}
}
}
@@ -335,6 +335,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
state.roomTitle = roomInfo.displayName ?? roomProxy.id
state.roomAvatar = roomInfo.avatar
state.dmRecipientDetails.statusEmoji = roomInfo.statusEmoji
state.hasOngoingCall = roomInfo.hasRoomCall
state.activeRoomCallIntent = roomInfo.activeRoomCallIntent
state.hasSuccessor = roomInfo.successor != nil
@@ -86,7 +86,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
.store(in: &cancellables)
Task {
await userSession.clientProxy.loadUserProfile()
await userSession.clientProxy.loadUserProfileIfNeeded()
await state.accountProfileURL = userSession.clientProxy.accountURL(action: .profile)
}
}
@@ -98,6 +98,8 @@ struct SettingsScreen: View {
SettingsScreenUserStatusPickerView { action in
context.send(viewAction: .userStatus(action))
}
.presentationDetents([.medium]) // Stop using a List and calculate the exact height?
.presentationBackground(.compound.bgCanvasDefault)
}
}
}
@@ -46,7 +46,7 @@ class UserDetailsEditScreenViewModel: UserDetailsEditScreenViewModelType, UserDe
.store(in: &cancellables)
Task {
await self.clientProxy.loadUserProfile()
await self.clientProxy.loadUserProfileIfNeeded()
state.canEditAvatar = await clientProxy.capabilities.canChangeAvatar()
state.canEditDisplayName = await clientProxy.capabilities.canChangeDisplayName()
}
@@ -103,6 +103,8 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi
private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
state.roomTitle = roomInfo.displayName ?? roomProxy.id
state.roomAvatar = roomInfo.avatar
state.dmRecipientDetails.statusEmoji = roomInfo.statusEmoji
if let powerLevels = roomInfo.powerLevels {
state.canSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
}
@@ -32,6 +32,9 @@ class ClientProxy: ClientProxyProtocol {
// periphery: ignore - only for retain
private var syncServiceStateUpdateTaskHandle: TaskHandle?
// periphery:ignore - required for instance retention in the rust codebase
private var userProfileListenerTaskHandle: TaskHandle?
// periphery:ignore - required for instance retention in the rust codebase
private var ignoredUsersListenerTaskHandle: TaskHandle?
@@ -278,9 +281,13 @@ class ClientProxy: ClientProxyProtocol {
try await client.setUtdDelegate(utdDelegate: ClientDecryptionErrorDelegate(actionsSubject: actionsSubject))
loadUserAvatarURLFromCache()
let canSubscribeToUserProfile = appSettings.userStatusEnabled // TODO: @pixlwave to add a /versions check too.
await setupSubscriptions()
if !canSubscribeToUserProfile {
loadUserAvatarURLFromCache()
}
await setupSubscriptions(canSubscribeToUserProfile: canSubscribeToUserProfile)
Task {
do {
@@ -686,14 +693,18 @@ class ClientProxy: ClientProxyProtocol {
}
}
func loadUserProfile() async -> Result<Void, ClientProxyError> {
func loadUserProfileIfNeeded() async -> Result<Void, ClientProxyError> {
// There's no need to load the profile if we're subscribed to it via /sync
guard userProfileListenerTaskHandle == nil else { return .success(()) }
do {
async let displayName = client.displayName()
async let avatarURLString = client.avatarUrl()
let profile = try await UserProfile(userID: userID,
displayName: displayName,
avatarURL: avatarURLString.flatMap(URL.init))
avatarURL: avatarURLString.flatMap(URL.init),
status: userProfileSubject.value.status)
loadCachedAvatarURLTask?.cancel()
userProfileSubject.send(profile)
return .success(())
@@ -706,7 +717,7 @@ class ClientProxy: ClientProxyProtocol {
func setUserDisplayName(_ name: String) async -> Result<Void, ClientProxyError> {
do {
try await client.setDisplayName(name: name)
Task { await self.loadUserProfile() }
Task { await self.loadUserProfileIfNeeded() }
return .success(())
} catch {
MXLog.error("Failed setting user display name with error: \(error)")
@@ -723,7 +734,7 @@ class ClientProxy: ClientProxyProtocol {
do {
let data = try Data(contentsOf: imageURL)
try await client.uploadAvatar(mimeType: mimeType, data: data)
Task { await self.loadUserProfile() }
Task { await self.loadUserProfileIfNeeded() }
return .success(())
} catch {
MXLog.error("Failed setting user avatar with error: \(error)")
@@ -734,7 +745,7 @@ class ClientProxy: ClientProxyProtocol {
func removeUserAvatar() async -> Result<Void, ClientProxyError> {
do {
try await client.removeAvatar()
Task { await self.loadUserProfile() }
Task { await self.loadUserProfileIfNeeded() }
return .success(())
} catch {
MXLog.error("Failed removing user avatar with error: \(error)")
@@ -746,7 +757,7 @@ class ClientProxy: ClientProxyProtocol {
func setUserStatus(_ status: UserStatus.Raw) async -> Result<Void, ClientProxyError> {
do {
try await client.setUserStatus(status: status.rustValue)
Task { await self.loadUserProfile() }
// No need to refresh the profile, we only support user status with the profiles /sync extension.
return .success(())
} catch {
MXLog.error("Failed setting user status with error: \(error)")
@@ -758,7 +769,7 @@ class ClientProxy: ClientProxyProtocol {
func removeUserStatus() async -> Result<Void, ClientProxyError> {
do {
try await client.clearUserStatus()
Task { await self.loadUserProfile() }
Task { await self.loadUserProfileIfNeeded() }
return .success(())
} catch {
MXLog.error("Failed removing user status with error: \(error)")
@@ -1017,7 +1028,7 @@ class ClientProxy: ClientProxyProtocol {
// MARK: - Private
private func setupSubscriptions() async {
private func setupSubscriptions(canSubscribeToUserProfile: Bool) async {
networkMonitor.reachabilityPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
@@ -1030,6 +1041,12 @@ class ClientProxy: ClientProxyProtocol {
}
.store(in: &cancellables)
if canSubscribeToUserProfile {
userProfileListenerTaskHandle = try? client.subscribeToOwnProfile(listener: SDKListener.onMainActor { [weak self] profile in
self?.userProfileSubject.send(.init(rustUserProfile: profile))
})
}
ignoredUsersListenerTaskHandle = client.subscribeToIgnoredUsers(listener: SDKListener.onMainActor { [weak self] ignoredUsers in
self?.ignoredUsersSubject.send(ignoredUsers)
})
@@ -1201,7 +1218,8 @@ class ClientProxy: ClientProxyProtocol {
let profile = self.userProfileSubject.value
self.userProfileSubject.value = UserProfile(userID: profile.id,
displayName: profile.displayName,
avatarURL: urlString.flatMap(URL.init))
avatarURL: urlString.flatMap(URL.init),
status: profile.status)
} catch {
MXLog.error("Failed to look for the avatar url in the cache: \(error)")
}
@@ -1481,11 +1499,14 @@ private struct ClientProxyServices {
init(client: ClientProtocol,
notificationSettings: NotificationSettingsProxyProtocol,
appSettings: AppSettings) async throws {
let syncService = try await client
var syncServiceBuilder = client
.syncService()
.withOfflineMode()
.withSharePos(enable: true)
.finish()
if appSettings.userStatusEnabled {
syncServiceBuilder = syncServiceBuilder.withProfilesExtension()
}
let syncService = try await syncServiceBuilder.finish()
let roomListService = syncService.roomListService()
@@ -212,7 +212,11 @@ protocol ClientProxyProtocol: AnyObject {
/// Will only work for rooms that are in our room list/local store
func reportRoomForIdentifier(_ identifier: String, reason: String) async -> Result<Void, ClientProxyError>
@discardableResult func loadUserProfile() async -> Result<Void, ClientProxyError>
/// Loads the user's own profile when the server doesn't support MSC4262 and both returns the profile
/// as well as publishing it via ``userProfilePublisher``.
///
/// When the server does support the MSC, then the client automatically publishes profile and keeps it up to date.
@discardableResult func loadUserProfileIfNeeded() async -> Result<Void, ClientProxyError>
func setUserDisplayName(_ name: String) async -> Result<Void, ClientProxyError>
func setUserAvatar(media: MediaInfo) async -> Result<Void, ClientProxyError>
func removeUserAvatar() async -> Result<Void, ClientProxyError>
@@ -94,6 +94,11 @@ extension BaseRoomInfoProxyProtocol {
return .room(id: id, name: displayName, avatarURL: avatarURL)
}
var statusEmoji: Character? {
guard case let .heroes(heroes) = avatar else { return nil }
return heroes.first?.status.displayed?.emoji
}
}
extension RoomInfoProxyProtocol {
@@ -186,7 +186,7 @@ nonisolated extension RoomSummary {
}
var statusEmoji: Character? {
guard isDirect, case let .heroes(heroes) = avatar, heroes.count == 1 else { return nil }
guard case let .heroes(heroes) = avatar else { return nil }
return heroes.first?.status.displayed?.emoji
}
}
@@ -47,7 +47,7 @@ nonisolated struct UserProfile: Hashable, Identifiable {
id = rustRoomHero.userId
displayName = rustRoomHero.displayName
avatarURL = rustRoomHero.avatarUrl.flatMap(URL.init(string:))
status = .init() // Requires https://github.com/matrix-org/matrix-rust-sdk/pull/6733
status = .init(rustStatus: rustRoomHero.status, rustCall: rustRoomHero.call)
}
init(member: RoomMemberProxyProtocol) {