diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 1f5998cb9..4df72a647 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -294,6 +294,7 @@ "common_no_space_name" = "No space name"; "common_not_encrypted" = "Not encrypted"; "common_offline" = "Offline"; +"common_on_a_call" = "On a call"; "common_open_source_licenses" = "Open source licenses"; "common_optic_id_ios" = "Optic ID"; "common_or" = "or"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index ea95d057c..75e2c4de9 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -294,6 +294,7 @@ "common_no_space_name" = "No space name"; "common_not_encrypted" = "Not encrypted"; "common_offline" = "Offline"; +"common_on_a_call" = "On a call"; "common_open_source_licenses" = "Open source licenses"; "common_optic_id_ios" = "Optic ID"; "common_or" = "or"; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index f92545655..2cf564692 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -644,6 +644,8 @@ internal nonisolated enum L10n { internal static var commonNotEncrypted: String { return L10n.tr("Localizable", "common_not_encrypted") } /// Offline internal static var commonOffline: String { return L10n.tr("Localizable", "common_offline") } + /// On a call + internal static var commonOnACall: String { return L10n.tr("Localizable", "common_on_a_call") } /// Open source licenses internal static var commonOpenSourceLicenses: String { return L10n.tr("Localizable", "common_open_source_licenses") } /// Optic ID diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 20f00408c..663667b30 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -10045,6 +10045,11 @@ nonisolated class RoomMemberProxyMock: RoomMemberProxyProtocol, @unchecked Senda nonisolated(unsafe) var displayName: String? nonisolated(unsafe) var disambiguatedDisplayName: String? nonisolated(unsafe) var avatarURL: URL? + var status: UserStatus { + get { return underlyingStatus } + set(value) { underlyingStatus = value } + } + nonisolated(unsafe) var underlyingStatus: UserStatus! var membership: MembershipState { get { return underlyingMembership } set(value) { underlyingMembership = value } diff --git a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift index f69ea522d..bd3c65a26 100644 --- a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift @@ -13,6 +13,7 @@ nonisolated struct RoomMemberProxyMockConfiguration { var userID: String var displayName: String? var avatarURL: URL? + var status = UserStatus() var membership: MembershipState var isIgnored = false @@ -23,14 +24,12 @@ nonisolated struct RoomMemberProxyMockConfiguration { extension RoomMemberProxyMock { convenience init(with configuration: RoomMemberProxyMockConfiguration) { self.init() + userID = configuration.userID displayName = configuration.displayName - - if let displayName = configuration.displayName { - disambiguatedDisplayName = "\(displayName) (\(userID))" - } - + disambiguatedDisplayName = configuration.displayName.map { "\($0) (\(userID))" } avatarURL = configuration.avatarURL + status = configuration.status membership = configuration.membership isIgnored = configuration.isIgnored @@ -93,6 +92,20 @@ extension RoomMemberProxyMock { membership: .join)) } + static var mockErin: RoomMemberProxyMock { + RoomMemberProxyMock(with: .init(userID: "@erin:matrix.org", + displayName: "Erin", + status: .mockFocussing, + membership: .join)) + } + + static var mockFrank: RoomMemberProxyMock { + RoomMemberProxyMock(with: .init(userID: "@frank:matrix.org", + displayName: "Frank", + status: .mockCall, + membership: .join)) + } + static var mockVerbose: RoomMemberProxyMock { RoomMemberProxyMock(with: .init(userID: "@charliev:matrix.org", displayName: "Charlie is the best display name", diff --git a/ElementX/Sources/Mocks/UserProfile+Mock.swift b/ElementX/Sources/Mocks/UserProfile+Mock.swift index 71f4a953d..5a80d2922 100644 --- a/ElementX/Sources/Mocks/UserProfile+Mock.swift +++ b/ElementX/Sources/Mocks/UserProfile+Mock.swift @@ -9,7 +9,6 @@ import Foundation extension UserProfile { - /// Mocks static var mockAlice: UserProfile { .init(userID: "@alice:matrix.org", displayName: "Alice", avatarURL: "mxc://matrix.org/UcCimidcvpFvWkPzvjXMQPHA") } @@ -30,7 +29,33 @@ extension UserProfile { .init(userID: "@dan:matrix.org", displayName: "Dan", avatarURL: .mockMXCUserAvatar) } + static var mockErin: UserProfile { + .init(userID: "@erin:matrix.org", displayName: "Erin", status: .mockFocussing) + } + + static var mockFrank: UserProfile { + .init(userID: "@frank:matrix.org", displayName: "Frank", status: .mockCall) + } + static var mockVerbose: UserProfile { .init(userID: "@charlie:matrix.org", displayName: "Charlie is the best display name", avatarURL: nil) } } + +nonisolated extension UserStatus { + static var mockCall: UserStatus { + .init(userSet: nil, call: .init(startDate: nil)) + } + + static var mockFocussing: UserStatus { + .init(userSet: .init(text: "Focussing", emoji: "🧑‍💻"), call: nil) + } + + static var mockHoliday: UserStatus { + .init(userSet: .init(text: "Holiday", emoji: "🏝️"), call: nil) + } + + static func mock(text: String, emoji: Character) -> UserStatus { + .init(userSet: .init(text: text, emoji: emoji), call: nil) + } +} diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberDetails.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberDetails.swift index 096ac9161..192d25e03 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberDetails.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberDetails.swift @@ -13,6 +13,8 @@ nonisolated struct RoomMemberDetails: Identifiable, Hashable { let id: String let name: String? let avatarURL: URL? + let status: UserStatus + let permalink: URL? var isInvited: Bool @@ -34,6 +36,7 @@ nonisolated extension RoomMemberDetails { id = proxy.userID name = proxy.displayName avatarURL = proxy.avatarURL + status = proxy.status permalink = proxy.permalink isActive = proxy.isActive isInvited = proxy.membership == .invite diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift index f0eb7508f..397ed821f 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift @@ -36,6 +36,10 @@ final nonisolated class RoomMemberProxy: RoomMemberProxyProtocol { member.avatarUrl.flatMap(URL.init(string:)) } + var status: UserStatus { + UserStatus() + } + var membership: MembershipState { member.membership } diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift index de250e3f6..f889e16c9 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift @@ -15,8 +15,8 @@ nonisolated protocol RoomMemberProxyProtocol: AnyObject, Sendable { var displayName: String? { get } var disambiguatedDisplayName: String? { get } - var avatarURL: URL? { get } + var status: UserStatus { get } var membership: MembershipState { get } diff --git a/ElementX/Sources/Services/Timeline/TimelineItemSender.swift b/ElementX/Sources/Services/Timeline/TimelineItemSender.swift index 98ca01305..a4c70b1f0 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemSender.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemSender.swift @@ -16,12 +16,18 @@ nonisolated struct TimelineItemSender: Identifiable, Hashable { let displayName: String? let isDisplayNameAmbiguous: Bool let avatarURL: URL? + let status: UserStatus - init(id: String, displayName: String? = nil, isDisplayNameAmbiguous: Bool = false, avatarURL: URL? = nil) { + init(id: String, + displayName: String? = nil, + isDisplayNameAmbiguous: Bool = false, + avatarURL: URL? = nil, + status: UserStatus = .init()) { self.id = id self.displayName = displayName self.isDisplayNameAmbiguous = isDisplayNameAmbiguous self.avatarURL = avatarURL + self.status = status } init(senderID: String, senderProfile: ProfileDetails) { diff --git a/ElementX/Sources/Services/Users/UserProfile.swift b/ElementX/Sources/Services/Users/UserProfile.swift index a9ff9905e..204add17b 100644 --- a/ElementX/Sources/Services/Users/UserProfile.swift +++ b/ElementX/Sources/Services/Users/UserProfile.swift @@ -13,35 +13,41 @@ nonisolated struct UserProfile: Hashable, Identifiable { let id: String let displayName: String? let avatarURL: URL? + let status: UserStatus - init(userID: String, displayName: String? = nil, avatarURL: URL? = nil) { + init(userID: String, displayName: String? = nil, avatarURL: URL? = nil, status: UserStatus = .init()) { id = userID self.displayName = displayName self.avatarURL = avatarURL + self.status = status } init(member: RoomMemberDetails) { id = member.id displayName = member.isBanned ? nil : member.name avatarURL = member.isBanned ? nil : member.avatarURL + status = member.status } init(sender: TimelineItemSender) { id = sender.id displayName = sender.displayName avatarURL = sender.avatarURL + status = sender.status } init(sdkUserProfile: MatrixRustSDK.UserProfile) { id = sdkUserProfile.userId displayName = sdkUserProfile.displayName avatarURL = sdkUserProfile.avatarUrl.flatMap(URL.init(string:)) + status = .init() } init(sdkRoomHero: MatrixRustSDK.RoomHero) { id = sdkRoomHero.userId displayName = sdkRoomHero.displayName avatarURL = sdkRoomHero.avatarUrl.flatMap(URL.init(string:)) + status = .init() } init(member: RoomMemberProxyProtocol) { @@ -55,6 +61,53 @@ nonisolated struct UserProfile: Hashable, Identifiable { } } +nonisolated struct UserStatus: Hashable { + /// The status manually set by the user (if any). + let userSet: UserSet? + /// The status automatically set by Element Call (while in a call). + let call: Call? + + /// The status that should be displayed on the user's profile. + var displayed: DisplayedStatus? { + // The user-set status takes precedence over the call indicator. + userSet.map { .userSet($0) } ?? call.map { .inCall($0) } + } + + nonisolated struct UserSet: Hashable { + let text: String + let emoji: Character + } + + nonisolated struct Call: Hashable { + let startDate: Date? + } + + nonisolated enum DisplayedStatus: Hashable { + case userSet(UserSet) + case inCall(Call) + + var text: String { + switch self { + case .userSet(let userSet): userSet.text + case .inCall: L10n.commonOnACall + } + } + + var emoji: Character { + switch self { + case .userSet(let userSet): userSet.emoji + case .inCall: "🎧" + } + } + } +} + +nonisolated extension UserStatus { + init() { + self.init(userSet: nil, call: nil) + } +} + struct SearchUsersResults { let results: [UserProfile] let limited: Bool