Use the room heroes when computing a DM avatar. (#2900)

* Use room heroes for DM avatar content ID.

* Use RoomAvatar.heroes for the DM Details stack.
This commit is contained in:
Doug
2024-06-19 11:27:10 +01:00
committed by GitHub
parent 17d96b0f4b
commit 834260c144
55 changed files with 524 additions and 198 deletions
@@ -8056,6 +8056,11 @@ class RoomProxyMock: RoomProxyProtocol {
var underlyingOwnUserID: String!
var name: String?
var topic: String?
var avatar: RoomAvatar {
get { return underlyingAvatar }
set(value) { underlyingAvatar = value }
}
var underlyingAvatar: RoomAvatar!
var avatarURL: URL?
var membersPublisher: CurrentValuePublisher<[RoomMemberProxyProtocol], Never> {
get { return underlyingMembersPublisher }
@@ -10733,6 +10733,71 @@ open class RoomSDKMock: MatrixRustSDK.Room {
}
}
//MARK: - heroes
var heroesUnderlyingCallsCount = 0
open var heroesCallsCount: Int {
get {
if Thread.isMainThread {
return heroesUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = heroesUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
heroesUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
heroesUnderlyingCallsCount = newValue
}
}
}
}
open var heroesCalled: Bool {
return heroesCallsCount > 0
}
var heroesUnderlyingReturnValue: [RoomHero]!
open var heroesReturnValue: [RoomHero]! {
get {
if Thread.isMainThread {
return heroesUnderlyingReturnValue
} else {
var returnValue: [RoomHero]? = nil
DispatchQueue.main.sync {
returnValue = heroesUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
heroesUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
heroesUnderlyingReturnValue = newValue
}
}
}
}
open var heroesClosure: (() -> [RoomHero])?
open override func heroes() -> [RoomHero] {
heroesCallsCount += 1
if let heroesClosure = heroesClosure {
return heroesClosure()
} else {
return heroesReturnValue
}
}
//MARK: - id
var idUnderlyingCallsCount = 0
@@ -54,6 +54,7 @@ extension RoomProxyMock {
id = configuration.id
name = configuration.name
topic = configuration.topic
avatar = .room(id: configuration.id, name: configuration.name, avatarURL: configuration.avatarURL) // Note: This doesn't replicate the real proxy logic.
avatarURL = configuration.avatarURL
isDirect = configuration.isDirect
isSpace = configuration.isSpace
@@ -84,6 +84,7 @@ extension Array where Element == RoomSummary {
name: "Foundation 🔭🪐🌌",
isDirect: false,
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("I do not wish to take the trouble to understand mysticism"),
lastMessageFormattedTimestamp: "14:56",
unreadMessagesCount: 0,
@@ -100,6 +101,7 @@ extension Array where Element == RoomSummary {
name: "Foundation and Empire",
isDirect: false,
avatarURL: URL.picturesDirectory,
heroes: [],
lastMessage: AttributedString("How do you see the Emperor then? You think he keeps office hours?"),
lastMessageFormattedTimestamp: "2:56 PM",
unreadMessagesCount: 2,
@@ -116,6 +118,7 @@ extension Array where Element == RoomSummary {
name: "Second Foundation",
isDirect: false,
avatarURL: nil,
heroes: [],
lastMessage: try? AttributedString(markdown: "He certainly seemed no *mental genius* to me"),
lastMessageFormattedTimestamp: "Some time ago",
unreadMessagesCount: 3,
@@ -132,6 +135,7 @@ extension Array where Element == RoomSummary {
name: "Foundation's Edge",
isDirect: false,
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("There's an archaic measure of time that's called the month"),
lastMessageFormattedTimestamp: "Just now",
unreadMessagesCount: 2,
@@ -148,6 +152,7 @@ extension Array where Element == RoomSummary {
name: "Foundation and Earth",
isDirect: true,
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("Clearly, if Earth is powerful enough to do that, it might also be capable of adjusting minds in order to force belief in its radioactivity"),
lastMessageFormattedTimestamp: "1986",
unreadMessagesCount: 1,
@@ -164,6 +169,7 @@ extension Array where Element == RoomSummary {
name: "Prelude to Foundation",
isDirect: true,
avatarURL: nil,
heroes: [],
lastMessage: AttributedString("Are you groping for the word 'paranoia'?"),
lastMessageFormattedTimestamp: "きょうはじゅういちがつじゅういちにちです",
unreadMessagesCount: 6,
@@ -180,6 +186,7 @@ extension Array where Element == RoomSummary {
name: "Unknown",
isDirect: false,
avatarURL: nil,
heroes: [],
lastMessage: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
@@ -229,6 +236,7 @@ extension Array where Element == RoomSummary {
name: "First room",
isDirect: false,
avatarURL: URL.picturesDirectory,
heroes: [],
lastMessage: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
@@ -245,6 +253,7 @@ extension Array where Element == RoomSummary {
name: "Second room",
isDirect: true,
avatarURL: nil,
heroes: [],
lastMessage: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
@@ -17,28 +17,9 @@
import SwiftUI
struct AvatarHeaderView<Footer: View>: View {
private struct AvatarInfo {
let id: String
let name: String?
let avatarURL: URL?
init(from room: RoomDetails) {
id = room.id
name = room.name
avatarURL = room.avatarURL
}
init(from member: RoomMemberDetails) {
id = member.id
name = member.isBanned ? nil : member.name
avatarURL = member.isBanned ? nil : member.avatarURL
}
init(from user: UserProfileProxy) {
id = user.userID
name = user.displayName
avatarURL = user.avatarURL
}
private enum AvatarInfo {
case room(RoomAvatar)
case user(UserProfileProxy)
}
private enum Badge: Hashable {
@@ -46,8 +27,8 @@ struct AvatarHeaderView<Footer: View>: View {
case `public`
}
private let mainAvatarInfo: AvatarInfo
private let secondaryAvatarInfo: AvatarInfo?
private let avatarInfo: AvatarInfo
private let title: String
private let subtitle: String?
private let badges: [Badge]
@@ -61,8 +42,8 @@ struct AvatarHeaderView<Footer: View>: View {
imageProvider: ImageProviderProtocol? = nil,
onAvatarTap: (() -> Void)? = nil,
@ViewBuilder footer: @escaping () -> Footer) {
mainAvatarInfo = .init(from: room)
secondaryAvatarInfo = nil
avatarInfo = .room(room.avatar)
title = room.name ?? room.id
subtitle = room.canonicalAlias
self.avatarSize = avatarSize
@@ -83,9 +64,10 @@ struct AvatarHeaderView<Footer: View>: View {
imageProvider: ImageProviderProtocol? = nil,
onAvatarTap: (() -> Void)? = nil,
@ViewBuilder footer: @escaping () -> Footer) {
mainAvatarInfo = .init(from: dmRecipient)
secondaryAvatarInfo = .init(from: accountOwner)
subtitle = dmRecipient.isBanned ? nil : dmRecipient.name == nil ? nil : dmRecipient.id
let dmRecipientProfile = UserProfileProxy(member: dmRecipient)
avatarInfo = .room(.heroes([dmRecipientProfile, UserProfileProxy(member: accountOwner)]))
title = dmRecipientProfile.displayName ?? dmRecipientProfile.userID
subtitle = dmRecipientProfile.displayName == nil ? nil : dmRecipientProfile.userID
avatarSize = .user(on: .dmDetails)
self.imageProvider = imageProvider
@@ -100,15 +82,13 @@ struct AvatarHeaderView<Footer: View>: View {
imageProvider: ImageProviderProtocol? = nil,
onAvatarTap: (() -> Void)? = nil,
@ViewBuilder footer: @escaping () -> Footer) {
mainAvatarInfo = .init(from: member)
secondaryAvatarInfo = nil
subtitle = member.isBanned ? nil : member.name == nil ? nil : member.id
let profile = UserProfileProxy(member: member)
self.avatarSize = avatarSize
self.imageProvider = imageProvider
self.onAvatarTap = onAvatarTap
self.footer = footer
badges = []
self.init(user: profile,
avatarSize: avatarSize,
imageProvider: imageProvider,
onAvatarTap: onAvatarTap,
footer: footer)
}
init(user: UserProfileProxy,
@@ -116,8 +96,8 @@ struct AvatarHeaderView<Footer: View>: View {
imageProvider: ImageProviderProtocol? = nil,
onAvatarTap: (() -> Void)? = nil,
@ViewBuilder footer: @escaping () -> Footer) {
mainAvatarInfo = .init(from: user)
secondaryAvatarInfo = nil
avatarInfo = .user(user)
title = user.displayName ?? user.userID
subtitle = user.displayName == nil ? nil : user.userID
self.avatarSize = avatarSize
@@ -152,40 +132,15 @@ struct AvatarHeaderView<Footer: View>: View {
@ViewBuilder
private var avatar: some View {
if let secondaryAvatarInfo {
ZStack {
LoadableAvatarImage(url: mainAvatarInfo.avatarURL,
name: mainAvatarInfo.name,
contentID: mainAvatarInfo.id,
avatarSize: avatarSize,
imageProvider: imageProvider)
.scaledFrame(size: 120, alignment: .topTrailing)
LoadableAvatarImage(url: secondaryAvatarInfo.avatarURL,
name: secondaryAvatarInfo.name,
contentID: secondaryAvatarInfo.id,
avatarSize: avatarSize,
imageProvider: imageProvider)
.mask {
Rectangle()
.fill(Color.white)
.overlay {
Circle()
.inset(by: -4)
.fill(Color.black)
.scaledOffset(x: 120 - avatarSize.value,
y: -120 + avatarSize.value)
}
.compositingGroup()
.luminanceToAlpha()
}
.scaledFrame(size: 120, alignment: .bottomLeading)
}
.scaledFrame(size: 120)
} else {
LoadableAvatarImage(url: mainAvatarInfo.avatarURL,
name: mainAvatarInfo.name,
contentID: mainAvatarInfo.id,
switch avatarInfo {
case .room(let roomAvatar):
RoomAvatarImage(avatar: roomAvatar,
avatarSize: avatarSize,
imageProvider: imageProvider)
case .user(let userProfile):
LoadableAvatarImage(url: userProfile.avatarURL,
name: userProfile.displayName,
contentID: userProfile.userID,
avatarSize: avatarSize,
imageProvider: imageProvider)
}
@@ -203,7 +158,7 @@ struct AvatarHeaderView<Footer: View>: View {
Spacer()
.frame(height: 9)
Text(mainAvatarInfo.name ?? mainAvatarInfo.id)
Text(title)
.foregroundColor(.compound.textPrimary)
.font(.compound.headingMDBold)
.multilineTextAlignment(.center)
@@ -237,7 +192,9 @@ struct AvatarHeaderView_Previews: PreviewProvider, TestablePreview {
Form {
AvatarHeaderView(room: .init(id: "@test:matrix.org",
name: "Test Room",
avatarURL: URL.picturesDirectory,
avatar: .room(id: "@test:matrix.org",
name: "Test Room",
avatarURL: .picturesDirectory),
canonicalAlias: "#test:matrix.org",
isEncrypted: true,
isPublic: true),
@@ -0,0 +1,125 @@
//
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
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.
case room(id: String, name: String?, avatarURL: URL?)
/// An avatar generated from the room's heroes.
case heroes([UserProfileProxy])
}
/// 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: AvatarSize
let imageProvider: ImageProviderProtocol?
var body: some View {
switch avatar {
case .room(let id, let name, let avatarURL):
LoadableAvatarImage(url: avatarURL,
name: name,
contentID: id,
avatarSize: avatarSize,
imageProvider: imageProvider)
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: nil)
} 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,
imageProvider: imageProvider)
.scaledFrame(size: clusterSize, alignment: .topTrailing)
LoadableAvatarImage(url: users[1].avatarURL,
name: users[1].displayName,
contentID: users[1].userID,
avatarSize: avatarSize,
imageProvider: imageProvider)
.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,
imageProvider: imageProvider)
}
}
}
}
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),
imageProvider: MockMediaProvider())
RoomAvatarImage(avatar: .room(id: "!2:server.com",
name: "Room",
avatarURL: .picturesDirectory),
avatarSize: .room(on: .home),
imageProvider: MockMediaProvider())
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com",
displayName: "User",
avatarURL: nil)]),
avatarSize: .room(on: .home),
imageProvider: MockMediaProvider())
RoomAvatarImage(avatar: .heroes([.init(userID: "@user:server.com",
displayName: "User",
avatarURL: .picturesDirectory)]),
avatarSize: .room(on: .home),
imageProvider: MockMediaProvider())
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),
imageProvider: MockMediaProvider())
}
}
}
@@ -41,5 +41,5 @@ struct GlobalSearchRoom: Identifiable, Equatable {
let id: String
let name: String
let alias: String?
let avatarURL: URL?
let avatar: RoomAvatar
}
@@ -81,7 +81,7 @@ class GlobalSearchScreenViewModel: GlobalSearchScreenViewModelType, GlobalSearch
return GlobalSearchRoom(id: details.id,
name: details.name,
alias: details.canonicalAlias,
avatarURL: details.avatarURL)
avatar: details.avatar)
}
}
}
@@ -35,11 +35,9 @@ struct GlobalSearchScreenListRow: View {
@ViewBuilder @MainActor
var avatar: some View {
if dynamicTypeSize < .accessibility3 {
LoadableAvatarImage(url: room.avatarURL,
name: room.name,
contentID: room.id,
avatarSize: .room(on: .messageForwarding),
imageProvider: context.imageProvider)
RoomAvatarImage(avatar: room.avatar,
avatarSize: .room(on: .messageForwarding),
imageProvider: context.imageProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
@@ -55,7 +53,9 @@ struct GlobalSearchScreenListRow_Previews: PreviewProvider, TestablePreview {
GlobalSearchScreenListRow(room: .init(id: "123",
name: "Tech central",
alias: "The best place in the whole wide world",
avatarURL: .picturesDirectory),
avatar: .room(id: "123",
name: "Tech central",
avatarURL: .picturesDirectory)),
context: viewModel.context)
}
}
@@ -157,7 +157,7 @@ struct HomeScreenRoom: Identifiable, Equatable {
let id: String
/// The real room identifier this item points to
let roomId: String?
let roomID: String?
let type: RoomType
@@ -181,7 +181,7 @@ struct HomeScreenRoom: Identifiable, Equatable {
let lastMessage: AttributedString?
let avatarURL: URL?
let avatar: RoomAvatar
let inviter: InviterDetails?
@@ -189,7 +189,7 @@ struct HomeScreenRoom: Identifiable, Equatable {
static func placeholder() -> HomeScreenRoom {
HomeScreenRoom(id: UUID().uuidString,
roomId: nil,
roomID: nil,
type: .placeholder,
badges: .init(isDotShown: false, isMentionShown: false, isMuteShown: false, isCallShown: false),
name: "Placeholder room name",
@@ -198,7 +198,7 @@ struct HomeScreenRoom: Identifiable, Equatable {
isFavourite: false,
timestamp: "Now",
lastMessage: placeholderLastMessage,
avatarURL: nil,
avatar: .room(id: "", name: "", avatarURL: nil),
inviter: nil,
canonicalAlias: nil)
}
@@ -224,7 +224,7 @@ extension HomeScreenRoom {
}
self.init(id: identifier,
roomId: details.id,
roomID: details.id,
type: details.isInvite ? .invite : .room,
badges: .init(isDotShown: isDotShown,
isMentionShown: isMentionShown,
@@ -236,7 +236,7 @@ extension HomeScreenRoom {
isFavourite: details.isFavourite,
timestamp: details.lastMessageFormattedTimestamp,
lastMessage: details.lastMessage,
avatarURL: details.avatarURL,
avatar: details.avatar,
inviter: inviter,
canonicalAlias: details.canonicalAlias)
}
@@ -15,6 +15,7 @@
//
import Combine
import Compound
import SwiftUI
@MainActor
@@ -27,11 +28,9 @@ struct HomeScreenInviteCell: View {
var body: some View {
HStack(alignment: .top, spacing: 16) {
if dynamicTypeSize < .accessibility3 {
LoadableAvatarImage(url: room.avatarURL,
name: title,
contentID: room.id,
avatarSize: .custom(52),
imageProvider: context.imageProvider)
RoomAvatarImage(avatar: room.avatar,
avatarSize: .custom(52),
imageProvider: context.imageProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
@@ -48,8 +47,8 @@ struct HomeScreenInviteCell: View {
.padding(.top, 12)
.padding(.leading, 16)
.onTapGesture {
if let roomId = room.roomId {
context.send(viewAction: .selectRoom(roomIdentifier: roomId))
if let roomID = room.roomID {
context.send(viewAction: .selectRoom(roomIdentifier: roomID))
}
}
}
@@ -214,6 +213,7 @@ private extension HomeScreenRoom {
name: "Some Guy",
isDirect: true,
avatarURL: nil,
heroes: [.init(userID: "@someone:somewhere.com")],
lastMessage: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
@@ -240,6 +240,7 @@ private extension HomeScreenRoom {
name: "Awesome Room",
isDirect: false,
avatarURL: avatarURL,
heroes: [.init(userID: "@someone:somewhere.com")],
lastMessage: nil,
lastMessageFormattedTimestamp: nil,
unreadMessagesCount: 0,
@@ -31,8 +31,8 @@ struct HomeScreenRoomCell: View {
var body: some View {
Button {
if let roomId = room.roomId {
context.send(viewAction: .selectRoom(roomIdentifier: roomId))
if let roomID = room.roomID {
context.send(viewAction: .selectRoom(roomIdentifier: roomID))
}
} label: {
HStack(spacing: 16.0) {
@@ -57,11 +57,9 @@ struct HomeScreenRoomCell: View {
@ViewBuilder @MainActor
private var avatar: some View {
if dynamicTypeSize < .accessibility3 {
LoadableAvatarImage(url: room.avatarURL,
name: room.name,
contentID: room.roomId,
avatarSize: .room(on: .home),
imageProvider: context.imageProvider)
RoomAvatarImage(avatar: room.avatar,
avatarSize: .room(on: .home),
imageProvider: context.imageProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
@@ -38,6 +38,14 @@ struct JoinRoomScreenViewState: BindableState {
var mode: JoinRoomScreenInteractionMode = .loading
var bindings = JoinRoomScreenViewStateBindings()
var title: String {
roomDetails?.name ?? L10n.screenJoinRoomTitleNoPreview
}
var avatar: RoomAvatar {
.room(id: roomID, name: title, avatarURL: roomDetails?.avatarURL)
}
}
struct JoinRoomScreenViewStateBindings {
@@ -40,17 +40,13 @@ struct JoinRoomScreen: View {
var mainContent: some View {
VStack(spacing: 16) {
let title = context.viewState.roomDetails?.name ?? L10n.screenJoinRoomTitleNoPreview
LoadableAvatarImage(url: context.viewState.roomDetails?.avatarURL,
name: title,
contentID: context.viewState.roomID,
avatarSize: .room(on: .joinRoom),
imageProvider: context.imageProvider)
RoomAvatarImage(avatar: context.viewState.avatar,
avatarSize: .room(on: .joinRoom),
imageProvider: context.imageProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
VStack(spacing: 8) {
Text(title)
Text(context.viewState.title)
.font(.compound.headingMDBold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
@@ -44,7 +44,7 @@ struct MessageForwardingRoom: Identifiable, Equatable {
let id: String
let name: String
let alias: String?
let avatarURL: URL?
let avatar: RoomAvatar
}
struct MessageForwardingItem: Hashable {
@@ -94,7 +94,7 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
continue
}
let room = MessageForwardingRoom(id: details.id, name: details.name, alias: details.canonicalAlias, avatarURL: details.avatarURL)
let room = MessageForwardingRoom(id: details.id, name: details.name, alias: details.canonicalAlias, avatar: details.avatar)
rooms.append(room)
}
}
@@ -88,11 +88,9 @@ private struct MessageForwardingListRow: View {
@ViewBuilder @MainActor
var avatar: some View {
if dynamicTypeSize < .accessibility3 {
LoadableAvatarImage(url: room.avatarURL,
name: room.name,
contentID: room.id,
avatarSize: .room(on: .messageForwarding),
imageProvider: context.imageProvider)
RoomAvatarImage(avatar: room.avatar,
avatarSize: .room(on: .messageForwarding),
imageProvider: context.imageProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
@@ -44,11 +44,9 @@ struct RoomDirectorySearchCell: View {
}
private var avatar: some View {
LoadableAvatarImage(url: result.avatarURL,
name: result.name,
contentID: result.id,
avatarSize: .room(on: .roomDirectorySearch),
imageProvider: imageProvider)
RoomAvatarImage(avatar: result.avatar,
avatarSize: .room(on: .roomDirectorySearch),
imageProvider: imageProvider)
.accessibilityHidden(true)
}
}
@@ -62,7 +60,9 @@ struct RoomDirectorySearchCell_Previews: PreviewProvider, TestablePreview {
alias: "#test:example.com",
name: "Test title",
topic: "test description",
avatarURL: nil,
avatar: .room(id: "!test_id_1:matrix.org",
name: "Test title",
avatarURL: nil),
canBeJoined: true),
imageProvider: MockMediaProvider()) { }
@@ -70,7 +70,9 @@ struct RoomDirectorySearchCell_Previews: PreviewProvider, TestablePreview {
alias: "#test:example.com",
name: nil,
topic: "test description",
avatarURL: nil,
avatar: .room(id: "!test_id_2:matrix.org",
name: nil,
avatarURL: nil),
canBeJoined: true),
imageProvider: MockMediaProvider()) { }
@@ -78,7 +80,9 @@ struct RoomDirectorySearchCell_Previews: PreviewProvider, TestablePreview {
alias: "#test_no_topic:example.com",
name: "Test title no topic",
topic: nil,
avatarURL: nil,
avatar: .room(id: "!test_id_3:example.com",
name: "Test title no topic",
avatarURL: nil),
canBeJoined: true),
imageProvider: MockMediaProvider()) { }
@@ -86,7 +90,9 @@ struct RoomDirectorySearchCell_Previews: PreviewProvider, TestablePreview {
alias: "#test_no_topic:example.com",
name: nil,
topic: nil,
avatarURL: nil,
avatar: .room(id: "!test_id_4:example.com",
name: nil,
avatarURL: nil),
canBeJoined: true),
imageProvider: MockMediaProvider()) { }
@@ -94,7 +100,9 @@ struct RoomDirectorySearchCell_Previews: PreviewProvider, TestablePreview {
alias: nil,
name: "Test title no alias",
topic: nil,
avatarURL: nil,
avatar: .room(id: "!test_id_5:example.com",
name: "Test title no alias",
avatarURL: nil),
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
@@ -102,7 +110,9 @@ struct RoomDirectorySearchCell_Previews: PreviewProvider, TestablePreview {
alias: nil,
name: "Test title no alias",
topic: "Topic",
avatarURL: nil,
avatar: .room(id: "!test_id_6:example.com",
name: "Test title no alias",
avatarURL: nil),
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
@@ -110,15 +120,18 @@ struct RoomDirectorySearchCell_Previews: PreviewProvider, TestablePreview {
alias: nil,
name: nil,
topic: "Topic",
avatarURL: nil,
avatar: .room(id: "!test_id_7:example.com",
name: nil,
avatarURL: nil),
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
RoomDirectorySearchCell(result: .init(id: "!test_id_8:example.com",
alias: nil,
name: nil,
topic: nil,
avatarURL: nil,
avatar: .room(id: "!test_id_8:example.com",
name: nil,
avatarURL: nil),
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
}
@@ -83,13 +83,17 @@ struct RoomDirectorySearchScreen_Previews: PreviewProvider, TestablePreview {
alias: "#test_1:example.com",
name: "Test 1",
topic: "Test description 1",
avatarURL: nil,
avatar: .room(id: "test_1",
name: "Test 1",
avatarURL: nil),
canBeJoined: true),
RoomDirectorySearchResult(id: "test_2",
alias: "#test_2:example.com",
name: "Test 2",
topic: nil,
avatarURL: URL.documentsDirectory,
avatar: .room(id: "test_2",
name: "Test 2",
avatarURL: .documentsDirectory),
canBeJoined: false)]
let roomDirectorySearchProxy = RoomDirectorySearchProxyMock(configuration: .init(results: results))
@@ -144,7 +144,7 @@ enum RoomScreenComposerAction {
struct RoomScreenViewState: BindableState {
var roomID: String
var roomTitle = ""
var roomAvatarURL: URL?
var roomAvatar: RoomAvatar
var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = []
var showLoading = false
@@ -80,9 +80,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
appSettings: appSettings,
analyticsService: analyticsService)
super.init(initialViewState: RoomScreenViewState(roomID: timelineController.roomID,
super.init(initialViewState: RoomScreenViewState(roomID: roomProxy.id,
roomTitle: roomProxy.roomTitle,
roomAvatarURL: roomProxy.avatarURL,
roomAvatar: roomProxy.avatar,
timelineStyle: appSettings.timelineStyle,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
timelineViewState: TimelineViewState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
@@ -389,9 +389,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in
guard let self else { return }
self.state.roomTitle = roomProxy.roomTitle
self.state.roomAvatarURL = roomProxy.avatarURL
self.state.hasOngoingCall = roomProxy.hasOngoingCall
state.roomTitle = roomProxy.roomTitle
state.roomAvatar = roomProxy.avatar
state.hasOngoingCall = roomProxy.hasOngoingCall
}
.store(in: &cancellables)
@@ -15,19 +15,18 @@
//
import Combine
import Foundation
import Compound
import SwiftUI
struct RoomHeaderView: View {
let roomID: String
let roomName: String
let avatarURL: URL?
let roomAvatar: RoomAvatar
let imageProvider: ImageProviderProtocol?
var body: some View {
HStack(spacing: 12) {
roomAvatar
avatarImage
.accessibilityHidden(true)
Text(roomName)
.font(.compound.bodyLGSemibold)
@@ -37,28 +36,28 @@ struct RoomHeaderView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
private var roomAvatar: some View {
LoadableAvatarImage(url: avatarURL,
name: roomName,
contentID: roomID,
avatarSize: .room(on: .timeline),
imageProvider: imageProvider)
private var avatarImage: some View {
RoomAvatarImage(avatar: roomAvatar,
avatarSize: .room(on: .timeline),
imageProvider: imageProvider)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.avatar)
}
}
struct RoomHeaderView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
RoomHeaderView(roomID: "1",
roomName: "Some Room name",
avatarURL: URL.picturesDirectory,
RoomHeaderView(roomName: "Some Room name",
roomAvatar: .room(id: "1",
name: "Some Room Name",
avatarURL: URL.picturesDirectory),
imageProvider: MockMediaProvider())
.previewLayout(.sizeThatFits)
.padding()
RoomHeaderView(roomID: "1",
roomName: "Some Room name",
avatarURL: nil,
RoomHeaderView(roomName: "Some Room name",
roomAvatar: .room(id: "1",
name: "Some Room Name",
avatarURL: nil),
imageProvider: MockMediaProvider())
.previewLayout(.sizeThatFits)
.padding()
@@ -137,9 +137,8 @@ struct RoomScreen: View {
// .principal + .primaryAction works better than .navigation leading + trailing
// as the latter disables interaction in the action button for rooms with long names
ToolbarItem(placement: .principal) {
RoomHeaderView(roomID: context.viewState.roomID,
roomName: context.viewState.roomTitle,
avatarURL: context.viewState.roomAvatarURL,
RoomHeaderView(roomName: context.viewState.roomTitle,
roomAvatar: context.viewState.roomAvatar,
imageProvider: context.imageProvider)
// Using a button stops it from getting truncated in the navigation bar
.onTapGesture {
@@ -184,7 +183,9 @@ struct RoomScreen: View {
// MARK: - Previews
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(name: "Preview room", hasOngoingCall: true)),
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
name: "Preview room",
hasOngoingCall: true)),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -84,7 +84,8 @@ struct TimelineView: UIViewControllerRepresentable {
// MARK: - Previews
struct TimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(name: "Preview room")),
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
name: "Preview room")),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
@@ -110,7 +110,7 @@ struct NotificationSettingsEditScreenRoom: Identifiable, Equatable {
var name = ""
var avatarURL: URL?
var avatar: RoomAvatar
var notificationMode: RoomNotificationModeProxy?
}
@@ -168,7 +168,7 @@ class NotificationSettingsEditScreenViewModel: NotificationSettingsEditScreenVie
return NotificationSettingsEditScreenRoom(id: details.id,
roomId: details.id,
name: details.name,
avatarURL: details.avatarURL,
avatar: details.avatar,
notificationMode: notificationMode)
}
@@ -39,11 +39,9 @@ struct NotificationSettingsEditScreenRoomCell: View {
@ViewBuilder @MainActor
var avatar: some View {
if dynamicTypeSize < .accessibility3 {
LoadableAvatarImage(url: room.avatarURL,
name: room.name,
contentID: room.roomId,
avatarSize: .room(on: .notificationSettings),
imageProvider: context.imageProvider)
RoomAvatarImage(avatar: room.avatar,
avatarSize: .room(on: .notificationSettings),
imageProvider: context.imageProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
@@ -76,7 +74,8 @@ struct NotificationSettingsEditScreenRoomCell_Previews: PreviewProvider, Testabl
case .filled(let details):
return NotificationSettingsEditScreenRoom(id: UUID().uuidString,
roomId: details.id,
name: details.name)
name: details.name,
avatar: details.avatar)
}
}
@@ -19,7 +19,7 @@ import Foundation
struct RoomDetails {
let id: String
let name: String?
let avatarURL: URL?
let avatar: RoomAvatar
let canonicalAlias: String?
let isEncrypted: Bool
let isPublic: Bool
@@ -100,6 +100,18 @@ class RoomProxy: RoomProxyProtocol {
var avatarURL: URL? {
roomListItem.avatarUrl().flatMap(URL.init(string:))
}
var avatar: RoomAvatar {
if isDirect, avatarURL == nil {
let heroes = room.heroes()
if heroes.count == 1 {
return .heroes(heroes.map(UserProfileProxy.init))
}
}
return .room(id: id, name: name, avatarURL: avatarURL)
}
var joinedMembersCount: Int {
Int(room.joinedMembersCount())
@@ -47,12 +47,15 @@ protocol RoomProxyProtocol {
var topic: String? { get }
/// The room's avatar info for use in a ``RoomAvatarImage``.
var avatar: RoomAvatar { get }
/// The room's avatar URL. Use this for editing and favour ``avatar`` for display.
var avatarURL: URL? { get }
var membersPublisher: CurrentValuePublisher<[RoomMemberProxyProtocol], Never> { get }
var typingMembersPublisher: CurrentValuePublisher<[String], Never> { get }
var joinedMembersCount: Int { get }
var activeMembersCount: Int { get }
@@ -146,7 +149,7 @@ extension RoomProxyProtocol {
var details: RoomDetails {
RoomDetails(id: id,
name: name,
avatarURL: avatarURL,
avatar: avatar,
canonicalAlias: canonicalAlias,
isEncrypted: isEncrypted,
isPublic: isPublic)
@@ -26,6 +26,7 @@ struct RoomSummaryDetails {
let name: String
let isDirect: Bool
let avatarURL: URL?
let heroes: [UserProfileProxy]
let lastMessage: AttributedString?
let lastMessageFormattedTimestamp: String?
let unreadMessagesCount: UInt
@@ -64,6 +65,7 @@ extension RoomSummaryDetails {
name = string
isDirect = true
avatarURL = nil
heroes = []
lastMessage = AttributedString(string)
lastMessageFormattedTimestamp = "Now"
unreadMessagesCount = hasUnreadMessages ? 1 : 0
@@ -78,4 +80,12 @@ extension RoomSummaryDetails {
isMarkedUnread = false
isFavourite = false
}
var avatar: RoomAvatar {
if isDirect, avatarURL == nil, heroes.count == 1 {
.heroes(heroes)
} else {
.room(id: id, name: name, avatarURL: avatarURL)
}
}
}
@@ -252,6 +252,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
name: roomInfo.displayName ?? roomInfo.id,
isDirect: roomInfo.isDirect,
avatarURL: roomInfo.avatarUrl.flatMap(URL.init(string:)),
heroes: roomInfo.heroes.map(UserProfileProxy.init),
lastMessage: attributedLastMessage,
lastMessageFormattedTimestamp: lastMessageFormattedTimestamp,
unreadMessagesCount: UInt(roomInfo.numUnreadMessages),
@@ -154,7 +154,7 @@ final class RoomDirectorySearchProxy: RoomDirectorySearchProxyProtocol {
alias: value.alias,
name: value.name,
topic: value.topic,
avatarURL: value.avatarUrl.flatMap(URL.init(string:)),
avatar: .room(id: value.roomId, name: value.name, avatarURL: value.avatarUrl.flatMap(URL.init(string:))),
canBeJoined: value.joinRule == .public)
}
}
@@ -34,6 +34,6 @@ struct RoomDirectorySearchResult: Identifiable {
let alias: String?
let name: String?
let topic: String?
let avatarURL: URL?
let avatar: RoomAvatar
let canBeJoined: Bool
}
@@ -28,12 +28,24 @@ struct UserProfileProxy: Equatable, Hashable {
self.avatarURL = avatarURL
}
init(member: RoomMemberDetails) {
userID = member.id
displayName = member.isBanned ? nil : member.name
avatarURL = member.isBanned ? nil : member.avatarURL
}
init(sdkUserProfile: MatrixRustSDK.UserProfile) {
userID = sdkUserProfile.userId
displayName = sdkUserProfile.displayName
avatarURL = sdkUserProfile.avatarUrl.flatMap(URL.init(string:))
}
init(sdkRoomHero: MatrixRustSDK.RoomHero) {
userID = sdkRoomHero.userId
displayName = sdkRoomHero.displayName
avatarURL = sdkRoomHero.avatarUrl.flatMap(URL.init(string:))
}
/// A user is meant to be "verified" when the GET profile returns back either the display name or the avatar
/// If isn't we aren't sure that the related matrix id really exists.
var isVerified: Bool {