From 01033cf53e3709293fca916edd0c7b075d1a64bc Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:20:13 +0100 Subject: [PATCH] Add support for Space avatars. (#4362) --- .../SwiftUI/Views/LoadableAvatarImage.swift | 12 ++- .../Other/SwiftUI/Views/RoomAvatarImage.swift | 95 ++++++++++++------- .../Services/Room/RoomInfoProxyProtocol.swift | 4 + .../Room/RoomSummary/RoomSummary.swift | 5 + .../TimelineController.swift | 3 +- .../roomAvatarImage.iPad-en-GB-0.png | 4 +- .../roomAvatarImage.iPad-pseudo-0.png | 4 +- .../roomAvatarImage.iPhone-16-en-GB-0.png | 4 +- .../roomAvatarImage.iPhone-16-pseudo-0.png | 4 +- UnitTests/Sources/RoomSummaryTests.swift | 6 ++ 10 files changed, 97 insertions(+), 44 deletions(-) diff --git a/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift b/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift index b7a0ef9de..2b2ec87f4 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/LoadableAvatarImage.swift @@ -11,20 +11,24 @@ struct LoadableAvatarImage: View { private let url: URL? private let name: String? private let contentID: String? + private let isSpace: Bool private let avatarSize: Avatars.Size private let mediaProvider: MediaProviderProtocol? private let onTap: ((URL) -> Void)? @ScaledMetric private var frameSize: CGFloat - init(url: URL?, name: String?, + init(url: URL?, + name: String?, contentID: String?, + isSpace: Bool = false, avatarSize: Avatars.Size, mediaProvider: MediaProviderProtocol?, onTap: ((URL) -> Void)? = nil) { self.url = url self.name = name self.contentID = contentID + self.isSpace = isSpace self.avatarSize = avatarSize self.mediaProvider = mediaProvider self.onTap = onTap @@ -49,10 +53,14 @@ struct LoadableAvatarImage: View { avatar .frame(width: frameSize, height: frameSize) .background(Color.compound.bgCanvasDefault) - .clipShape(Circle()) + .clipShape(avatarShape) .environment(\.shouldAutomaticallyLoadImages, true) // We always load avatars. } + private var avatarShape: some Shape { + isSpace ? AnyShape(RoundedRectangle(cornerRadius: frameSize / 4)) : AnyShape(Circle()) + } + @ViewBuilder private var avatar: some View { if let url { diff --git a/ElementX/Sources/Other/SwiftUI/Views/RoomAvatarImage.swift b/ElementX/Sources/Other/SwiftUI/Views/RoomAvatarImage.swift index 854a713d3..2bcc8dcb9 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/RoomAvatarImage.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/RoomAvatarImage.swift @@ -9,10 +9,12 @@ import SwiftUI /// Information about a room avatar such as it's URL or the heroes to use as a fallback. 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?) - /// An avatar generated from the room's heroes. + /// An avatar generated from a collection of room heroes. 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. case tombstoned @@ -22,6 +24,8 @@ enum RoomAvatar: Equatable { .room(id: id, name: name, avatarURL: nil) case let .heroes(users): .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: .tombstoned } @@ -29,7 +33,8 @@ enum RoomAvatar: Equatable { var hasURL: Bool { switch self { - case let .room(_, _, url): + case let .room(_, _, url), + let .space(_, _, url): return url != nil case let .heroes(heroes): return heroes.first?.avatarURL != nil @@ -106,6 +111,14 @@ struct RoomAvatarImage: View { mediaProvider: mediaProvider, 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: TombstonedAvatarImage(avatarSize: avatarSize) } @@ -114,37 +127,53 @@ struct RoomAvatarImage: View { struct RoomAvatarImage_Previews: PreviewProvider, TestablePreview { static var previews: some View { - HStack(spacing: 8) { - RoomAvatarImage(avatar: .room(id: "!1:server.com", - name: "Room", - avatarURL: nil), - avatarSize: .room(on: .home), - mediaProvider: MediaProviderMock(configuration: .init())) + VStack(spacing: 20) { + HStack(spacing: 12) { + RoomAvatarImage(avatar: .room(id: "!1:server.com", + name: "Room", + avatarURL: nil), + avatarSize: .room(on: .home), + mediaProvider: MediaProviderMock(configuration: .init())) + + RoomAvatarImage(avatar: .room(id: "!2:server.com", + name: "Room", + avatarURL: .mockMXCAvatar), + avatarSize: .room(on: .home), + mediaProvider: MediaProviderMock(configuration: .init())) + + RoomAvatarImage(avatar: .space(id: "!space:server.com", + name: "Room", + avatarURL: nil), + avatarSize: .room(on: .home), + mediaProvider: MediaProviderMock(configuration: .init())) + + RoomAvatarImage(avatar: .space(id: "!otherspace:server.com", + name: "Room", + avatarURL: .mockMXCAvatar), + avatarSize: .room(on: .home), + mediaProvider: MediaProviderMock(configuration: .init())) + + RoomAvatarImage(avatar: .tombstoned, avatarSize: .room(on: .home), mediaProvider: MediaProviderMock(configuration: .init())) + } - RoomAvatarImage(avatar: .room(id: "!2:server.com", - name: "Room", - avatarURL: .mockMXCAvatar), - avatarSize: .room(on: .home), - mediaProvider: MediaProviderMock(configuration: .init())) - - 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())) - - 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())) + } } } } diff --git a/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift index be54de5ec..28280c16b 100644 --- a/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift @@ -69,6 +69,10 @@ extension BaseRoomInfoProxyProtocol { return .tombstoned } + if isSpace { + return .space(id: id, name: displayName, avatarURL: avatarURL) + } + if isDirect, avatarURL == nil, heroes.count == 1 { return .heroes(heroes.map(UserProfileProxy.init)) } diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift index 46b514317..72733abad 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift @@ -134,6 +134,11 @@ extension RoomSummary { 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 { return .heroes(heroes) } else { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift index 7ba2f88ec..491fa88e4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/TimelineController.swift @@ -570,7 +570,8 @@ class TimelineController: TimelineControllerProtocol { attachments: nil) let avatarURL: URL? = switch roomProxy.details.avatar { - case .room(_, _, let avatarURL): + case .room(_, _, let avatarURL), + .space(_, _, let avatarURL): avatarURL case .heroes(let userProfiles): userProfiles.first?.avatarURL diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPad-en-GB-0.png index d6633c6bf..e16fae5bd 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1896e5328d738494310900bf5d0f4a7441bb70f91103a20bce20ca064b6e6d5e -size 103499 +oid sha256:e28d36588f155e868a9b24d78f7688c64fac91e57a81b835dd111687c265428b +size 132520 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPad-pseudo-0.png index d6633c6bf..e16fae5bd 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1896e5328d738494310900bf5d0f4a7441bb70f91103a20bce20ca064b6e6d5e -size 103499 +oid sha256:e28d36588f155e868a9b24d78f7688c64fac91e57a81b835dd111687c265428b +size 132520 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPhone-16-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPhone-16-en-GB-0.png index 9278d4cc8..6820db683 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPhone-16-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPhone-16-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d84daf5a9f2f6caed69fca648e8f677b3a1055f238d482bbfd7ede5769605bb6 -size 58978 +oid sha256:4d5acb74a871dc05d9112d6c5ee88d26f2ed2a489995bf9800f9c644236576f9 +size 85999 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPhone-16-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPhone-16-pseudo-0.png index 9278d4cc8..6820db683 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPhone-16-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomAvatarImage.iPhone-16-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d84daf5a9f2f6caed69fca648e8f677b3a1055f238d482bbfd7ede5769605bb6 -size 58978 +oid sha256:4d5acb74a871dc05d9112d6c5ee88d26f2ed2a489995bf9800f9c644236576f9 +size 85999 diff --git a/UnitTests/Sources/RoomSummaryTests.swift b/UnitTests/Sources/RoomSummaryTests.swift index eb718d0a3..410b9ad4c 100644 --- a/UnitTests/Sources/RoomSummaryTests.swift +++ b/UnitTests/Sources/RoomSummaryTests.swift @@ -24,6 +24,8 @@ class RoomSummaryTests: XCTestCase { XCTAssertEqual(avatarURL, roomDetails.avatarURL) case .heroes: XCTFail("A room shouldn't use the heroes for its avatar.") + case .space: + XCTFail("A room shouldn't use a space avatar.") case .tombstoned: XCTFail("A room shouldn't use the tombstone for its avatar.") } @@ -39,6 +41,8 @@ class RoomSummaryTests: XCTestCase { XCTAssertEqual(avatarURL, roomDetails.avatarURL) case .heroes: 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: 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.") case .heroes(let heroes): XCTAssertEqual(heroes, self.heroes) + case .space: + XCTFail("A DM shouldn't use a space avatar.") case .tombstoned: XCTFail("A room shouldn't use the tombstone for its avatar.") }