diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 81851cf451..aedd933673 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -183,6 +183,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "HomeScreenKnockedCell_Previews") } + func testHomeScreenNewSoundBanner() async throws { + try await performAccessibilityAudit(named: "HomeScreenNewSoundBanner_Previews") + } + func testHomeScreenRecoveryKeyConfirmationBanner() async throws { try await performAccessibilityAudit(named: "HomeScreenRecoveryKeyConfirmationBanner_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 6632f1af05..963a8bcb52 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -137,6 +137,7 @@ 1621BF6316FFFEF5AE067C77 /* Avatars.swift in Sources */ = {isa = PBXBuildFile; fileRef = C142248014E08E885E323E56 /* Avatars.swift */; }; 1653275750CE11F5CE94DDFD /* ReadReceiptsSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8063E65441E771200108C558 /* ReadReceiptsSummaryView.swift */; }; 167D00CAA13FAFB822298021 /* MediaProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62A81CCC2516D9CF9322DF01 /* MediaProviderTests.swift */; }; + 167D5024DB9D44197AEA0507 /* HomeScreenNewSoundBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD5480F03306234FC086E93B /* HomeScreenNewSoundBanner.swift */; }; 16A1F6C703305FCAF4E14EC6 /* TimelineProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A8AA0DFA06012A9DAB951E /* TimelineProxyMock.swift */; }; 16A5D1749A32B91203495EF7 /* FrequentlyUsedEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2074C0449B83D5858BD2D7 /* FrequentlyUsedEmoji.swift */; }; 16CBD087038DE3815CDA512C /* PollMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D38391154120264910D19528 /* PollMock.swift */; }; @@ -2474,6 +2475,7 @@ BC51BF90469412ABDE658CDD /* portrait_test_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = portrait_test_image.jpg; sourceTree = ""; }; BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutMocks.swift; sourceTree = ""; }; BCDA016D05107DED3B9495CB /* TimelineItemDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemDebugView.swift; sourceTree = ""; }; + BD5480F03306234FC086E93B /* HomeScreenNewSoundBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenNewSoundBanner.swift; sourceTree = ""; }; BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationContent.swift; sourceTree = ""; }; BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemReplyDetails.swift; sourceTree = ""; }; BE98688578F8B0541D853695 /* test_pdf.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = test_pdf.pdf; sourceTree = ""; }; @@ -4125,6 +4127,7 @@ C0FEA560929DD73FFEF8C3DF /* HomeScreenEmptyStateView.swift */, D8FC33C3F6BF597E095CE9FA /* HomeScreenInviteCell.swift */, A103580EBA06155B1343EF16 /* HomeScreenKnockedCell.swift */, + BD5480F03306234FC086E93B /* HomeScreenNewSoundBanner.swift */, 05512FB13987D221B7205DE0 /* HomeScreenRecoveryKeyConfirmationBanner.swift */, ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */, C7661EFFCAA307A97D71132A /* HomeScreenRoomList.swift */, @@ -7766,6 +7769,7 @@ 22C5483D01EEB290B8339817 /* HomeScreenInviteCell.swift in Sources */, 86DFA58FBBEB0AF671D2A1E1 /* HomeScreenKnockedCell.swift in Sources */, 8810A2A30A68252EBB54EE05 /* HomeScreenModels.swift in Sources */, + 167D5024DB9D44197AEA0507 /* HomeScreenNewSoundBanner.swift in Sources */, B04E9EB589CE99C3929E817A /* HomeScreenRecoveryKeyConfirmationBanner.swift in Sources */, 0AE0AB1952F186EB86719B4F /* HomeScreenRoomCell.swift in Sources */, A10D6CCDE2010C09EEA1A593 /* HomeScreenRoomList.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 088ea2319e..0a25d0f795 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -159,6 +159,8 @@ "banner_migrate_to_native_sliding_sync_description" = "Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."; "banner_migrate_to_native_sliding_sync_force_logout_title" = "Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app."; "banner_migrate_to_native_sliding_sync_title" = "Upgrade available"; +"banner_new_sound_message" = "Your notification ping has been updated—clearer, quicker, and less disruptive."; +"banner_new_sound_title" = "We’ve refreshed your sounds"; "banner_set_up_recovery_content" = "Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices."; "banner_set_up_recovery_title" = "Set up recovery to protect your account"; "call_invalid_audio_device_bluetooth_devices_disabled" = "Element Call does not support using Bluetooth audio devices in this Android version. Please select a different audio device."; diff --git a/ElementX/Resources/Sounds/message.caf b/ElementX/Resources/Sounds/message.caf index 4c241b5866..55de877513 100644 Binary files a/ElementX/Resources/Sounds/message.caf and b/ElementX/Resources/Sounds/message.caf differ diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 69bc3161b1..7fd8b58e2c 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -437,6 +437,11 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg await userSession.clientProxy.expireSyncSessions() } + if oldVersion < Version(25, 10, 0) { + MXLog.info("Migrating to version 25.10.0, showing new sound banner to existing user.") + appSettings.hasSeenNewSoundBanner = false + } + userSessionMigrationsOldVersion = nil } diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index a27522f962..5323f3c2a4 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -29,6 +29,7 @@ final class AppSettings { private enum UserDefaultsKeys: String { case lastVersionLaunched case seenInvites + case hasSeenNewSoundBanner case appLockNumberOfPINAttempts case appLockNumberOfBiometricAttempts case timelineStyle @@ -161,6 +162,10 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.seenInvites, defaultValue: [], storageType: .userDefaults(store)) var seenInvites: Set + /// Defaults to `true` for new users, and we use a migration to set it to `false` for existing users. + @UserPreference(key: UserDefaultsKeys.hasSeenNewSoundBanner, defaultValue: true, storageType: .userDefaults(store)) + var hasSeenNewSoundBanner + /// The initial set of account providers shown to the user in the authentication flow. /// /// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 8066a59956..812efc3dcc 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -356,6 +356,10 @@ internal enum L10n { internal static var bannerMigrateToNativeSlidingSyncForceLogoutTitle: String { return L10n.tr("Localizable", "banner_migrate_to_native_sliding_sync_force_logout_title") } /// Upgrade available internal static var bannerMigrateToNativeSlidingSyncTitle: String { return L10n.tr("Localizable", "banner_migrate_to_native_sliding_sync_title") } + /// Your notification ping has been updated—clearer, quicker, and less disruptive. + internal static var bannerNewSoundMessage: String { return L10n.tr("Localizable", "banner_new_sound_message") } + /// We’ve refreshed your sounds + internal static var bannerNewSoundTitle: String { return L10n.tr("Localizable", "banner_new_sound_title") } /// Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices. internal static var bannerSetUpRecoveryContent: String { return L10n.tr("Localizable", "banner_set_up_recovery_content") } /// Set up recovery diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 53ff42a4bc..0e4275df56 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -53,6 +53,7 @@ enum TestablePreviewsDictionary { "HomeScreenEmptyStateView_Previews" : HomeScreenEmptyStateView_Previews.self, "HomeScreenInviteCell_Previews" : HomeScreenInviteCell_Previews.self, "HomeScreenKnockedCell_Previews" : HomeScreenKnockedCell_Previews.self, + "HomeScreenNewSoundBanner_Previews" : HomeScreenNewSoundBanner_Previews.self, "HomeScreenRecoveryKeyConfirmationBanner_Previews" : HomeScreenRecoveryKeyConfirmationBanner_Previews.self, "HomeScreenRoomCell_Previews" : HomeScreenRoomCell_Previews.self, "HomeScreen_Previews" : HomeScreen_Previews.self, diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 7b1b6dafdf..a9eab56c98 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -39,6 +39,7 @@ enum HomeScreenViewAction { case confirmRecoveryKey case resetEncryption case skipRecoveryKeyConfirmation + case dismissNewSoundBanner case updateVisibleItemRange(Range) case globalSearch case markRoomAsUnread(roomIdentifier: String) @@ -92,6 +93,7 @@ struct HomeScreenViewState: BindableState { var userAvatarURL: URL? var securityBannerMode = HomeScreenSecurityBannerMode.none + var shouldShowNewSoundBanner = false var requiresExtraAccountSetup = false @@ -134,6 +136,10 @@ struct HomeScreenViewState: BindableState { var shouldShowFilters: Bool { !bindings.isSearchFieldFocused && roomListMode == .rooms } + + var shouldShowBanner: Bool { + securityBannerMode.isShown || shouldShowNewSoundBanner + } } struct HomeScreenViewStateBindings { diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 177bcc693b..46990d80b9 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -105,6 +105,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol } .store(in: &cancellables) + appSettings.$hasSeenNewSoundBanner + .sink { [weak self] hasSeenNewSoundBanner in + self?.state.shouldShowNewSoundBanner = !hasSeenNewSoundBanner + } + .store(in: &cancellables) + userSession.clientProxy.hideInviteAvatarsPublisher .removeDuplicates() .receive(on: DispatchQueue.main) @@ -160,6 +166,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol actionsSubject.send(.presentEncryptionResetScreen) case .skipRecoveryKeyConfirmation: state.securityBannerMode = .dismissed + case .dismissNewSoundBanner: + appSettings.hasSeenNewSoundBanner = true case .updateVisibleItemRange(let range): roomSummaryProvider?.updateVisibleRange(range) case .startChat: diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift index 7e5dba6bcb..9b4c34fb37 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenContent.swift @@ -122,14 +122,16 @@ struct HomeScreenContent: View { @ViewBuilder private var topSection: some View { // An empty VStack causes glitches within the room list - if context.viewState.shouldShowFilters || context.viewState.securityBannerMode.isShown { + if context.viewState.shouldShowFilters || context.viewState.shouldShowBanner { VStack(spacing: 0) { if context.viewState.shouldShowFilters { RoomListFiltersView(state: $context.filtersState) } - + if case let .show(state) = context.viewState.securityBannerMode { HomeScreenRecoveryKeyConfirmationBanner(state: state, context: context) + } else if context.viewState.shouldShowNewSoundBanner { + HomeScreenNewSoundBanner { context.send(viewAction: .dismissNewSoundBanner) } } } .background(Color.compound.bgCanvasDefault) diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenNewSoundBanner.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenNewSoundBanner.swift new file mode 100644 index 0000000000..be62dc9e58 --- /dev/null +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenNewSoundBanner.swift @@ -0,0 +1,59 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +struct HomeScreenNewSoundBanner: View { + let dismissAction: () -> Void + + var body: some View { + VStack(spacing: 16) { + content + buttons + } + .padding(16) + .background(Color.compound.bgSubtleSecondary) + .cornerRadius(14) + .padding(.horizontal, 16) + } + + var content: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Text(L10n.bannerNewSoundTitle) + .font(.compound.bodyLGSemibold) + .foregroundColor(.compound.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: dismissAction) { + Image(systemName: "xmark") + .foregroundColor(.compound.iconSecondary) + .frame(width: 12, height: 12) + } + } + + Text(L10n.bannerNewSoundMessage) + .font(.compound.bodyMD) + .foregroundColor(.compound.textSecondary) + } + } + + var buttons: some View { + Button(action: dismissAction) { + Text(L10n.actionOk) + .frame(maxWidth: .infinity) + } + .buttonStyle(.compound(.primary, size: .medium)) + } +} + +struct HomeScreenNewSoundBanner_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + HomeScreenNewSoundBanner { } + } +} diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index a1b795053b..bab870d266 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -275,6 +275,12 @@ extension PreviewTests { } } + func testHomeScreenNewSoundBanner() async throws { + for (index, preview) in HomeScreenNewSoundBanner_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + func testHomeScreenRecoveryKeyConfirmationBanner() async throws { for (index, preview) in HomeScreenRecoveryKeyConfirmationBanner_Previews._allPreviews.enumerated() { try await assertSnapshots(matching: preview, step: index) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPad-en-GB-0.png new file mode 100644 index 0000000000..67b9151c0b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50db0fb40d18d13f4d2935d5a3bf94267665e39d4920640157b83440b2e43f24 +size 93124 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPad-pseudo-0.png new file mode 100644 index 0000000000..6ccb8b78ea --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24b5a53e8064a09d2c419a861ce2877c8680ecafe8531469530bfff7c60e965a +size 103633 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPhone-16-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPhone-16-en-GB-0.png new file mode 100644 index 0000000000..8015e6c36b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPhone-16-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f068c7428744de0bf0bfae78edda3ebe5d62f7e8f0c6bd820ea0872feed2423e +size 52313 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPhone-16-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPhone-16-pseudo-0.png new file mode 100644 index 0000000000..415225d9ee --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/homeScreenNewSoundBanner.iPhone-16-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7863d39543d71a142c8d58c42688953e7a8892f4b81d78a9b9f60717624abce3 +size 72122 diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index 5d1aa0c2d5..1dd73d61e3 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -362,6 +362,19 @@ class HomeScreenViewModelTests: XCTestCase { try await deferredAction.fulfill() } + func testNewSoundBanner() { + appSettings.hasSeenNewSoundBanner = false + + setupViewModel() + XCTAssertTrue(context.viewState.shouldShowBanner) + XCTAssertTrue(context.viewState.shouldShowNewSoundBanner) + + context.send(viewAction: .dismissNewSoundBanner) + XCTAssertFalse(context.viewState.shouldShowBanner) + XCTAssertFalse(context.viewState.shouldShowNewSoundBanner) + XCTAssertTrue(appSettings.hasSeenNewSoundBanner) + } + // MARK: - Helpers enum InviteType { case rooms, spaces }