Add support for Space avatars. (#4362)

This commit is contained in:
Doug
2025-07-29 12:20:13 +01:00
committed by GitHub
parent 75bc26d20b
commit 01033cf53e
10 changed files with 97 additions and 44 deletions
@@ -11,20 +11,24 @@ struct LoadableAvatarImage: View {
private let url: URL? private let url: URL?
private let name: String? private let name: String?
private let contentID: String? private let contentID: String?
private let isSpace: Bool
private let avatarSize: Avatars.Size private let avatarSize: Avatars.Size
private let mediaProvider: MediaProviderProtocol? private let mediaProvider: MediaProviderProtocol?
private let onTap: ((URL) -> Void)? private let onTap: ((URL) -> Void)?
@ScaledMetric private var frameSize: CGFloat @ScaledMetric private var frameSize: CGFloat
init(url: URL?, name: String?, init(url: URL?,
name: String?,
contentID: String?, contentID: String?,
isSpace: Bool = false,
avatarSize: Avatars.Size, avatarSize: Avatars.Size,
mediaProvider: MediaProviderProtocol?, mediaProvider: MediaProviderProtocol?,
onTap: ((URL) -> Void)? = nil) { onTap: ((URL) -> Void)? = nil) {
self.url = url self.url = url
self.name = name self.name = name
self.contentID = contentID self.contentID = contentID
self.isSpace = isSpace
self.avatarSize = avatarSize self.avatarSize = avatarSize
self.mediaProvider = mediaProvider self.mediaProvider = mediaProvider
self.onTap = onTap self.onTap = onTap
@@ -49,10 +53,14 @@ struct LoadableAvatarImage: View {
avatar avatar
.frame(width: frameSize, height: frameSize) .frame(width: frameSize, height: frameSize)
.background(Color.compound.bgCanvasDefault) .background(Color.compound.bgCanvasDefault)
.clipShape(Circle()) .clipShape(avatarShape)
.environment(\.shouldAutomaticallyLoadImages, true) // We always load avatars. .environment(\.shouldAutomaticallyLoadImages, true) // We always load avatars.
} }
private var avatarShape: some Shape {
isSpace ? AnyShape(RoundedRectangle(cornerRadius: frameSize / 4)) : AnyShape(Circle())
}
@ViewBuilder @ViewBuilder
private var avatar: some View { private var avatar: some View {
if let url { if let url {
@@ -9,10 +9,12 @@ import SwiftUI
/// Information about a room avatar such as it's URL or the heroes to use as a fallback. /// Information about a room avatar such as it's URL or the heroes to use as a fallback.
enum RoomAvatar: Equatable { enum RoomAvatar: Equatable {
/// An avatar generated from the room's details. /// An avatar generated from a Room's details.
case room(id: String, name: String?, avatarURL: URL?) case room(id: String, name: String?, avatarURL: URL?)
/// An avatar generated from the room's heroes. /// An avatar generated from a collection of room heroes.
case heroes([UserProfileProxy]) case heroes([UserProfileProxy])
/// An avatar generated from a Space's details.
case space(id: String, name: String?, avatarURL: URL?)
/// A static avatar for a tombstoned room. /// A static avatar for a tombstoned room.
case tombstoned case tombstoned
@@ -22,6 +24,8 @@ enum RoomAvatar: Equatable {
.room(id: id, name: name, avatarURL: nil) .room(id: id, name: name, avatarURL: nil)
case let .heroes(users): case let .heroes(users):
.heroes(users.map { .init(userID: $0.userID, displayName: $0.displayName, avatarURL: nil) }) .heroes(users.map { .init(userID: $0.userID, displayName: $0.displayName, avatarURL: nil) })
case .space(let id, let name, _):
.space(id: id, name: name, avatarURL: nil)
case .tombstoned: case .tombstoned:
.tombstoned .tombstoned
} }
@@ -29,7 +33,8 @@ enum RoomAvatar: Equatable {
var hasURL: Bool { var hasURL: Bool {
switch self { switch self {
case let .room(_, _, url): case let .room(_, _, url),
let .space(_, _, url):
return url != nil return url != nil
case let .heroes(heroes): case let .heroes(heroes):
return heroes.first?.avatarURL != nil return heroes.first?.avatarURL != nil
@@ -106,6 +111,14 @@ struct RoomAvatarImage: View {
mediaProvider: mediaProvider, mediaProvider: mediaProvider,
onTap: onAvatarTap) onTap: onAvatarTap)
} }
case .space(let id, let name, let avatarURL):
LoadableAvatarImage(url: avatarURL,
name: name,
contentID: id,
isSpace: true,
avatarSize: avatarSize,
mediaProvider: mediaProvider,
onTap: onAvatarTap)
case .tombstoned: case .tombstoned:
TombstonedAvatarImage(avatarSize: avatarSize) TombstonedAvatarImage(avatarSize: avatarSize)
} }
@@ -114,37 +127,53 @@ struct RoomAvatarImage: View {
struct RoomAvatarImage_Previews: PreviewProvider, TestablePreview { struct RoomAvatarImage_Previews: PreviewProvider, TestablePreview {
static var previews: some View { static var previews: some View {
HStack(spacing: 8) { VStack(spacing: 20) {
RoomAvatarImage(avatar: .room(id: "!1:server.com", HStack(spacing: 12) {
name: "Room", RoomAvatarImage(avatar: .room(id: "!1:server.com",
avatarURL: nil), name: "Room",
avatarSize: .room(on: .home), avatarURL: nil),
mediaProvider: MediaProviderMock(configuration: .init())) avatarSize: .room(on: .home),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .room(id: "!2:server.com", RoomAvatarImage(avatar: .room(id: "!2:server.com",
name: "Room", name: "Room",
avatarURL: .mockMXCAvatar), avatarURL: .mockMXCAvatar),
avatarSize: .room(on: .home), avatarSize: .room(on: .home),
mediaProvider: MediaProviderMock(configuration: .init())) mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com", RoomAvatarImage(avatar: .space(id: "!space:server.com",
displayName: "User", name: "Room",
avatarURL: nil)]), avatarURL: nil),
avatarSize: .room(on: .home), avatarSize: .room(on: .home),
mediaProvider: MediaProviderMock(configuration: .init())) mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com", RoomAvatarImage(avatar: .space(id: "!otherspace:server.com",
displayName: "User", name: "Room",
avatarURL: .mockMXCAvatar)]), avatarURL: .mockMXCAvatar),
avatarSize: .room(on: .home), avatarSize: .room(on: .home),
mediaProvider: MediaProviderMock(configuration: .init())) mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .heroes([.init(userID: "@alice:server.com", displayName: "Alice", avatarURL: nil), RoomAvatarImage(avatar: .tombstoned, avatarSize: .room(on: .home), mediaProvider: MediaProviderMock(configuration: .init()))
.init(userID: "@bob:server.net", displayName: "Bob", avatarURL: nil)]), }
avatarSize: .room(on: .home),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .tombstoned, avatarSize: .room(on: .home), mediaProvider: MediaProviderMock(configuration: .init())) HStack(spacing: 12) {
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com",
displayName: "User",
avatarURL: nil)]),
avatarSize: .room(on: .home),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com",
displayName: "User",
avatarURL: .mockMXCAvatar)]),
avatarSize: .room(on: .home),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .heroes([.init(userID: "@alice:server.com", displayName: "Alice", avatarURL: nil),
.init(userID: "@bob:server.net", displayName: "Bob", avatarURL: nil)]),
avatarSize: .room(on: .home),
mediaProvider: MediaProviderMock(configuration: .init()))
}
} }
} }
} }
@@ -69,6 +69,10 @@ extension BaseRoomInfoProxyProtocol {
return .tombstoned return .tombstoned
} }
if isSpace {
return .space(id: id, name: displayName, avatarURL: avatarURL)
}
if isDirect, avatarURL == nil, heroes.count == 1 { if isDirect, avatarURL == nil, heroes.count == 1 {
return .heroes(heroes.map(UserProfileProxy.init)) return .heroes(heroes.map(UserProfileProxy.init))
} }
@@ -134,6 +134,11 @@ extension RoomSummary {
return .tombstoned return .tombstoned
} }
// NOTE: The check for isSpace isn't implemented yet, waiting to see
// whether we end up with a different type for spaces in the room list.
//
// Don't forget to add a test when you remove this comment 😄
if isDirect, avatarURL == nil, heroes.count == 1 { if isDirect, avatarURL == nil, heroes.count == 1 {
return .heroes(heroes) return .heroes(heroes)
} else { } else {
@@ -570,7 +570,8 @@ class TimelineController: TimelineControllerProtocol {
attachments: nil) attachments: nil)
let avatarURL: URL? = switch roomProxy.details.avatar { let avatarURL: URL? = switch roomProxy.details.avatar {
case .room(_, _, let avatarURL): case .room(_, _, let avatarURL),
.space(_, _, let avatarURL):
avatarURL avatarURL
case .heroes(let userProfiles): case .heroes(let userProfiles):
userProfiles.first?.avatarURL userProfiles.first?.avatarURL
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1896e5328d738494310900bf5d0f4a7441bb70f91103a20bce20ca064b6e6d5e oid sha256:e28d36588f155e868a9b24d78f7688c64fac91e57a81b835dd111687c265428b
size 103499 size 132520
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1896e5328d738494310900bf5d0f4a7441bb70f91103a20bce20ca064b6e6d5e oid sha256:e28d36588f155e868a9b24d78f7688c64fac91e57a81b835dd111687c265428b
size 103499 size 132520
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d84daf5a9f2f6caed69fca648e8f677b3a1055f238d482bbfd7ede5769605bb6 oid sha256:4d5acb74a871dc05d9112d6c5ee88d26f2ed2a489995bf9800f9c644236576f9
size 58978 size 85999
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d84daf5a9f2f6caed69fca648e8f677b3a1055f238d482bbfd7ede5769605bb6 oid sha256:4d5acb74a871dc05d9112d6c5ee88d26f2ed2a489995bf9800f9c644236576f9
size 58978 size 85999
+6
View File
@@ -24,6 +24,8 @@ class RoomSummaryTests: XCTestCase {
XCTAssertEqual(avatarURL, roomDetails.avatarURL) XCTAssertEqual(avatarURL, roomDetails.avatarURL)
case .heroes: case .heroes:
XCTFail("A room shouldn't use the heroes for its avatar.") XCTFail("A room shouldn't use the heroes for its avatar.")
case .space:
XCTFail("A room shouldn't use a space avatar.")
case .tombstoned: case .tombstoned:
XCTFail("A room shouldn't use the tombstone for its avatar.") XCTFail("A room shouldn't use the tombstone for its avatar.")
} }
@@ -39,6 +41,8 @@ class RoomSummaryTests: XCTestCase {
XCTAssertEqual(avatarURL, roomDetails.avatarURL) XCTAssertEqual(avatarURL, roomDetails.avatarURL)
case .heroes: case .heroes:
XCTFail("A DM with an avatar set shouldn't use the heroes instead.") XCTFail("A DM with an avatar set shouldn't use the heroes instead.")
case .space:
XCTFail("A DM shouldn't use a space avatar.")
case .tombstoned: case .tombstoned:
XCTFail("A room shouldn't use the tombstone for its avatar.") XCTFail("A room shouldn't use the tombstone for its avatar.")
} }
@@ -52,6 +56,8 @@ class RoomSummaryTests: XCTestCase {
XCTFail("A DM without an avatar should defer to the hero for the correct placeholder tint colour.") XCTFail("A DM without an avatar should defer to the hero for the correct placeholder tint colour.")
case .heroes(let heroes): case .heroes(let heroes):
XCTAssertEqual(heroes, self.heroes) XCTAssertEqual(heroes, self.heroes)
case .space:
XCTFail("A DM shouldn't use a space avatar.")
case .tombstoned: case .tombstoned:
XCTFail("A room shouldn't use the tombstone for its avatar.") XCTFail("A room shouldn't use the tombstone for its avatar.")
} }