diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index ea63ed8776..f1540e2be6 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -595,6 +595,7 @@ 899359A4D1147601F6C4E364 /* PillConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB8D34E94AB07128DB73D6C7 /* PillConstants.swift */; }; 899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA60F848D1C14F873F9621A /* RoomMemberDetailsScreenCoordinator.swift */; }; 8A0BD60CA4A6004DB06B5403 /* MediaUploadingPreprocessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669F35C505ACE1110589F875 /* MediaUploadingPreprocessor.swift */; }; + 8A83D715940378B9BA9F739E /* RoomInviterLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */; }; 8AA84EF202F2EFC8453A97BD /* SecureBackupRecoveryKeyScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E027C112740573D27765C /* SecureBackupRecoveryKeyScreenModels.swift */; }; 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5E9C044BEB7C70B1378E91 /* UserSession.swift */; }; 8AC256AF0EC54658321C9241 /* LegalInformationScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5725BC6C63604CB769145B /* LegalInformationScreenViewModelTests.swift */; }; @@ -1667,6 +1668,7 @@ 7DDBF99755A9008CF8C8499E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 7DDF49CEBC0DFC59C308335F /* RoomMemberDetailsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberDetailsScreenViewModelProtocol.swift; sourceTree = ""; }; 7E492690C8B27A892C194CC4 /* AdvancedSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsScreenCoordinator.swift; sourceTree = ""; }; + 7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomInviterLabel.swift; sourceTree = ""; }; 7EC2F1622C5BBABED6012E12 /* HeroImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeroImage.swift; sourceTree = ""; }; 7EECE8B331CD169790EF284F /* BugReportScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModelTests.swift; sourceTree = ""; }; 7F615A00DB223FF3280204D2 /* UserDiscoveryServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoveryServiceProtocol.swift; sourceTree = ""; }; @@ -2792,6 +2794,7 @@ 648DD1C10E4957CB791FE0B8 /* OverridableAvatarImage.swift */, C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */, BEF5FE93A06F563B477F024A /* RoomAvatarImage.swift */, + 7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */, 839E2C35DF3F9C7B54C3CE49 /* RoundedCornerShape.swift */, DE7C80EF77AD102053D3646E /* RoundedLabelItem.swift */, AEB5FF7A09B79B0C6B528F7C /* SFNumberedListView.swift */, @@ -6445,6 +6448,7 @@ 42F1C8731166633E35A6D7E6 /* RoomEventStringBuilder.swift in Sources */, D55AF9B5B55FEED04771A461 /* RoomFlowCoordinator.swift in Sources */, 04A16B45228F7678A027C079 /* RoomHeaderView.swift in Sources */, + 8A83D715940378B9BA9F739E /* RoomInviterLabel.swift in Sources */, F4996C82A4B3A5FF0C8EDD03 /* RoomListFilterModels.swift in Sources */, 4A9CEEE612D6D8B3DDBD28BA /* RoomListFilterView.swift in Sources */, BD0BE20DBCE31253AE4490A1 /* RoomListFiltersEmptyStateView.swift in Sources */, diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index f989f22f70..f8b228ec2f 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -8273,6 +8273,23 @@ class RoomProxyMock: RoomProxyProtocol { set(value) { underlyingMembership = value } } var underlyingMembership: Membership! + var inviterCallsCount = 0 + var inviterCalled: Bool { + return inviterCallsCount > 0 + } + + var inviter: RoomMemberProxyProtocol? { + get async { + inviterCallsCount += 1 + if let inviterClosure = inviterClosure { + return await inviterClosure() + } else { + return underlyingInviter + } + } + } + var underlyingInviter: RoomMemberProxyProtocol? + var inviterClosure: (() async -> RoomMemberProxyProtocol?)? var hasOngoingCall: Bool { get { return underlyingHasOngoingCall } set(value) { underlyingHasOngoingCall = value } diff --git a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift index 2523ee0fda..4fb81d4e4d 100644 --- a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift @@ -97,6 +97,11 @@ extension RoomMemberProxyMock { membership: .join)) } + static var mockNoName: RoomMemberProxyMock { + RoomMemberProxyMock(with: .init(userID: "@anonymous:matrix.org", + membership: .join)) + } + static var mockInvited: RoomMemberProxyMock { RoomMemberProxyMock(with: .init(userID: "@invited:matrix.org", displayName: "Invited", diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index 85fc38e886..decb0df185 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -35,6 +35,7 @@ struct RoomProxyMockConfiguration { var members: [RoomMemberProxyMock] = .allMembers var ownUserID = RoomMemberProxyMock.mockMe.userID + var inviter: RoomMemberProxyProtocol? var canUserInvite = true var canUserTriggerRoomNotification = false @@ -85,6 +86,7 @@ extension RoomProxyMock { ownUserID = configuration.ownUserID membership = .joined + inviterClosure = { configuration.inviter } membersPublisher = CurrentValueSubject(configuration.members).asCurrentValuePublisher() typingMembersPublisher = CurrentValueSubject([]).asCurrentValuePublisher() diff --git a/ElementX/Sources/Other/SwiftUI/Views/RoomInviterLabel.swift b/ElementX/Sources/Other/SwiftUI/Views/RoomInviterLabel.swift new file mode 100644 index 0000000000..17ad05bb81 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Views/RoomInviterLabel.swift @@ -0,0 +1,85 @@ +// +// 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 + +struct RoomInviterDetails: Equatable { + let id: String + let displayName: String? + let avatarURL: URL? + + let attributedInviteText: AttributedString + + init(member: RoomMemberProxyProtocol) { + id = member.userID + displayName = member.displayName + avatarURL = member.avatarURL + + let nameOrLocalPart = if let displayName = member.displayName { + displayName + } else { + String(member.userID.dropFirst().prefix { $0 != ":" }) + } + + // Pre-compute the attributed string. + let placeholder = "{displayname}" + var string = AttributedString(L10n.screenInvitesInvitedYou(placeholder, id)) + var displayNameString = AttributedString(nameOrLocalPart) + displayNameString.bold() + displayNameString.foregroundColor = .compound.textPrimary + string.replace(placeholder, with: displayNameString) + attributedInviteText = string + } +} + +struct RoomInviterLabel: View { + let inviter: RoomInviterDetails + + let imageProvider: ImageProviderProtocol? + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + LoadableAvatarImage(url: inviter.avatarURL, + name: inviter.displayName, + contentID: inviter.id, + avatarSize: .custom(16), + imageProvider: imageProvider) + .alignmentGuide(.firstTextBaseline) { $0[.bottom] * 0.8 } + + Text(inviter.attributedInviteText) + } + } +} + +// MARK: - Previews + +struct RoomInviterLabel_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VStack(spacing: 10) { + RoomInviterLabel(inviter: .init(member: RoomMemberProxyMock.mockAlice), + imageProvider: MockMediaProvider()) + RoomInviterLabel(inviter: .init(member: RoomMemberProxyMock.mockDan), + imageProvider: MockMediaProvider()) + RoomInviterLabel(inviter: .init(member: RoomMemberProxyMock.mockNoName), + imageProvider: MockMediaProvider()) + RoomInviterLabel(inviter: .init(member: RoomMemberProxyMock.mockCharlie), + imageProvider: MockMediaProvider()) + .foregroundStyle(.compound.textPrimary) + } + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + } +} diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 93e3fb5f65..b267d9cf94 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -144,12 +144,6 @@ struct HomeScreenRoom: Identifiable, Equatable { case invite } - struct InviterDetails: Equatable { - let userID: String - let displayName: String? - let avatarURL: URL? - } - static let placeholderLastMessage = AttributedString("Hidden last message") /// The list item identifier is it's room identifier. @@ -182,7 +176,7 @@ struct HomeScreenRoom: Identifiable, Equatable { let avatar: RoomAvatar - let inviter: InviterDetails? + let inviter: RoomInviterDetails? let canonicalAlias: String? @@ -215,12 +209,7 @@ extension HomeScreenRoom { let isCallShown = summary.hasOngoingCall let isHighlighted = summary.isMarkedUnread || (!summary.isMuted && (summary.hasUnreadNotifications || summary.hasUnreadMentions)) - var inviter: InviterDetails? - if let roomMemberProxy = summary.inviter { - inviter = .init(userID: roomMemberProxy.userID, - displayName: roomMemberProxy.displayName, - avatarURL: roomMemberProxy.avatarURL) - } + let inviter = summary.inviter.map(RoomInviterDetails.init) self.init(id: identifier, roomID: summary.id, diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift index 3dc83f9a35..b3140b3032 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift @@ -74,17 +74,10 @@ struct HomeScreenInviteCell: View { @ViewBuilder private var inviterView: some View { - if let invitedText = attributedInviteText, let name = room.inviter?.displayName { - HStack(alignment: .firstTextBaseline, spacing: 8) { - LoadableAvatarImage(url: room.inviter?.avatarURL, - name: name, - contentID: name, - avatarSize: .custom(16), - imageProvider: context.imageProvider) - .alignmentGuide(.firstTextBaseline) { $0[.bottom] * 0.8 } - - Text(invitedText) - } + if let inviter = room.inviter, !room.isDirect { + RoomInviterLabel(inviter: inviter, imageProvider: context.imageProvider) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textPlaceholder) } } @@ -130,27 +123,7 @@ struct HomeScreenInviteCell: View { } private var subtitle: String? { - room.isDirect ? room.inviter?.userID : room.canonicalAlias - } - - private var attributedInviteText: AttributedString? { - guard - room.isDirect == false, - let inviterName = room.inviter?.displayName, - let inviterID = room.inviter?.userID - else { - return nil - } - - let text = L10n.screenInvitesInvitedYou(inviterName, inviterID) - var attributedString = AttributedString(text) - attributedString.font = .compound.bodyMD - attributedString.foregroundColor = .compound.textPlaceholder - if let range = attributedString.range(of: inviterName) { - attributedString[range].foregroundColor = .compound.textPrimary - attributedString[range].font = .compound.bodyMDSemibold - } - return attributedString + room.isDirect ? room.inviter?.id : room.canonicalAlias } private var badge: some View { diff --git a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenModels.swift b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenModels.swift index e215a129a7..f5becd709c 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenModels.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenModels.swift @@ -35,6 +35,7 @@ struct JoinRoomScreenRoomDetails { let canonicalAlias: String? let avatar: RoomAvatar let memberCount: UInt + let inviter: RoomInviterDetails? } struct JoinRoomScreenViewState: BindableState { diff --git a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift index 3fd3a4d305..efe4710774 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift @@ -81,7 +81,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo defer { hideLoadingIndicator() - updateRoomDetails() + Task { await updateRoomDetails() } } // Using only the preview API isn't enough as it's not capable @@ -91,13 +91,13 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo if let roomProxy = await clientProxy.roomForIdentifier(roomID) { self.roomProxy = roomProxy - updateRoomDetails() + await updateRoomDetails() } switch await clientProxy.roomPreviewForIdentifier(roomID, via: via) { case .success(let roomPreviewDetails): self.roomPreviewDetails = roomPreviewDetails - updateRoomDetails() + await updateRoomDetails() case .failure(.roomPreviewIsPrivate): break // Handled by the mode, we don't need an error indicator. case .failure: @@ -105,22 +105,24 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo } } - private func updateRoomDetails() { + private func updateRoomDetails() async { let name = roomProxy?.name ?? roomPreviewDetails?.name + let inviter = await roomProxy?.inviter.flatMap(RoomInviterDetails.init) state.roomDetails = JoinRoomScreenRoomDetails(name: name, topic: roomProxy?.topic ?? roomPreviewDetails?.topic, canonicalAlias: roomProxy?.canonicalAlias ?? roomPreviewDetails?.canonicalAlias, avatar: roomProxy?.avatar ?? .room(id: roomID, name: name ?? "", avatarURL: roomPreviewDetails?.avatarURL), - memberCount: UInt(roomProxy?.activeMembersCount ?? Int(roomPreviewDetails?.memberCount ?? 0))) + memberCount: UInt(roomProxy?.activeMembersCount ?? Int(roomPreviewDetails?.memberCount ?? 0)), + inviter: inviter) updateMode() } private func updateMode() { - if roomProxy?.isPublic ?? false || roomPreviewDetails?.isPublic ?? false { - state.mode = .join - } else if roomProxy?.membership == .invited || roomPreviewDetails?.isInvited ?? false { + if roomProxy?.membership == .invited || roomPreviewDetails?.isInvited ?? false { // Check invites first to show Accept/Decline buttons on public rooms. state.mode = .invited + } else if roomProxy?.isPublic ?? false || roomPreviewDetails?.isPublic ?? false { + state.mode = .join } else if roomPreviewDetails?.canKnock ?? false, allowKnocking { // Knocking is not supported yet, the flag is purely for preview tests. state.mode = .knock } else { diff --git a/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift b/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift index 9029508bc6..dba25600da 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/View/JoinRoomScreen.swift @@ -62,6 +62,12 @@ struct JoinRoomScreen: View { BadgeLabel(title: "\(memberCount)", icon: \.userProfile, isHighlighted: false) } + if let inviter = context.viewState.roomDetails?.inviter { + RoomInviterLabel(inviter: inviter, imageProvider: context.imageProvider) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + } + if let topic = context.viewState.roomDetails?.topic { Text(topic) .font(.compound.bodyMD) diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index eb274047fe..34d435ea15 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -64,6 +64,12 @@ class RoomProxy: RoomProxyProtocol { room.membership() } + var inviter: RoomMemberProxyProtocol? { + get async { + await (try? roomListItem.roomInfo().inviter).map(RoomMemberProxy.init) + } + } + var isDirect: Bool { room.isDirect() } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 1aa02c185d..ec161ed683 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -40,6 +40,7 @@ protocol RoomProxyProtocol { var isFavourite: Bool { get async } var pinnedEventIDs: [String] { get async } var membership: Membership { get } + var inviter: RoomMemberProxyProtocol? { get async } var hasOngoingCall: Bool { get } var canonicalAlias: String? { get } var ownUserID: String { get } diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPad-en-GB.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPad-en-GB.1.png new file mode 100644 index 0000000000..ee923355ee --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPad-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfca845384d5d0a44aa1a3e322c7aa059c4286750d2e0ff2a47632096b95c926 +size 108298 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPad-pseudo.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPad-pseudo.1.png new file mode 100644 index 0000000000..c6aaf315f1 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPad-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd1441564319b3f2a6028ac242063014964d601913ea1f8a7ccd86d1d7d4ef4f +size 121015 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPhone-15-en-GB.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPhone-15-en-GB.1.png new file mode 100644 index 0000000000..40d6e7a7f8 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPhone-15-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0aa045fa03babdf4b7eb051cc1befb486524ef0a904fb5f55d8f41633dd06f20 +size 63819 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPhone-15-pseudo.1.png b/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPhone-15-pseudo.1.png new file mode 100644 index 0000000000..db2ae7f7c6 --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_roomInviterLabel-iPhone-15-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56a4cb633080ff6f57162246d43e6dc2f8dbddd718c84181f348af1e1efeb47f +size 79274