Files
letro-ios/ElementX/Sources/Other/SwiftUI/Views/RoomAvatarImage.swift
Doug bab9b89416 Some random tweaks made on a train 🚆 (#4636)
* Fix the search text field's tint colour.

* Don't allow optional content IDs in the placeholder avatar.

* Use SwiftUI to resolve the hex values in the Inspector app.

This fixes incorrect values being shown in dark/high-contrast modes.

* Fix a layout bug with the colour swatch in the Inspector app on iPhone.

* Switch to the chats tab when selecting a room with the global search screen.

* Run the latest SwiftFormat.
2025-10-21 10:04:54 +01:00

180 lines
8.2 KiB
Swift

//
// Copyright 2024 New Vector 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
/// 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 a Room's details.
case room(id: String, name: String?, avatarURL: URL?)
/// 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
var removingAvatar: RoomAvatar {
switch self {
case let .room(id, name, _):
.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
}
}
var hasURL: Bool {
switch self {
case let .room(_, _, url),
let .space(_, _, url):
return url != nil
case let .heroes(heroes):
return heroes.first?.avatarURL != nil
case .tombstoned:
return false
}
}
}
/// A view that shows the avatar for a room, or a cluster of heroes if provided.
///
/// This should be preferred over `LoadableAvatarImage` when displaying a
/// room avatar so that DMs have a consistent appearance throughout the app.
struct RoomAvatarImage: View {
let avatar: RoomAvatar
let avatarSize: Avatars.Size
let mediaProvider: MediaProviderProtocol?
private(set) var onAvatarTap: ((URL) -> Void)?
var body: some View {
switch avatar {
case .room(let id, let name, let avatarURL):
LoadableAvatarImage(url: avatarURL,
name: name,
contentID: id,
avatarSize: avatarSize,
mediaProvider: mediaProvider,
onTap: onAvatarTap)
case .heroes(let users):
// We will expand upon this with more stack sizes in the future.
if users.count == 0 {
let _ = assertionFailure("We should never pass empty heroes here.")
PlaceholderAvatarImage(name: nil, contentID: "")
} else if users.count == 2 {
let clusterSize = avatarSize.value * 1.6
ZStack {
LoadableAvatarImage(url: users[0].avatarURL,
name: users[0].displayName,
contentID: users[0].userID,
avatarSize: avatarSize,
mediaProvider: mediaProvider,
onTap: onAvatarTap)
.scaledFrame(size: clusterSize, alignment: .topTrailing)
LoadableAvatarImage(url: users[1].avatarURL,
name: users[1].displayName,
contentID: users[1].userID,
avatarSize: avatarSize,
mediaProvider: mediaProvider,
onTap: onAvatarTap)
.mask {
Rectangle()
.fill(Color.white)
.overlay {
Circle()
.inset(by: -4)
.fill(Color.black)
.scaledOffset(x: clusterSize - avatarSize.value,
y: -clusterSize + avatarSize.value)
}
.compositingGroup()
.luminanceToAlpha()
}
.scaledFrame(size: clusterSize, alignment: .bottomLeading)
}
.scaledFrame(size: clusterSize)
} else {
LoadableAvatarImage(url: users[0].avatarURL,
name: users[0].displayName,
contentID: users[0].userID,
avatarSize: avatarSize,
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)
}
}
}
struct RoomAvatarImage_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 20) {
HStack(spacing: 12) {
RoomAvatarImage(avatar: .room(id: "!1:server.com",
name: "Room",
avatarURL: nil),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .room(id: "!2:server.com",
name: "Room",
avatarURL: .mockMXCAvatar),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .space(id: "!space:server.com",
name: "Room",
avatarURL: nil),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .space(id: "!otherspace:server.com",
name: "Room",
avatarURL: .mockMXCAvatar),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .tombstoned, avatarSize: .room(on: .chats), mediaProvider: MediaProviderMock(configuration: .init()))
}
HStack(spacing: 12) {
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com",
displayName: "User",
avatarURL: nil)]),
avatarSize: .room(on: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com",
displayName: "User",
avatarURL: .mockMXCAvatar)]),
avatarSize: .room(on: .chats),
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: .chats),
mediaProvider: MediaProviderMock(configuration: .init()))
}
}
}
}