Add a view to show the user's account from Element Classic. (#5361)

No presentation logic yet.
This commit is contained in:
Doug
2026-04-08 21:18:03 +01:00
committed by GitHub
parent f21e8cb892
commit 1a5a709e27
19 changed files with 588 additions and 26 deletions
@@ -68,10 +68,12 @@ class ClassicAppAccountManager {
return ClassicAppAccount(userID: userID,
displayName: user?.displayName,
avatarURL: user?.avatarURL.flatMap(URL.init(string:)),
avatarURL: user?.avatarURL,
serverName: serverName,
homeserverURL: mxAccount.homeserverURL,
cryptoStoreURL: cryptoStoreURL(for: userID),
cryptoStorePassphrase: cryptoStorePassphrase)
cryptoStorePassphrase: cryptoStorePassphrase.base64EncodedString(),
accessToken: mxAccount.accessToken)
}
private func loadUser(for mxAccount: ClassicAppMXAccount) -> ClassicAppMXUser? {
@@ -7,13 +7,23 @@
import Foundation
struct ClassicAppAccount: Equatable {
struct ClassicAppAccount: Equatable, CustomStringConvertible {
let userID: String
let displayName: String?
let avatarURL: URL?
let serverName: String
let homeserverURL: URL
let cryptoStoreURL: URL
let cryptoStorePassphrase: Data
let cryptoStorePassphrase: String
let accessToken: String // For avatar loading and key backup detection.
/// Custom `CustomStringConvertible` without the access token.
var description: String {
"ClassicAppAccount(userID: \(userID), homeserverURL: \(homeserverURL))"
}
}
// MARK: NSCoding Types
@@ -21,8 +31,10 @@ struct ClassicAppAccount: Equatable {
final class ClassicAppMXAccount: NSObject, NSCoding {
/// The obtained user ID.
var userID: String
/// The access token to create a MXRestClient.
var accessToken: String
/// The homeserver url (ex: "https://matrix.org").
var homeserver: String?
var homeserverURL: URL
/// Disable the account without logging out (NO by default).
///
@@ -37,6 +49,11 @@ final class ClassicAppMXAccount: NSObject, NSCoding {
!isDisabled && !isSoftLogout
}
/// Override the existing `CustomStringConvertible` conformance.
override var description: String {
"ClassicAppMXAccount(userID: \(userID), homeserverURL: \(homeserverURL), isDisabled: \(isDisabled), isSoftLogout: \(isSoftLogout))"
}
// MARK: NSCoding
enum Keys {
@@ -64,12 +81,15 @@ final class ClassicAppMXAccount: NSObject, NSCoding {
required init?(coder: NSCoder) {
guard let userID = coder.decodeObject(forKey: Keys.userID) as? String,
let homeserver = coder.decodeObject(forKey: Keys.homeserverURL) as? String else {
let accessToken = coder.decodeObject(forKey: Keys.accessToken) as? String,
let homeserver = coder.decodeObject(forKey: Keys.homeserverURL) as? String,
let homeserverURL = URL(string: homeserver) else {
return nil
}
self.userID = userID
self.homeserver = homeserver
self.accessToken = accessToken
self.homeserverURL = homeserverURL
isDisabled = coder.decodeBool(forKey: Keys.isDisabled)
isSoftLogout = coder.decodeBool(forKey: Keys.isSoftLogout)
@@ -89,7 +109,7 @@ final class ClassicAppMXUser: NSObject, NSCoding {
/// The user display name.
let displayName: String?
/// The url of the user of the avatar.
let avatarURL: String?
let avatarURL: URL?
// MARK: NSCoding
@@ -110,7 +130,9 @@ final class ClassicAppMXUser: NSObject, NSCoding {
self.userID = userID
displayName = aDecoder.decodeObject(forKey: Keys.displayName) as? String
avatarURL = aDecoder.decodeObject(forKey: Keys.avatarURL) as? String
let avatarURLString = aDecoder.decodeObject(forKey: Keys.avatarURL) as? String
avatarURL = avatarURLString.flatMap { URL(string: $0) }
super.init()
}
@@ -9,6 +9,7 @@ import Foundation
import KeychainAccess
import MatrixRustSDK
// sourcery: AutoMockable
protocol ClassicAppManagerProtocol {
func loadAccounts() throws -> [ClassicAppAccount]
}
@@ -0,0 +1,91 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
/// A media provider that can download a `ClassicAppAccount`'s avatar image.
class ClassicAppMediaLoader: MediaLoaderProtocol {
let classicAppAccount: ClassicAppAccount
let urlSession: URLSession
init(classicAppAccount: ClassicAppAccount, urlSession: URLSession = .shared) {
self.classicAppAccount = classicAppAccount
self.urlSession = urlSession
}
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data {
try await loadMedia(source: source)
}
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data {
try await loadMedia(source: source, width: width, height: height)
}
func loadMediaFileForSource(_ source: MediaSourceProxy, filename: String?) async throws -> MediaFileHandleProxy {
throw MediaLoaderError.notSupported // Not needed for LoadableImage
}
// MARK: - Private
private func loadMedia(source: MediaSourceProxy, width: UInt? = nil, height: UInt? = nil) async throws -> Data {
guard let mxcURL = source.url else {
MXLog.error("The provided media source is missing the URL")
throw MediaLoaderError.invalidURL
}
guard let request = mediaURLRequest(from: mxcURL, width: width, height: height) else {
MXLog.error("Failed to construct media URL for source: \(mxcURL)")
throw MediaLoaderError.invalidURL
}
let (data, response) = try await urlSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
MXLog.error("Unexpected response fetching ClassicAppAccount media: \(source.url?.absoluteString ?? "nil")")
throw MediaLoaderError.unexpectedResponse
}
return data
}
/// Constructs an authenticated `URLRequest` to download Matrix media from the homeserver.
///
/// - When `width` and `height` are provided, converts `mxc://{serverName}/{mediaId}` to
/// `{homeserverURL}/_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}?width=…&height=…`
/// - Otherwise converts to `{homeserverURL}/_matrix/client/v1/media/download/{serverName}/{mediaId}`
///
/// Sets the `Authorization: Bearer` header when an access token is provided.
private func mediaURLRequest(from mxcURL: URL, width: UInt?, height: UInt?) -> URLRequest? {
guard mxcURL.scheme == "mxc" else { return nil }
let serverName = mxcURL.host() ?? ""
let mediaID = mxcURL.path().trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !serverName.isEmpty, !mediaID.isEmpty else { return nil }
let isThumbnail = width != nil && height != nil
let endpoint = isThumbnail ? "_matrix/client/v1/media/thumbnail" : "_matrix/client/v1/media/download"
var components = URLComponents(url: classicAppAccount.homeserverURL
.appending(path: endpoint)
.appending(path: serverName)
.appending(path: mediaID), resolvingAgainstBaseURL: false)
if let width, let height {
components?.queryItems = [
URLQueryItem(name: "width", value: String(width)),
URLQueryItem(name: "height", value: String(height))
]
}
guard let url = components?.url else { return nil }
var request = URLRequest(url: url)
request.setValue("Bearer \(classicAppAccount.accessToken)", forHTTPHeaderField: "Authorization")
return request
}
}