diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 0f998a65c..3422b6f8e 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -20,6 +20,7 @@ protocol CommonSettingsProtocol { final class AppSettings { private enum UserDefaultsKeys: String { case lastVersionLaunched + case seenInvites case appLockNumberOfPINAttempts case appLockNumberOfBiometricAttempts case timelineStyle @@ -104,6 +105,11 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.lastVersionLaunched, storageType: .userDefaults(store)) var lastVersionLaunched: String? + /// The Set of room identifiers of invites that the user already saw in the invites list. + /// This Set is being used to implement badges for unread invites. + @UserPreference(key: UserDefaultsKeys.seenInvites, defaultValue: [], storageType: .userDefaults(store)) + var seenInvites: Set + /// The default homeserver address used. This is intentionally a string without a scheme /// so that it can be passed to Rust as a ServerName for well-known discovery. private(set) var defaultHomeserverAddress = "matrix.org" diff --git a/ElementX/Sources/Mocks/InvitedRoomProxyMock.swift b/ElementX/Sources/Mocks/InvitedRoomProxyMock.swift index 8c4a248e7..35df16145 100644 --- a/ElementX/Sources/Mocks/InvitedRoomProxyMock.swift +++ b/ElementX/Sources/Mocks/InvitedRoomProxyMock.swift @@ -25,6 +25,8 @@ extension InvitedRoomProxyMock { id = configuration.id inviter = configuration.inviter info = RoomInfoProxy(roomInfo: .init(configuration)) + + rejectInvitationReturnValue = .success(()) } } diff --git a/ElementX/Sources/Mocks/RoomSummaryProviderMock.swift b/ElementX/Sources/Mocks/RoomSummaryProviderMock.swift index 46f47e462..1c32989dc 100644 --- a/ElementX/Sources/Mocks/RoomSummaryProviderMock.swift +++ b/ElementX/Sources/Mocks/RoomSummaryProviderMock.swift @@ -71,7 +71,7 @@ extension Array where Element == RoomSummary { static let mockRooms: [Element] = [ RoomSummary(roomListItem: RoomListItemSDKMock(), id: "1", - knockRequestType: nil, + joinRequestType: nil, name: "Foundation 🔭🪐🌌", isDirect: false, avatarURL: nil, @@ -88,7 +88,7 @@ extension Array where Element == RoomSummary { isFavourite: false), RoomSummary(roomListItem: RoomListItemSDKMock(), id: "2", - knockRequestType: nil, + joinRequestType: nil, name: "Foundation and Empire", isDirect: false, avatarURL: .mockMXCAvatar, @@ -105,7 +105,7 @@ extension Array where Element == RoomSummary { isFavourite: false), RoomSummary(roomListItem: RoomListItemSDKMock(), id: "3", - knockRequestType: nil, + joinRequestType: nil, name: "Second Foundation", isDirect: false, avatarURL: nil, @@ -122,7 +122,7 @@ extension Array where Element == RoomSummary { isFavourite: false), RoomSummary(roomListItem: RoomListItemSDKMock(), id: "4", - knockRequestType: nil, + joinRequestType: nil, name: "Foundation's Edge", isDirect: false, avatarURL: nil, @@ -139,7 +139,7 @@ extension Array where Element == RoomSummary { isFavourite: false), RoomSummary(roomListItem: RoomListItemSDKMock(), id: "5", - knockRequestType: nil, + joinRequestType: nil, name: "Foundation and Earth", isDirect: true, avatarURL: nil, @@ -156,7 +156,7 @@ extension Array where Element == RoomSummary { isFavourite: false), RoomSummary(roomListItem: RoomListItemSDKMock(), id: "6", - knockRequestType: nil, + joinRequestType: nil, name: "Prelude to Foundation", isDirect: true, avatarURL: nil, @@ -173,7 +173,7 @@ extension Array where Element == RoomSummary { isFavourite: false), RoomSummary(roomListItem: RoomListItemSDKMock(), id: "0", - knockRequestType: nil, + joinRequestType: nil, name: "Unknown", isDirect: false, avatarURL: nil, @@ -223,7 +223,7 @@ extension Array where Element == RoomSummary { static let mockInvites: [Element] = [ RoomSummary(roomListItem: RoomListItemSDKMock(), id: "someAwesomeRoomId1", - knockRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie), + joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie), name: "First room", isDirect: false, avatarURL: .mockMXCAvatar, @@ -240,7 +240,7 @@ extension Array where Element == RoomSummary { isFavourite: false), RoomSummary(roomListItem: RoomListItemSDKMock(), id: "someAwesomeRoomId2", - knockRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie), + joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie), name: "Second room", isDirect: true, avatarURL: nil, diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 0e57a98ae..575f82075 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -9,7 +9,7 @@ import Combine import Foundation import UIKit -enum HomeScreenViewModelAction { +enum HomeScreenViewModelAction: Equatable { case presentRoom(roomIdentifier: String) case presentRoomDetails(roomIdentifier: String) case roomLeft(roomIdentifier: String) @@ -207,24 +207,25 @@ struct HomeScreenRoom: Identifiable, Equatable { } extension HomeScreenRoom { - init(summary: RoomSummary, hideUnreadMessagesBadge: Bool) { - let identifier = summary.id + init(summary: RoomSummary, hideUnreadMessagesBadge: Bool, seenInvites: Set = []) { + let roomID = summary.id let hasUnreadMessages = hideUnreadMessagesBadge ? false : summary.hasUnreadMessages + let isUnseenInvite = summary.joinRequestType?.isInvite == true && !seenInvites.contains(roomID) - let isDotShown = hasUnreadMessages || summary.hasUnreadMentions || summary.hasUnreadNotifications || summary.isMarkedUnread || summary.knockRequestType?.isKnock == true + let isDotShown = hasUnreadMessages || summary.hasUnreadMentions || summary.hasUnreadNotifications || summary.isMarkedUnread || isUnseenInvite let isMentionShown = summary.hasUnreadMentions && !summary.isMuted let isMuteShown = summary.isMuted let isCallShown = summary.hasOngoingCall - let isHighlighted = summary.isMarkedUnread || (!summary.isMuted && (summary.hasUnreadNotifications || summary.hasUnreadMentions)) || summary.knockRequestType?.isKnock == true + let isHighlighted = summary.isMarkedUnread || (!summary.isMuted && (summary.hasUnreadNotifications || summary.hasUnreadMentions)) || isUnseenInvite - let type: HomeScreenRoom.RoomType = switch summary.knockRequestType { + let type: HomeScreenRoom.RoomType = switch summary.joinRequestType { case .invite(let inviter): .invite(inviterDetails: inviter.map(RoomInviterDetails.init)) case .knock: .knock case .none: .room } - self.init(id: identifier, + self.init(id: roomID, roomID: summary.id, type: type, badges: .init(isDotShown: isDotShown, diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 7d7bcd5fd..a8a68d42d 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -98,6 +98,13 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol .weakAssign(to: \.state.isRoomDirectorySearchEnabled, on: self) .store(in: &cancellables) + appSettings.$seenInvites + .removeDuplicates() + .sink { [weak self] _ in + self?.updateRooms() + } + .store(in: &cancellables) + let isSearchFieldFocused = context.$viewState.map(\.bindings.isSearchFieldFocused) let searchQuery = context.$viewState.map(\.bindings.searchQuery) let activeFilters = context.$viewState.map(\.bindings.filtersState.activeFilters) @@ -290,9 +297,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol } var rooms = [HomeScreenRoom]() + let seenInvites = appSettings.seenInvites for summary in roomSummaryProvider.roomListPublisher.value { - let room = HomeScreenRoom(summary: summary, hideUnreadMessagesBadge: appSettings.hideUnreadMessagesBadge) + let room = HomeScreenRoom(summary: summary, + hideUnreadMessagesBadge: appSettings.hideUnreadMessagesBadge, + seenInvites: seenInvites) rooms.append(room) } @@ -396,6 +406,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol analyticsService.trackJoinedRoom(isDM: roomProxy.info.isDirect, isSpace: roomProxy.info.isSpace, activeMemberCount: UInt(roomProxy.info.activeMembersCount)) + appSettings.seenInvites.remove(roomID) case .failure: displayError() } @@ -414,8 +425,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol state.bindings.alertInfo = .init(id: UUID(), title: title, message: message, - primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), - secondaryButton: .init(title: L10n.actionDecline, role: .destructive) { Task { await self.declineInvite(roomID: room.id) } }) + primaryButton: .init(title: L10n.actionDecline, role: .destructive) { Task { await self.declineInvite(roomID: room.id) } }, + secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil)) } private func declineInvite(roomID: String) async { @@ -432,7 +443,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol let result = await roomProxy.rejectInvitation() - if case .failure = result { + switch result { + case .success: + appSettings.seenInvites.remove(roomID) + case .failure: displayError() } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift index 0e4659ac0..dd548d235 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift @@ -122,10 +122,13 @@ struct HomeScreenInviteCell: View { room.isDirect ? room.inviter?.id : room.canonicalAlias } + @ViewBuilder private var badge: some View { - Circle() - .scaledFrame(size: 12) - .foregroundColor(.compound.iconAccentTertiary) + if room.badges.isDotShown { + Circle() + .scaledFrame(size: 12) + .foregroundColor(.compound.iconAccentTertiary) // The badge is always green, no need to check isHighlighted here. + } } } @@ -178,7 +181,7 @@ private extension HomeScreenRoom { let summary = RoomSummary(roomListItem: RoomListItemSDKMock(), id: "@someone:somewhere.com", - knockRequestType: .invite(inviter: inviter), + joinRequestType: .invite(inviter: inviter), name: "Some Guy", isDirect: true, avatarURL: nil, @@ -205,7 +208,7 @@ private extension HomeScreenRoom { let summary = RoomSummary(roomListItem: RoomListItemSDKMock(), id: "@someone:somewhere.com", - knockRequestType: .invite(inviter: inviter), + joinRequestType: .invite(inviter: inviter), name: "Awesome Room", isDirect: false, avatarURL: avatarURL, diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift index 7fb2a9366..b3914d1a5 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift @@ -49,10 +49,9 @@ struct HomeScreenKnockedCell: View { private var mainContent: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .firstTextBaseline, spacing: 16) { - textualContent - badge - } + textualContent + + // No badge - the user initiated the knock, it cannot be unread. Text(L10n.screenRoomlistKnockEventSentDescription) .font(.compound.bodyMD) @@ -95,12 +94,6 @@ struct HomeScreenKnockedCell: View { private var subtitle: String? { room.canonicalAlias } - - private var badge: some View { - Circle() - .scaledFrame(size: 12) - .foregroundColor(.compound.iconAccentTertiary) - } } struct HomeScreenKnockedCell_Previews: PreviewProvider, TestablePreview { @@ -152,7 +145,7 @@ private extension HomeScreenRoom { let summary = RoomSummary(roomListItem: RoomListItemSDKMock(), id: "@someone:somewhere.com", - knockRequestType: .invite(inviter: inviter), + joinRequestType: .invite(inviter: inviter), name: "Some Guy", isDirect: true, avatarURL: nil, @@ -179,7 +172,7 @@ private extension HomeScreenRoom { let summary = RoomSummary(roomListItem: RoomListItemSDKMock(), id: "@someone:somewhere.com", - knockRequestType: .invite(inviter: inviter), + joinRequestType: .invite(inviter: inviter), name: "Awesome Room", isDirect: false, avatarURL: avatarURL, diff --git a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift index f65231425..ab68299b9 100644 --- a/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/JoinRoomScreen/JoinRoomScreenViewModel.swift @@ -41,6 +41,18 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo super.init(initialViewState: JoinRoomScreenViewState(roomID: roomID), mediaProvider: mediaProvider) + context.$viewState.map(\.mode) + .removeDuplicates() + .sink { mode in + switch mode { + case .invited: + appSettings.seenInvites.insert(roomID) + default: + break + } + } + .store(in: &cancellables) + Task { await loadRoomDetails() } @@ -225,6 +237,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo if let alias = state.roomDetails?.canonicalAlias { switch await clientProxy.joinRoomAlias(alias) { case .success: + appSettings.seenInvites.remove(roomID) actionsSubject.send(.joined) case .failure(let error): if case .forbiddenAccess = error { @@ -238,6 +251,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo } else { switch await clientProxy.joinRoom(roomID, via: via) { case .success: + appSettings.seenInvites.remove(roomID) actionsSubject.send(.joined) case .failure(let error): if case .forbiddenAccess = error { @@ -343,6 +357,8 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo return false } + appSettings.seenInvites.remove(roomID) + actionsSubject.send(.dismiss) return true } diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift index 5b9feb7f9..7748395d1 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummary.swift @@ -9,23 +9,21 @@ import Foundation import MatrixRustSDK struct RoomSummary { - enum KnockRequestType { + enum JoinRequestType { case invite(inviter: RoomMemberProxyProtocol?) case knock var isInvite: Bool { - if case .invite = self { - return true - } else { - return false + switch self { + case .invite: true + default: false } } var isKnock: Bool { - if case .knock = self { - return true - } else { - return false + switch self { + case .knock: true + default: false } } } @@ -34,7 +32,7 @@ struct RoomSummary { let id: String - let knockRequestType: KnockRequestType? + let joinRequestType: JoinRequestType? let name: String let isDirect: Bool @@ -103,7 +101,7 @@ extension RoomSummary { canonicalAlias = nil hasOngoingCall = false - knockRequestType = nil + joinRequestType = nil isMarkedUnread = false isFavourite = false } diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift index de9482351..ff8597f8d 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift @@ -255,7 +255,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol { let notificationMode = roomInfo.cachedUserDefinedNotificationMode.flatMap { RoomNotificationModeProxy.from(roomNotificationMode: $0) } - let knockRequestType: RoomSummary.KnockRequestType? = switch roomInfo.membership { + let joinRequestType: RoomSummary.JoinRequestType? = switch roomInfo.membership { case .invited: .invite(inviter: inviterProxy) case .knocked: .knock default: nil @@ -263,7 +263,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol { return RoomSummary(roomListItem: roomListItem, id: roomInfo.id, - knockRequestType: knockRequestType, + joinRequestType: joinRequestType, name: roomInfo.displayName ?? roomInfo.id, isDirect: roomInfo.isDirect, avatarURL: roomInfo.avatarUrl.flatMap(URL.init(string:)), diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPad-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPad-en-GB.1.png index d5c0e2510..520ffcf49 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPad-en-GB.1.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPad-en-GB.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bebc9800849eacc1de8368501484314199288de7ede2941d97e5580e3770d943 -size 186322 +oid sha256:35a8ec89cb55bcd41aa7a022ce5dd028d8f19c7e09f69ca120090bda9671f113 +size 184071 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPad-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPad-pseudo.1.png index 9cf37a5d4..d85c0dd61 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPad-pseudo.1.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPad-pseudo.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cbd734a397df85be3f35029edac33bff4e472af4dc0235ef7884902831c201b8 -size 199771 +oid sha256:8b8bfeb1dc08cb0c8cec725eabaff5a0c0235b0a0202606a55f84bfc8a1779ac +size 197627 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPhone-16-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPhone-16-en-GB.1.png index 43c45d997..8b6fb835f 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPhone-16-en-GB.1.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPhone-16-en-GB.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0580617659e673c8e581f14171c2dfcb37a7e334e4aca2eaade10cc081af5f8 -size 130536 +oid sha256:6f9cbb9334a0f7f5b40a24dd53eeedf854ae65a5fdfd831df21f4c2ec7bcb4c9 +size 128793 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPhone-16-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPhone-16-pseudo.1.png index cd1c9080b..fbf515fc8 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPhone-16-pseudo.1.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_homeScreenKnockedCell-iPhone-16-pseudo.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a634516220bbe3443c58f055574bacda2b4aceae099d7bb5a9b6a5051e6e99ff -size 153293 +oid sha256:39f875068096460991dc69ea7199fe4621cf63baf7080e4de978b28b30bf4df4 +size 151585 diff --git a/UnitTests/Sources/HomeScreenRoomTests.swift b/UnitTests/Sources/HomeScreenRoomTests.swift index 073bb5f00..83fd45aef 100644 --- a/UnitTests/Sources/HomeScreenRoomTests.swift +++ b/UnitTests/Sources/HomeScreenRoomTests.swift @@ -22,7 +22,7 @@ class HomeScreenRoomTests: XCTestCase { hasOngoingCall: Bool) { roomSummary = RoomSummary(roomListItem: .init(noPointer: .init()), id: "Test room", - knockRequestType: nil, + joinRequestType: nil, name: "Test room", isDirect: false, avatarURL: nil, diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index 04857e194..b9741f09a 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -13,13 +13,20 @@ import XCTest @MainActor class HomeScreenViewModelTests: XCTestCase { var viewModel: HomeScreenViewModelProtocol! - var clientProxy: ClientProxyMock! var context: HomeScreenViewModelType.Context! { viewModel.context } - var cancellables = Set() - var roomSummaryProvider: RoomSummaryProviderMock! - override func setUpWithError() throws { + var clientProxy: ClientProxyMock! + var roomSummaryProvider: RoomSummaryProviderMock! + var appSettings: AppSettings! + + var cancellables = Set() + + override func setUp() { cancellables.removeAll() + + AppSettings.resetAllSettings() + appSettings = AppSettings() + ServiceLocator.shared.register(appSettings: appSettings) } override func tearDown() { @@ -29,26 +36,26 @@ class HomeScreenViewModelTests: XCTestCase { func testSelectRoom() async throws { setupViewModel() - let mockRoomId = "mock_room_id" + let mockRoomID = "mock_room_id" var correctResult = false - var selectedRoomId = "" + var selectedRoomID = "" viewModel.actions .sink { action in switch action { - case .presentRoom(let roomId): + case .presentRoom(let roomID): correctResult = true - selectedRoomId = roomId + selectedRoomID = roomID default: break } } .store(in: &cancellables) - context.send(viewAction: .selectRoom(roomIdentifier: mockRoomId)) + context.send(viewAction: .selectRoom(roomIdentifier: mockRoomID)) await Task.yield() XCTAssert(correctResult) - XCTAssertEqual(mockRoomId, selectedRoomId) + XCTAssertEqual(mockRoomID, selectedRoomID) } func testTapUserAvatar() async throws { @@ -75,26 +82,26 @@ class HomeScreenViewModelTests: XCTestCase { func testLeaveRoomAlert() async throws { setupViewModel() - let mockRoomId = "1" + let mockRoomID = "1" - clientProxy.roomForIdentifierClosure = { _ in .joined(JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room"))) } + clientProxy.roomForIdentifierClosure = { _ in .joined(JoinedRoomProxyMock(.init(id: mockRoomID, name: "Some room"))) } let deferred = deferFulfillment(context.$viewState) { value in value.bindings.leaveRoomAlertItem != nil } - context.send(viewAction: .leaveRoom(roomIdentifier: mockRoomId)) + context.send(viewAction: .leaveRoom(roomIdentifier: mockRoomID)) try await deferred.fulfill() - XCTAssertEqual(context.leaveRoomAlertItem?.roomID, mockRoomId) + XCTAssertEqual(context.leaveRoomAlertItem?.roomID, mockRoomID) } func testLeaveRoomError() async throws { setupViewModel() - let mockRoomId = "1" - let room = JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room")) + let mockRoomID = "1" + let room = JoinedRoomProxyMock(.init(id: mockRoomID, name: "Some room")) room.leaveRoomClosure = { .failure(.sdkError(ClientProxyMockError.generic)) } clientProxy.roomForIdentifierClosure = { _ in .joined(room) } @@ -103,7 +110,7 @@ class HomeScreenViewModelTests: XCTestCase { value.bindings.alertInfo != nil } - context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomId)) + context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomID)) try await deferred.fulfill() @@ -113,26 +120,26 @@ class HomeScreenViewModelTests: XCTestCase { func testLeaveRoomSuccess() async throws { setupViewModel() - let mockRoomId = "1" + let mockRoomID = "1" var correctResult = false let expectation = expectation(description: #function) viewModel.actions .sink { action in switch action { case .roomLeft(let roomIdentifier): - correctResult = roomIdentifier == mockRoomId + correctResult = roomIdentifier == mockRoomID default: break } expectation.fulfill() } .store(in: &cancellables) - let room = JoinedRoomProxyMock(.init(id: mockRoomId, name: "Some room")) + let room = JoinedRoomProxyMock(.init(id: mockRoomID, name: "Some room")) room.leaveRoomClosure = { .success(()) } clientProxy.roomForIdentifierClosure = { _ in .joined(room) } - context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomId)) + context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomID)) await fulfillment(of: [expectation]) XCTAssertNil(context.alertInfo) XCTAssertTrue(correctResult) @@ -141,19 +148,19 @@ class HomeScreenViewModelTests: XCTestCase { func testShowRoomDetails() async throws { setupViewModel() - let mockRoomId = "1" + let mockRoomID = "1" var correctResult = false viewModel.actions .sink { action in switch action { case .presentRoomDetails(let roomIdentifier): - correctResult = roomIdentifier == mockRoomId + correctResult = roomIdentifier == mockRoomID default: break } } .store(in: &cancellables) - context.send(viewAction: .showRoomDetails(roomIdentifier: mockRoomId)) + context.send(viewAction: .showRoomDetails(roomIdentifier: mockRoomID)) await Task.yield() XCTAssertNil(context.alertInfo) XCTAssertTrue(correctResult) @@ -256,12 +263,88 @@ class HomeScreenViewModelTests: XCTestCase { XCTAssertEqual(context.viewState.securityBannerMode, .none) } + func testInviteUnreadBadge() async throws { + setupViewModel(withInvites: true) + var invites = context.viewState.rooms.invites + XCTAssertEqual(invites.count, 2) + + for invite in invites { + XCTAssertTrue(invite.badges.isDotShown) + } + + let deferred = deferFulfillment(context.$viewState) { state in + state.rooms.contains { room in + room.roomID == invites[0].roomID && room.badges.isDotShown == false + } + } + appSettings.seenInvites = Set(invites.compactMap(\.roomID)) + try await deferred.fulfill() + invites = context.viewState.rooms.invites + + for invite in invites { + XCTAssertFalse(invite.badges.isDotShown) + } + } + + func testAcceptInvite() async throws { + setupViewModel(withInvites: true) + + let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) + appSettings.seenInvites = Set(invitedRoomIDs) + XCTAssertEqual(invitedRoomIDs.count, 2) + + let deferred = deferFulfillment(viewModel.actions) { $0 == .presentRoom(roomIdentifier: invitedRoomIDs[0]) } + context.send(viewAction: .acceptInvite(roomIdentifier: invitedRoomIDs[0])) + try await deferred.fulfill() + + XCTAssertEqual(appSettings.seenInvites, [invitedRoomIDs[1]]) + } + + func testDeclineInvite() async throws { + setupViewModel(withInvites: true) + + let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) + appSettings.seenInvites = Set(invitedRoomIDs) + XCTAssertEqual(invitedRoomIDs.count, 2) + + let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } + context.send(viewAction: .declineInvite(roomIdentifier: invitedRoomIDs[0])) + try await deferred.fulfill() + + let rejectExpectation = expectation(description: "Expected rejectInvitation to be called.") + clientProxy.roomForIdentifierClosure = { _ in + let roomProxy = InvitedRoomProxyMock(.init()) + roomProxy.rejectInvitationClosure = { + rejectExpectation.fulfill() + return .success(()) + } + + return .invited(roomProxy) + } + context.viewState.bindings.alertInfo?.primaryButton.action?() + await fulfillment(of: [rejectExpectation], timeout: 1.0) + + XCTAssertEqual(appSettings.seenInvites, [invitedRoomIDs[1]]) + } + // MARK: - Helpers - private func setupViewModel(securityStatePublisher: CurrentValuePublisher? = nil) { - roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))) + private func setupViewModel(securityStatePublisher: CurrentValuePublisher? = nil, withInvites: Bool = false) { + var rooms: [RoomSummary] = .mockRooms + if withInvites { + rooms += .mockInvites + } + + roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(rooms))) + clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", roomSummaryProvider: roomSummaryProvider)) + if withInvites { + clientProxy.joinRoomViaReturnValue = .success(()) + clientProxy.joinRoomAliasReturnValue = .success(()) + clientProxy.roomForIdentifierClosure = { _ in .invited(InvitedRoomProxyMock(.init())) } + } + let userSession = UserSessionMock(.init(clientProxy: clientProxy)) if let securityStatePublisher { userSession.sessionSecurityStatePublisher = securityStatePublisher @@ -269,8 +352,20 @@ class HomeScreenViewModelTests: XCTestCase { viewModel = HomeScreenViewModel(userSession: userSession, analyticsService: ServiceLocator.shared.analytics, - appSettings: ServiceLocator.shared.settings, + appSettings: appSettings, selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), userIndicatorController: ServiceLocator.shared.userIndicatorController) } } + +private extension [HomeScreenRoom] { + var invites: [HomeScreenRoom] { + filter { room in + if case .invite = room.type { + true + } else { + false + } + } + } +} diff --git a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift index 092b217ef..2abfab8ee 100644 --- a/UnitTests/Sources/JoinRoomScreenViewModelTests.swift +++ b/UnitTests/Sources/JoinRoomScreenViewModelTests.swift @@ -19,12 +19,20 @@ class JoinRoomScreenViewModelTests: XCTestCase { } var viewModel: JoinRoomScreenViewModelProtocol! + var clientProxy: ClientProxyMock! + var appSettings: AppSettings! var context: JoinRoomScreenViewModelType.Context { viewModel.context } + override func setUp() { + AppSettings.resetAllSettings() + appSettings = AppSettings() + ServiceLocator.shared.register(appSettings: appSettings) + } + override func tearDown() { viewModel = nil clientProxy = nil @@ -32,7 +40,12 @@ class JoinRoomScreenViewModelTests: XCTestCase { } func testInteraction() async throws { + XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") + setupViewModel() + try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .joinable }.fulfill() + + XCTAssertTrue(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.") let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined } context.send(viewAction: .join) @@ -40,34 +53,45 @@ class JoinRoomScreenViewModelTests: XCTestCase { } func testAcceptInviteInteraction() async throws { - setupViewModel() + XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") + + setupViewModel(mode: .invited) + try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill() + + XCTAssertEqual(appSettings.seenInvites, ["1"], "The invited room's ID should be registered as a seen invite.") let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined } context.send(viewAction: .acceptInvite) try await deferred.fulfill() + + XCTAssertTrue(appSettings.seenInvites.isEmpty, "The after accepting an invite the invite should be forgotten in case the user leaves.") } func testDeclineInviteInteraction() async throws { + XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") + setupViewModel(mode: .invited) - try await deferFulfillment(viewModel.context.$viewState) { $0.roomDetails != nil }.fulfill() + try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill() + XCTAssertEqual(appSettings.seenInvites, ["1"], "The invited room's ID should be registered as a seen invite.") context.send(viewAction: .declineInvite) XCTAssertEqual(viewModel.context.alertInfo?.id, .declineInvite) - let deferred = deferFulfillment(viewModel.actionsPublisher) { action in - action == .dismiss - } + let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .dismiss } context.alertInfo?.secondaryButton?.action?() try await deferred.fulfill() + + XCTAssertTrue(appSettings.seenInvites.isEmpty, "The after declining an invite the invite should be forgotten in case another invite is received.") } func testKnockedState() async throws { + XCTAssertTrue(appSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") setupViewModel(mode: .knocked) - try await deferFulfillment(viewModel.context.$viewState) { state in - state.mode == .knocked - }.fulfill() + try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .knocked }.fulfill() + + XCTAssertTrue(appSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.") } func testCancelKnock() async throws { @@ -129,6 +153,7 @@ class JoinRoomScreenViewModelTests: XCTestCase { clientProxy = ClientProxyMock(.init()) clientProxy.joinRoomViaReturnValue = throwing ? .failure(.sdkError(ClientProxyMockError.generic)) : .success(()) + clientProxy.joinRoomAliasReturnValue = clientProxy.joinRoomViaReturnValue switch mode { case .knocked: @@ -160,7 +185,7 @@ class JoinRoomScreenViewModelTests: XCTestCase { viewModel = JoinRoomScreenViewModel(roomID: "1", via: [], - appSettings: ServiceLocator.shared.settings, + appSettings: appSettings, clientProxy: clientProxy, mediaProvider: MediaProviderMock(configuration: .init()), userIndicatorController: ServiceLocator.shared.userIndicatorController) diff --git a/UnitTests/Sources/LoggingTests.swift b/UnitTests/Sources/LoggingTests.swift index ffff8db60..9551e62cd 100644 --- a/UnitTests/Sources/LoggingTests.swift +++ b/UnitTests/Sources/LoggingTests.swift @@ -80,7 +80,7 @@ class LoggingTests: XCTestCase { let heroName = "Pseudonym" let roomSummary = RoomSummary(roomListItem: .init(noPointer: .init()), id: "myroomid", - knockRequestType: nil, + joinRequestType: nil, name: roomName, isDirect: true, avatarURL: nil, diff --git a/UnitTests/Sources/RoomSummaryTests.swift b/UnitTests/Sources/RoomSummaryTests.swift index 585ab193f..017d17567 100644 --- a/UnitTests/Sources/RoomSummaryTests.swift +++ b/UnitTests/Sources/RoomSummaryTests.swift @@ -56,7 +56,7 @@ class RoomSummaryTests: XCTestCase { func makeSummary(isDirect: Bool, hasRoomAvatar: Bool) -> RoomSummary { RoomSummary(roomListItem: .init(noPointer: .init()), id: roomDetails.id, - knockRequestType: nil, + joinRequestType: nil, name: roomDetails.name, isDirect: isDirect, avatarURL: hasRoomAvatar ? roomDetails.avatarURL : nil,