diff --git a/ClientKit/Sources/ClientKit/Models/AccountModel.swift b/ClientKit/Sources/ClientKit/Models/AccountModel.swift index f3a536b..c2928ac 100644 --- a/ClientKit/Sources/ClientKit/Models/AccountModel.swift +++ b/ClientKit/Sources/ClientKit/Models/AccountModel.swift @@ -27,7 +27,8 @@ import Foundation public let url: URL? public let username: String public let lastSeenStatusId: String? - + public let lastSeenNotificationId: String? + public var avatarData: Data? public init(id: String, @@ -50,6 +51,7 @@ import Foundation url: URL?, username: String, lastSeenStatusId: String?, + lastSeenNotificationId: String?, avatarData: Data? = nil) { self.id = id self.accessToken = accessToken @@ -72,6 +74,7 @@ import Foundation self.username = username self.lastSeenStatusId = lastSeenStatusId self.avatarData = avatarData + self.lastSeenNotificationId = lastSeenNotificationId } } diff --git a/CoreData/AccountData.swift b/CoreData/AccountData.swift index aadaf8a..a19835c 100644 --- a/CoreData/AccountData.swift +++ b/CoreData/AccountData.swift @@ -10,24 +10,62 @@ import ClientKit @Model final public class AccountData { @Attribute(.unique) public var id: String + + /// Access token to the server API. public var accessToken: String? + + /// Refresh token which can be used to download new access token. public var refreshToken: String? + + /// Full user name (user name with server address). public var acct: String + + /// URL to user avatar. public var avatar: URL? - public var avatarData: Data? + + /// Avatar downloaded from server (visible mainly in top navigation bar). + @Attribute(.externalStorage) public var avatarData: Data? + + /// Id of OAuth client. public var clientId: String + + /// Secret of OAutch client. public var clientSecret: String + + /// Vapid key of OAuth client. public var clientVapidKey: String + + /// Date of creating user. public var createdAt: String + + /// Human readable user name. public var displayName: String? + + /// Number of followers. public var followersCount: Int32 + + /// Number of following users. public var followingCount: Int32 + + /// URL to header image visible on user profile. public var header: URL? + + /// User profile is locked. public var locked: Bool + + /// Description on user profile. public var note: String? + + /// Address to server. public var serverUrl: URL + + /// NUmber of statuses added by the user. public var statusesCount: Int32 + + /// Url to user profile. public var url: URL? + + /// User name (without server address). public var username: String /// Last status seen on home timeline by the user. @@ -39,10 +77,12 @@ import ClientKit /// JSON string with last objects loaded into home timeline. public var timelineCache: String? + /// Last notification seen by the user. + public var lastSeenNotificationId: String? + @Relationship(deleteRule: .cascade, inverse: \ViewedStatus.pixelfedAccount) public var viewedStatuses: [ViewedStatus] @Relationship(deleteRule: .cascade, inverse: \AccountRelationship.pixelfedAccount) public var accountRelationships: [AccountRelationship] - - + init( accessToken: String? = nil, refreshToken: String? = nil, @@ -119,6 +159,7 @@ extension AccountData { url: self.url, username: self.username, lastSeenStatusId: self.lastSeenStatusId, + lastSeenNotificationId: self.lastSeenNotificationId, avatarData: self.avatarData) return accountModel } diff --git a/CoreData/AccountDataHandler.swift b/CoreData/AccountDataHandler.swift index 34351d4..b1c382f 100644 --- a/CoreData/AccountDataHandler.swift +++ b/CoreData/AccountDataHandler.swift @@ -7,6 +7,7 @@ import Foundation import SwiftData import PixelfedKit +import EnvironmentKit class AccountDataHandler { public static let shared = AccountDataHandler() @@ -63,13 +64,18 @@ class AccountDataHandler { } } - func update(lastSeenStatusId: String?, lastLoadedStatusId: String?, statuses: [Status]? = nil, accountId: String, modelContext: ModelContext) throws { + func update(lastSeenStatusId: String?, lastLoadedStatusId: String?, statuses: [Status]? = nil, applicationState: ApplicationState, modelContext: ModelContext) throws { + guard let accountId = applicationState.account?.id else { + return + } + guard let accountDataFromDb = self.getAccountData(accountId: accountId, modelContext: modelContext) else { return } if (accountDataFromDb.lastSeenStatusId ?? "0") < (lastSeenStatusId ?? "0") { accountDataFromDb.lastSeenStatusId = lastSeenStatusId + applicationState.lastSeenStatusId = lastSeenStatusId } if (accountDataFromDb.lastLoadedStatusId ?? "0") < (lastLoadedStatusId ?? "0") { @@ -82,4 +88,21 @@ class AccountDataHandler { try modelContext.save() } + + func update(lastSeenNotificationId: String?, applicationState: ApplicationState, modelContext: ModelContext) throws { + guard let accountId = applicationState.account?.id else { + return + } + + guard let accountDataFromDb = self.getAccountData(accountId: accountId, modelContext: modelContext) else { + return + } + + if (accountDataFromDb.lastSeenNotificationId ?? "0") < (lastSeenNotificationId ?? "0") { + accountDataFromDb.lastSeenNotificationId = lastSeenNotificationId + applicationState.lastSeenNotificationId = lastSeenNotificationId + } + + try modelContext.save() + } } diff --git a/EnvironmentKit/Sources/EnvironmentKit/ApplicationState.swift b/EnvironmentKit/Sources/EnvironmentKit/ApplicationState.swift index ea2f101..3e93838 100644 --- a/EnvironmentKit/Sources/EnvironmentKit/ApplicationState.swift +++ b/EnvironmentKit/Sources/EnvironmentKit/ApplicationState.swift @@ -35,9 +35,15 @@ import ClientKit /// Each URL in a status will be assumed to be exactly this many characters. public private(set) var statusCharactersReservedPerUrl = defaults.statusCharactersReservedPerUrl + /// Last notification seen by the user. + public var lastSeenNotificationId: String? + + /// Information about new notifications. + public var newNotificationsHasBeenAdded = false + /// Last status seen by the user. public var lastSeenStatusId: String? - + /// Amount of new statuses which are not displayed yet to the user. public var amountOfNewStatuses = 0 @@ -113,10 +119,12 @@ import ClientKit /// Hide statuses without ALT text. public var hideStatusesWithoutAlt = false - public func changeApplicationState(accountModel: AccountModel, instance: Instance?, lastSeenStatusId: String?) { + public func changeApplicationState(accountModel: AccountModel, instance: Instance?, lastSeenStatusId: String?, lastSeenNotificationId: String?) { self.account = accountModel + self.lastSeenNotificationId = lastSeenNotificationId self.lastSeenStatusId = lastSeenStatusId self.amountOfNewStatuses = 0 + self.newNotificationsHasBeenAdded = false if let statusesConfiguration = instance?.configuration?.statuses { self.statusMaxCharacters = statusesConfiguration.maxCharacters @@ -132,7 +140,9 @@ import ClientKit public func clearApplicationState() { self.account = nil self.lastSeenStatusId = nil + self.lastSeenNotificationId = nil self.amountOfNewStatuses = 0 + self.newNotificationsHasBeenAdded = false self.statusMaxCharacters = ApplicationState.defaults.statusMaxCharacters self.statusMaxMediaAttachments = ApplicationState.defaults.statusMaxMediaAttachments diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index abd8ea0..2711093 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -77,7 +77,6 @@ F8705A7B29FF872F00DA818A /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8705A7A29FF872F00DA818A /* QRCodeGenerator.swift */; }; F8705A7E29FF880600DA818A /* FileFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8705A7D29FF880600DA818A /* FileFetcher.swift */; }; F870EE5229F1645C00A2D43B /* MainNavigationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F870EE5129F1645C00A2D43B /* MainNavigationOptions.swift */; }; - F871F21D29EF0D7000A351EF /* NavigationMenuItemDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = F871F21C29EF0D7000A351EF /* NavigationMenuItemDetails.swift */; }; F8742FC429990AFB00E9642B /* ClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8742FC329990AFB00E9642B /* ClientError.swift */; }; F8764187298ABB520057D362 /* ViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8764186298ABB520057D362 /* ViewState.swift */; }; F876418D298AE5020057D362 /* PaginableStatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F876418C298AE5020057D362 /* PaginableStatusesView.swift */; }; @@ -159,6 +158,7 @@ F8D8E0CF2ACC23B300AA1374 /* ViewedStatusHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D8E0CE2ACC23B300AA1374 /* ViewedStatusHandler.swift */; }; F8D8E0D02ACC23B300AA1374 /* ViewedStatusHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D8E0CE2ACC23B300AA1374 /* ViewedStatusHandler.swift */; }; F8D8E0D12ACC23B300AA1374 /* ViewedStatusHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D8E0CE2ACC23B300AA1374 /* ViewedStatusHandler.swift */; }; + F8DE749F2AE4F7B500ACD188 /* NotificationsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8DE749E2AE4F7B500ACD188 /* NotificationsService.swift */; }; F8DF38E429DD68820047F1AA /* ViewOffsetKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */; }; F8DF38E629DDB98A0047F1AA /* SocialsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8DF38E529DDB98A0047F1AA /* SocialsSectionView.swift */; }; F8E36E462AB8745300769C55 /* Sizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E36E452AB8745300769C55 /* Sizable.swift */; }; @@ -274,7 +274,6 @@ F8705A7A29FF872F00DA818A /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = ""; }; F8705A7D29FF880600DA818A /* FileFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileFetcher.swift; sourceTree = ""; }; F870EE5129F1645C00A2D43B /* MainNavigationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationOptions.swift; sourceTree = ""; }; - F871F21C29EF0D7000A351EF /* NavigationMenuItemDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationMenuItemDetails.swift; sourceTree = ""; }; F8742FC329990AFB00E9642B /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = ""; }; F8764186298ABB520057D362 /* ViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewState.swift; sourceTree = ""; }; F876418C298AE5020057D362 /* PaginableStatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginableStatusesView.swift; sourceTree = ""; }; @@ -341,6 +340,7 @@ F8D5444229D4066C002225D6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; F8D8E0CA2ACC237000AA1374 /* ViewedStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewedStatus.swift; sourceTree = ""; }; F8D8E0CE2ACC23B300AA1374 /* ViewedStatusHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewedStatusHandler.swift; sourceTree = ""; }; + F8DE749E2AE4F7B500ACD188 /* NotificationsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsService.swift; sourceTree = ""; }; F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewOffsetKey.swift; sourceTree = ""; }; F8DF38E529DDB98A0047F1AA /* SocialsSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialsSectionView.swift; sourceTree = ""; }; F8E36E452AB8745300769C55 /* Sizable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sizable.swift; sourceTree = ""; }; @@ -479,7 +479,6 @@ F85D4DFD29B78C8400345267 /* HashtagModel.swift */, F89F57AF29D1C11200001EE3 /* RelationshipModel.swift */, F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */, - F871F21C29EF0D7000A351EF /* NavigationMenuItemDetails.swift */, F8624D3C29F2D3AC00204986 /* SelectedMenuItemDetails.swift */, F8E36E452AB8745300769C55 /* Sizable.swift */, F8B758DD2AB9DD85000C8068 /* ColumnData.swift */, @@ -773,6 +772,7 @@ F878842129A4A4E3003CFAD2 /* AppMetadataService.swift */, F86BC9E829EBBB66009415EC /* ImageSaver.swift */, F8D0E5232AE2A88A0061C561 /* HomeTimelineService.swift */, + F8DE749E2AE4F7B500ACD188 /* NotificationsService.swift */, ); path = Services; sourceTree = ""; @@ -1084,7 +1084,6 @@ F87AEB972986D16D00434FB6 /* AuthorisationError.swift in Sources */, F8742FC429990AFB00E9642B /* ClientError.swift in Sources */, F883402029B62AE900C3E096 /* SearchView.swift in Sources */, - F871F21D29EF0D7000A351EF /* NavigationMenuItemDetails.swift in Sources */, F8E36E462AB8745300769C55 /* Sizable.swift in Sources */, F85DBF8F296732E20069BF89 /* AccountsView.swift in Sources */, F805DCF129DBEF83006A1FD9 /* ReportView.swift in Sources */, @@ -1109,6 +1108,7 @@ F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */, F88C246E295C37B80006098B /* MainView.swift in Sources */, F8AFF7C429B25EF40087D083 /* ImagesGrid.swift in Sources */, + F8DE749F2AE4F7B500ACD188 /* NotificationsService.swift in Sources */, F8AFF7C129B259150087D083 /* HashtagsView.swift in Sources */, F8DF38E429DD68820047F1AA /* ViewOffsetKey.swift in Sources */, F89A46DE296EABA20062125F /* StatusPlaceholderView.swift in Sources */, diff --git a/Vernissage/Models/NavigationMenuItemDetails.swift b/Vernissage/Models/NavigationMenuItemDetails.swift deleted file mode 100644 index 819a667..0000000 --- a/Vernissage/Models/NavigationMenuItemDetails.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// https://mczachurski.dev -// Copyright © 2023 Marcin Czachurski and the repository contributors. -// Licensed under the Apache License 2.0. -// - -import Foundation -import SwiftUI - -@Observable class NavigationMenuItemDetails: Identifiable { - var title: LocalizedStringKey - var image: String - - var viewMode: MainView.ViewMode { - didSet { - self.title = viewMode.title - self.image = viewMode.image - } - } - - init(viewMode: MainView.ViewMode) { - self.viewMode = viewMode - self.title = viewMode.title - self.image = viewMode.image - } -} diff --git a/Vernissage/Models/SelectedMenuItemDetails.swift b/Vernissage/Models/SelectedMenuItemDetails.swift index 1cc8ac8..9bf5977 100644 --- a/Vernissage/Models/SelectedMenuItemDetails.swift +++ b/Vernissage/Models/SelectedMenuItemDetails.swift @@ -6,11 +6,12 @@ import Foundation -class SelectedMenuItemDetails: NavigationMenuItemDetails { +class SelectedMenuItemDetails: Identifiable { public let position: Int - + public var viewMode: MainView.ViewMode + init(position: Int, viewMode: MainView.ViewMode) { self.position = position - super.init(viewMode: viewMode) + self.viewMode = viewMode } } diff --git a/Vernissage/Services/HomeTimelineService.swift b/Vernissage/Services/HomeTimelineService.swift index 0c391f8..1d027f4 100644 --- a/Vernissage/Services/HomeTimelineService.swift +++ b/Vernissage/Services/HomeTimelineService.swift @@ -105,23 +105,7 @@ public class HomeTimelineService { return visibleStatuses } - - public func update(lastSeenStatusId: String?, lastLoadedStatusId: String?, statuses: [Status]? = nil, applicationState: ApplicationState, modelContext: ModelContext) throws { - guard let accountId = applicationState.account?.id else { - return - } - try AccountDataHandler.shared.update(lastSeenStatusId: lastSeenStatusId, - lastLoadedStatusId: lastLoadedStatusId, - statuses: statuses, - accountId: accountId, - modelContext: modelContext) - - if (applicationState.lastSeenStatusId ?? "0") < (lastSeenStatusId ?? "0") { - applicationState.lastSeenStatusId = lastSeenStatusId - } - } - private func hasBeenAlreadyOnTimeline(accountId: String, status: Status, modelContext: ModelContext) -> Bool { return ViewedStatusHandler.shared.hasBeenAlreadyOnTimeline(accountId: accountId, status: status, modelContext: modelContext) } diff --git a/Vernissage/Services/NotificationsService.swift b/Vernissage/Services/NotificationsService.swift new file mode 100644 index 0000000..6b78b68 --- /dev/null +++ b/Vernissage/Services/NotificationsService.swift @@ -0,0 +1,53 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import Foundation +import SwiftData +import PixelfedKit +import ClientKit +import ServicesKit +import Nuke +import OSLog +import EnvironmentKit +import Semaphore + +/// Service responsible for managing notifications. +@MainActor +public class NotificationsService { + public static let shared = NotificationsService() + private init() { } + + private let semaphore = AsyncSemaphore(value: 1) + + public func newNotificationsHasBeenAdded(for account: AccountModel, modelContext: ModelContext) async -> Bool { + await semaphore.wait() + defer { semaphore.signal() } + + guard let accessToken = account.accessToken else { + return false + } + + // Get maximimum downloaded stauts id. + guard let lastSeenNotificationId = self.getLastSeenNotificationId(accountId: account.id, modelContext: modelContext) else { + return false + } + + let client = PixelfedClient(baseURL: account.serverUrl).getAuthenticated(token: accessToken) + + do { + let linkableNotifications = try await client.notifications(minId: lastSeenNotificationId, limit: 5) + return linkableNotifications.data.first(where: { $0.id != lastSeenNotificationId }) != nil + } catch { + ErrorService.shared.handle(error, message: "notifications.error.loadingNotificationsFailed") + return false + } + } + + private func getLastSeenNotificationId(accountId: String, modelContext: ModelContext) -> String? { + let accountData = AccountDataHandler.shared.getAccountData(accountId: accountId, modelContext: modelContext) + return accountData?.lastSeenNotificationId + } +} diff --git a/Vernissage/VernissageApp.swift b/Vernissage/VernissageApp.swift index 439ecd8..7208cca 100644 --- a/Vernissage/VernissageApp.swift +++ b/Vernissage/VernissageApp.swift @@ -64,8 +64,8 @@ struct VernissageApp: App { .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 5) { Task { - // Refresh indicator of new photos when application is become active. - await self.calculateNewPhotosInBackground() + // Refresh indicator of new photos and new statuses when application is become active. + _ = await (self.calculateNewPhotosInBackground(), self.calculateNewNotificationsInBackground()) } } @@ -74,8 +74,8 @@ struct VernissageApp: App { } .onReceive(timer) { _ in Task { - // Refresh indicator of new photos each two minutes (when application is in the foreground).. - await self.calculateNewPhotosInBackground() + // Refresh indicator of new photos and new notifications each two minutes (when application is in the foreground).. + _ = await (self.calculateNewPhotosInBackground(), self.calculateNewNotificationsInBackground()) } } .onChange(of: applicationState.theme) { oldValue, newValue in @@ -155,14 +155,15 @@ struct VernissageApp: App { // Refresh application state. self.applicationState.changeApplicationState(accountModel: accountModel, instance: instance, - lastSeenStatusId: accountModel.lastSeenStatusId) + lastSeenStatusId: accountModel.lastSeenStatusId, + lastSeenNotificationId: accountModel.lastSeenNotificationId) // Change view displayed by application. self.applicationViewMode = .mainView // Check amount of newly added photos. if checkNewPhotos { - await self.calculateNewPhotosInBackground() + _ = await (self.calculateNewPhotosInBackground(), self.calculateNewNotificationsInBackground()) } } } @@ -211,7 +212,7 @@ struct VernissageApp: App { } private func calculateNewPhotosInBackground() async { - let modelContext = self.modelContainer.mainContext + let modelContext = self.modelContainer.mainContext if let account = self.applicationState.account { self.applicationState.amountOfNewStatuses = await HomeTimelineService.shared.amountOfNewStatuses( @@ -222,4 +223,12 @@ struct VernissageApp: App { ) } } + + private func calculateNewNotificationsInBackground() async { + let modelContext = self.modelContainer.mainContext + + if let account = self.applicationState.account { + self.applicationState.newNotificationsHasBeenAdded = await NotificationsService.shared.newNotificationsHasBeenAdded(for: account, modelContext: modelContext) + } + } } diff --git a/Vernissage/ViewModifiers/NavigationMenuButtons.swift b/Vernissage/ViewModifiers/NavigationMenuButtons.swift index 7358db7..ff0a1f1 100644 --- a/Vernissage/ViewModifiers/NavigationMenuButtons.swift +++ b/Vernissage/ViewModifiers/NavigationMenuButtons.swift @@ -21,6 +21,7 @@ extension View { @MainActor private struct NavigationMenuButtons: ViewModifier { + @Environment(ApplicationState.self) var applicationState @Environment(RouterPath.self) var routerPath @Environment(\.modelContext) private var modelContext @@ -28,13 +29,13 @@ private struct NavigationMenuButtons: ViewModifier { private let onViewModeIconTap: (MainView.ViewMode) -> Void private let imageFontSize = 20.0 - private let customMenuItems = [ - NavigationMenuItemDetails(viewMode: .home), - NavigationMenuItemDetails(viewMode: .local), - NavigationMenuItemDetails(viewMode: .federated), - NavigationMenuItemDetails(viewMode: .search), - NavigationMenuItemDetails(viewMode: .profile), - NavigationMenuItemDetails(viewMode: .notifications) + private let customMenuItems: [MainView.ViewMode] = [ + .home, + .local, + .federated, + .search, + .profile, + .notifications ] @State private var displayedCustomMenuItems = [ @@ -168,7 +169,7 @@ private struct NavigationMenuButtons: ViewModifier { Button { self.onViewModeIconTap(displayedCustomMenuItem.viewMode) } label: { - Image(systemName: displayedCustomMenuItem.image) + displayedCustomMenuItem.viewMode.getImage(applicationState: applicationState) .font(.system(size: self.imageFontSize)) .foregroundColor(.mainTextColor.opacity(0.75)) .padding(.vertical, 10) @@ -183,24 +184,28 @@ private struct NavigationMenuButtons: ViewModifier { ForEach(self.customMenuItems) { item in Button { withAnimation { - displayedCustomMenuItem.viewMode = item.viewMode + displayedCustomMenuItem.viewMode = item } - // Saving in core data. + // Saving in database. switch displayedCustomMenuItem.position { case 1: - ApplicationSettingsHandler.shared.set(customNavigationMenuItem1: item.viewMode.rawValue, modelContext: modelContext) + ApplicationSettingsHandler.shared.set(customNavigationMenuItem1: item.rawValue, modelContext: modelContext) case 2: - ApplicationSettingsHandler.shared.set(customNavigationMenuItem2: item.viewMode.rawValue, modelContext: modelContext) + ApplicationSettingsHandler.shared.set(customNavigationMenuItem2: item.rawValue, modelContext: modelContext) case 3: - ApplicationSettingsHandler.shared.set(customNavigationMenuItem3: item.viewMode.rawValue, modelContext: modelContext) + ApplicationSettingsHandler.shared.set(customNavigationMenuItem3: item.rawValue, modelContext: modelContext) default: break } self.hiddenMenuItems = self.displayedCustomMenuItems.map({ $0.viewMode }) } label: { - Label(item.title, systemImage: item.image) + Label { + Text(item.title, comment: "Menu item") + } icon: { + item.getImage(applicationState: applicationState) + } } } } @@ -217,10 +222,8 @@ private struct NavigationMenuButtons: ViewModifier { private func setCustomMenuItem(position: Int, viewMode: MainView.ViewMode) { if let displayedCustomMenuItem = self.displayedCustomMenuItems.first(where: { $0.position == position }), - let customMenuItem = self.customMenuItems.first(where: { $0.viewMode == viewMode }) { - displayedCustomMenuItem.title = customMenuItem.title - displayedCustomMenuItem.viewMode = customMenuItem.viewMode - displayedCustomMenuItem.image = customMenuItem.image + let customMenuItem = self.customMenuItems.first(where: { $0 == viewMode }) { + displayedCustomMenuItem.viewMode = customMenuItem } } } diff --git a/Vernissage/Views/HomeTimelineView.swift b/Vernissage/Views/HomeTimelineView.swift index 593b5d2..da5f661 100644 --- a/Vernissage/Views/HomeTimelineView.swift +++ b/Vernissage/Views/HomeTimelineView.swift @@ -197,10 +197,10 @@ struct HomeTimelineView: View { modelContext: modelContext) // Remeber first status returned by API in user context (when it's newer then remembered). - try HomeTimelineService.shared.update(lastSeenStatusId: nil, - lastLoadedStatusId: statuses.first?.id, - applicationState: self.applicationState, - modelContext: modelContext) + try AccountDataHandler.shared.update(lastSeenStatusId: nil, + lastLoadedStatusId: statuses.first?.id, + applicationState: self.applicationState, + modelContext: modelContext) // Append statuses to viewed. try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext) @@ -274,11 +274,11 @@ struct HomeTimelineView: View { modelContext: modelContext) // Remeber first status returned by API in user context (when it's newer then remembered). - try HomeTimelineService.shared.update(lastSeenStatusId: self.statusViewModels.first?.id, - lastLoadedStatusId: statuses.first?.id, - statuses: statuses, - applicationState: self.applicationState, - modelContext: modelContext) + try AccountDataHandler.shared.update(lastSeenStatusId: self.statusViewModels.first?.id, + lastLoadedStatusId: statuses.first?.id, + statuses: statuses, + applicationState: self.applicationState, + modelContext: modelContext) // Append statuses to viewed. try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext) diff --git a/Vernissage/Views/MainView.swift b/Vernissage/Views/MainView.swift index 5ae5879..3258098 100644 --- a/Vernissage/Views/MainView.swift +++ b/Vernissage/Views/MainView.swift @@ -33,7 +33,7 @@ struct MainView: View { @Query(sort: \AccountData.acct, order: .forward) var dbAccounts: [AccountData] - public enum ViewMode: Int { + public enum ViewMode: Int, Identifiable { case home = 1 case local = 2 case federated = 3 @@ -44,6 +44,10 @@ struct MainView: View { case trendingTags = 8 case trendingAccounts = 9 + var id: Self { + return self + } + public var title: LocalizedStringKey { switch self { case .home: @@ -67,26 +71,36 @@ struct MainView: View { } } - public var image: String { + @ViewBuilder + public func getImage(applicationState: ApplicationState) -> some View { switch self { case .home: - return "house" + Image(systemName: "house") case .trendingPhotos: - return "photo.stack" + Image(systemName: "photo.stack") case .trendingTags: - return "tag" + Image(systemName: "tag") case .trendingAccounts: - return "person.3" + Image(systemName: "person.3") case .local: - return "building" + Image(systemName: "building") case .federated: - return "globe.europe.africa" + Image(systemName: "globe.europe.africa") case .profile: - return "person.crop.circle" + Image(systemName: "person.crop.circle") case .notifications: - return "bell.badge" + if applicationState.menuPosition == .top { + applicationState.newNotificationsHasBeenAdded ? Image(systemName: "bell.badge") : Image(systemName: "bell") + } else { + applicationState.newNotificationsHasBeenAdded + ? AnyView( + Image(systemName: "bell.badge") + .symbolRenderingMode(.palette) + .foregroundStyle(applicationState.tintColor.color().opacity(0.75), Color.mainTextColor.opacity(0.75))) + : AnyView(Image(systemName: "bell")) + } case .search: - return "magnifyingglass" + Image(systemName: "magnifyingglass") } } } @@ -309,7 +323,8 @@ struct MainView: View { // Refresh application state. self.applicationState.changeApplicationState(accountModel: signedInAccountModel, instance: instance, - lastSeenStatusId: signedInAccountModel.lastSeenStatusId) + lastSeenStatusId: signedInAccountModel.lastSeenStatusId, + lastSeenNotificationId: signedInAccountModel.lastSeenNotificationId) // Set account as default (application will open this account after restart). ApplicationSettingsHandler.shared.set(accountId: signedInAccountModel.id, modelContext: modelContext) diff --git a/Vernissage/Views/NotificationsView/NotificationsView.swift b/Vernissage/Views/NotificationsView/NotificationsView.swift index 5e005d4..56979c0 100644 --- a/Vernissage/Views/NotificationsView/NotificationsView.swift +++ b/Vernissage/Views/NotificationsView/NotificationsView.swift @@ -15,6 +15,7 @@ import WidgetsKit struct NotificationsView: View { @Environment(ApplicationState.self) var applicationState @Environment(Client.self) var client + @Environment(\.modelContext) private var modelContext @State var accountId: String @State private var notifications: [PixelfedKit.Notification] = [] @@ -76,7 +77,7 @@ struct NotificationsView: View { .listStyle(PlainListStyle()) .refreshable { HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) - await self.loadNewNotifications() + await self.refreshNotifications() HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) } } @@ -95,6 +96,9 @@ struct NotificationsView: View { withAnimation { self.state = .loaded } + + try AccountDataHandler.shared.update(lastSeenNotificationId: linkable.data.first?.id, applicationState: self.applicationState, modelContext: modelContext) + self.applicationState.newNotificationsHasBeenAdded = false } } catch { if !Task.isCancelled { @@ -122,7 +126,7 @@ struct NotificationsView: View { } } - private func loadNewNotifications() async { + private func refreshNotifications() async { do { if let linkable = try await self.client.notifications?.notifications(minId: self.minId, limit: self.defaultPageSize) { if let first = linkable.data.first, self.notifications.contains(where: { notification in notification.id == first.id }) { @@ -130,6 +134,9 @@ struct NotificationsView: View { return } + try AccountDataHandler.shared.update(lastSeenNotificationId: linkable.data.first?.id, applicationState: self.applicationState, modelContext: modelContext) + self.applicationState.newNotificationsHasBeenAdded = false + self.minId = linkable.link?.minId self.notifications.insert(contentsOf: linkable.data, at: 0) } diff --git a/Vernissage/Views/StatusesView.swift b/Vernissage/Views/StatusesView.swift index f5e38c8..6295d69 100644 --- a/Vernissage/Views/StatusesView.swift +++ b/Vernissage/Views/StatusesView.swift @@ -198,10 +198,10 @@ struct StatusesView: View { if self.listType == .home { // Remeber first status returned by API in user context (when it's newer then remembered). - try HomeTimelineService.shared.update(lastSeenStatusId: nil, - lastLoadedStatusId: statuses.first?.id, - applicationState: self.applicationState, - modelContext: modelContext) + try AccountDataHandler.shared.update(lastSeenStatusId: nil, + lastLoadedStatusId: statuses.first?.id, + applicationState: self.applicationState, + modelContext: modelContext) // Append statuses to viewed. try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext) @@ -276,10 +276,10 @@ struct StatusesView: View { if self.listType == .home { // Remeber first status returned by API in user context (when it's newer then remembered). - try HomeTimelineService.shared.update(lastSeenStatusId: self.statusViewModels.first?.id, - lastLoadedStatusId: statuses.first?.id, - applicationState: self.applicationState, - modelContext: modelContext) + try AccountDataHandler.shared.update(lastSeenStatusId: self.statusViewModels.first?.id, + lastLoadedStatusId: statuses.first?.id, + applicationState: self.applicationState, + modelContext: modelContext) // Append statuses to viewed. try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext) diff --git a/Vernissage/Widgets/MainNavigationOptions.swift b/Vernissage/Widgets/MainNavigationOptions.swift index 7fa10e7..e79a3b0 100644 --- a/Vernissage/Widgets/MainNavigationOptions.swift +++ b/Vernissage/Widgets/MainNavigationOptions.swift @@ -6,8 +6,11 @@ import Foundation import SwiftUI +import EnvironmentKit struct MainNavigationOptions: View { + @Environment(ApplicationState.self) var applicationState + let onViewModeIconTap: (MainView.ViewMode) -> Void @Binding var hiddenMenuItems: [MainView.ViewMode] @@ -24,7 +27,7 @@ struct MainNavigationOptions: View { } label: { HStack { Text(MainView.ViewMode.home.title) - Image(systemName: MainView.ViewMode.home.image) + MainView.ViewMode.home.getImage(applicationState: applicationState) } } } @@ -35,7 +38,7 @@ struct MainNavigationOptions: View { } label: { HStack { Text(MainView.ViewMode.local.title) - Image(systemName: MainView.ViewMode.local.image) + MainView.ViewMode.local.getImage(applicationState: applicationState) } } } @@ -46,7 +49,7 @@ struct MainNavigationOptions: View { } label: { HStack { Text(MainView.ViewMode.federated.title) - Image(systemName: MainView.ViewMode.federated.image) + MainView.ViewMode.federated.getImage(applicationState: applicationState) } } } @@ -57,7 +60,7 @@ struct MainNavigationOptions: View { } label: { HStack { Text(MainView.ViewMode.search.title) - Image(systemName: MainView.ViewMode.search.image) + MainView.ViewMode.search.getImage(applicationState: applicationState) } } } @@ -70,7 +73,7 @@ struct MainNavigationOptions: View { } label: { HStack { Text(MainView.ViewMode.trendingPhotos.title) - Image(systemName: MainView.ViewMode.trendingPhotos.image) + MainView.ViewMode.trendingPhotos.getImage(applicationState: applicationState) } } @@ -79,7 +82,7 @@ struct MainNavigationOptions: View { } label: { HStack { Text(MainView.ViewMode.trendingTags.title) - Image(systemName: MainView.ViewMode.trendingTags.image) + MainView.ViewMode.trendingTags.getImage(applicationState: applicationState) } } @@ -88,7 +91,7 @@ struct MainNavigationOptions: View { } label: { HStack { Text(MainView.ViewMode.trendingAccounts.title) - Image(systemName: MainView.ViewMode.trendingAccounts.image) + MainView.ViewMode.trendingAccounts.getImage(applicationState: applicationState) } } } label: { @@ -106,7 +109,7 @@ struct MainNavigationOptions: View { } label: { HStack { Text(MainView.ViewMode.profile.title) - Image(systemName: MainView.ViewMode.profile.image) + MainView.ViewMode.profile.getImage(applicationState: applicationState) } } } @@ -117,7 +120,7 @@ struct MainNavigationOptions: View { } label: { HStack { Text(MainView.ViewMode.notifications.title) - Image(systemName: MainView.ViewMode.notifications.image) + MainView.ViewMode.notifications.getImage(applicationState: applicationState) } } } diff --git a/VernissageShare/ShareViewController.swift b/VernissageShare/ShareViewController.swift index 5f71aa2..0d221b3 100644 --- a/VernissageShare/ShareViewController.swift +++ b/VernissageShare/ShareViewController.swift @@ -40,7 +40,8 @@ class ShareViewController: UIViewController { // Set application state (with default instance settings). applicationState.changeApplicationState(accountModel: accountModel, instance: nil, - lastSeenStatusId: accountModel.lastSeenStatusId) + lastSeenStatusId: accountModel.lastSeenStatusId, + lastSeenNotificationId: accountModel.lastSeenNotificationId) // Update application settings from database. ApplicationSettingsHandler.shared.update(applicationState: applicationState, modelContext: modelContext)