Add indicator to notifications icon

This commit is contained in:
Marcin Czachurski 2023-10-22 10:09:02 +02:00
parent 06af34d4c8
commit 02c62b86d9
17 changed files with 249 additions and 122 deletions

View File

@ -27,7 +27,8 @@ import Foundation
public let url: URL? public let url: URL?
public let username: String public let username: String
public let lastSeenStatusId: String? public let lastSeenStatusId: String?
public let lastSeenNotificationId: String?
public var avatarData: Data? public var avatarData: Data?
public init(id: String, public init(id: String,
@ -50,6 +51,7 @@ import Foundation
url: URL?, url: URL?,
username: String, username: String,
lastSeenStatusId: String?, lastSeenStatusId: String?,
lastSeenNotificationId: String?,
avatarData: Data? = nil) { avatarData: Data? = nil) {
self.id = id self.id = id
self.accessToken = accessToken self.accessToken = accessToken
@ -72,6 +74,7 @@ import Foundation
self.username = username self.username = username
self.lastSeenStatusId = lastSeenStatusId self.lastSeenStatusId = lastSeenStatusId
self.avatarData = avatarData self.avatarData = avatarData
self.lastSeenNotificationId = lastSeenNotificationId
} }
} }

View File

@ -10,24 +10,62 @@ import ClientKit
@Model final public class AccountData { @Model final public class AccountData {
@Attribute(.unique) public var id: String @Attribute(.unique) public var id: String
/// Access token to the server API.
public var accessToken: String? public var accessToken: String?
/// Refresh token which can be used to download new access token.
public var refreshToken: String? public var refreshToken: String?
/// Full user name (user name with server address).
public var acct: String public var acct: String
/// URL to user avatar.
public var avatar: URL? 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 public var clientId: String
/// Secret of OAutch client.
public var clientSecret: String public var clientSecret: String
/// Vapid key of OAuth client.
public var clientVapidKey: String public var clientVapidKey: String
/// Date of creating user.
public var createdAt: String public var createdAt: String
/// Human readable user name.
public var displayName: String? public var displayName: String?
/// Number of followers.
public var followersCount: Int32 public var followersCount: Int32
/// Number of following users.
public var followingCount: Int32 public var followingCount: Int32
/// URL to header image visible on user profile.
public var header: URL? public var header: URL?
/// User profile is locked.
public var locked: Bool public var locked: Bool
/// Description on user profile.
public var note: String? public var note: String?
/// Address to server.
public var serverUrl: URL public var serverUrl: URL
/// NUmber of statuses added by the user.
public var statusesCount: Int32 public var statusesCount: Int32
/// Url to user profile.
public var url: URL? public var url: URL?
/// User name (without server address).
public var username: String public var username: String
/// Last status seen on home timeline by the user. /// Last status seen on home timeline by the user.
@ -39,10 +77,12 @@ import ClientKit
/// JSON string with last objects loaded into home timeline. /// JSON string with last objects loaded into home timeline.
public var timelineCache: String? 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: \ViewedStatus.pixelfedAccount) public var viewedStatuses: [ViewedStatus]
@Relationship(deleteRule: .cascade, inverse: \AccountRelationship.pixelfedAccount) public var accountRelationships: [AccountRelationship] @Relationship(deleteRule: .cascade, inverse: \AccountRelationship.pixelfedAccount) public var accountRelationships: [AccountRelationship]
init( init(
accessToken: String? = nil, accessToken: String? = nil,
refreshToken: String? = nil, refreshToken: String? = nil,
@ -119,6 +159,7 @@ extension AccountData {
url: self.url, url: self.url,
username: self.username, username: self.username,
lastSeenStatusId: self.lastSeenStatusId, lastSeenStatusId: self.lastSeenStatusId,
lastSeenNotificationId: self.lastSeenNotificationId,
avatarData: self.avatarData) avatarData: self.avatarData)
return accountModel return accountModel
} }

View File

@ -7,6 +7,7 @@
import Foundation import Foundation
import SwiftData import SwiftData
import PixelfedKit import PixelfedKit
import EnvironmentKit
class AccountDataHandler { class AccountDataHandler {
public static let shared = 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 { guard let accountDataFromDb = self.getAccountData(accountId: accountId, modelContext: modelContext) else {
return return
} }
if (accountDataFromDb.lastSeenStatusId ?? "0") < (lastSeenStatusId ?? "0") { if (accountDataFromDb.lastSeenStatusId ?? "0") < (lastSeenStatusId ?? "0") {
accountDataFromDb.lastSeenStatusId = lastSeenStatusId accountDataFromDb.lastSeenStatusId = lastSeenStatusId
applicationState.lastSeenStatusId = lastSeenStatusId
} }
if (accountDataFromDb.lastLoadedStatusId ?? "0") < (lastLoadedStatusId ?? "0") { if (accountDataFromDb.lastLoadedStatusId ?? "0") < (lastLoadedStatusId ?? "0") {
@ -82,4 +88,21 @@ class AccountDataHandler {
try modelContext.save() 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()
}
} }

View File

@ -35,9 +35,15 @@ import ClientKit
/// Each URL in a status will be assumed to be exactly this many characters. /// Each URL in a status will be assumed to be exactly this many characters.
public private(set) var statusCharactersReservedPerUrl = defaults.statusCharactersReservedPerUrl 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. /// Last status seen by the user.
public var lastSeenStatusId: String? public var lastSeenStatusId: String?
/// Amount of new statuses which are not displayed yet to the user. /// Amount of new statuses which are not displayed yet to the user.
public var amountOfNewStatuses = 0 public var amountOfNewStatuses = 0
@ -113,10 +119,12 @@ import ClientKit
/// Hide statuses without ALT text. /// Hide statuses without ALT text.
public var hideStatusesWithoutAlt = false 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.account = accountModel
self.lastSeenNotificationId = lastSeenNotificationId
self.lastSeenStatusId = lastSeenStatusId self.lastSeenStatusId = lastSeenStatusId
self.amountOfNewStatuses = 0 self.amountOfNewStatuses = 0
self.newNotificationsHasBeenAdded = false
if let statusesConfiguration = instance?.configuration?.statuses { if let statusesConfiguration = instance?.configuration?.statuses {
self.statusMaxCharacters = statusesConfiguration.maxCharacters self.statusMaxCharacters = statusesConfiguration.maxCharacters
@ -132,7 +140,9 @@ import ClientKit
public func clearApplicationState() { public func clearApplicationState() {
self.account = nil self.account = nil
self.lastSeenStatusId = nil self.lastSeenStatusId = nil
self.lastSeenNotificationId = nil
self.amountOfNewStatuses = 0 self.amountOfNewStatuses = 0
self.newNotificationsHasBeenAdded = false
self.statusMaxCharacters = ApplicationState.defaults.statusMaxCharacters self.statusMaxCharacters = ApplicationState.defaults.statusMaxCharacters
self.statusMaxMediaAttachments = ApplicationState.defaults.statusMaxMediaAttachments self.statusMaxMediaAttachments = ApplicationState.defaults.statusMaxMediaAttachments

View File

@ -77,7 +77,6 @@
F8705A7B29FF872F00DA818A /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8705A7A29FF872F00DA818A /* QRCodeGenerator.swift */; }; F8705A7B29FF872F00DA818A /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8705A7A29FF872F00DA818A /* QRCodeGenerator.swift */; };
F8705A7E29FF880600DA818A /* FileFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8705A7D29FF880600DA818A /* FileFetcher.swift */; }; F8705A7E29FF880600DA818A /* FileFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8705A7D29FF880600DA818A /* FileFetcher.swift */; };
F870EE5229F1645C00A2D43B /* MainNavigationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F870EE5129F1645C00A2D43B /* MainNavigationOptions.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 */; }; F8742FC429990AFB00E9642B /* ClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8742FC329990AFB00E9642B /* ClientError.swift */; };
F8764187298ABB520057D362 /* ViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8764186298ABB520057D362 /* ViewState.swift */; }; F8764187298ABB520057D362 /* ViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8764186298ABB520057D362 /* ViewState.swift */; };
F876418D298AE5020057D362 /* PaginableStatusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F876418C298AE5020057D362 /* PaginableStatusesView.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 */; }; F8D8E0CF2ACC23B300AA1374 /* ViewedStatusHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D8E0CE2ACC23B300AA1374 /* ViewedStatusHandler.swift */; };
F8D8E0D02ACC23B300AA1374 /* 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 */; }; 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 */; }; F8DF38E429DD68820047F1AA /* ViewOffsetKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */; };
F8DF38E629DDB98A0047F1AA /* SocialsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8DF38E529DDB98A0047F1AA /* SocialsSectionView.swift */; }; F8DF38E629DDB98A0047F1AA /* SocialsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8DF38E529DDB98A0047F1AA /* SocialsSectionView.swift */; };
F8E36E462AB8745300769C55 /* Sizable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E36E452AB8745300769C55 /* Sizable.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 = "<group>"; }; F8705A7A29FF872F00DA818A /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = "<group>"; };
F8705A7D29FF880600DA818A /* FileFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileFetcher.swift; sourceTree = "<group>"; }; F8705A7D29FF880600DA818A /* FileFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileFetcher.swift; sourceTree = "<group>"; };
F870EE5129F1645C00A2D43B /* MainNavigationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationOptions.swift; sourceTree = "<group>"; }; F870EE5129F1645C00A2D43B /* MainNavigationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavigationOptions.swift; sourceTree = "<group>"; };
F871F21C29EF0D7000A351EF /* NavigationMenuItemDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationMenuItemDetails.swift; sourceTree = "<group>"; };
F8742FC329990AFB00E9642B /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = "<group>"; }; F8742FC329990AFB00E9642B /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = "<group>"; };
F8764186298ABB520057D362 /* ViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewState.swift; sourceTree = "<group>"; }; F8764186298ABB520057D362 /* ViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewState.swift; sourceTree = "<group>"; };
F876418C298AE5020057D362 /* PaginableStatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginableStatusesView.swift; sourceTree = "<group>"; }; F876418C298AE5020057D362 /* PaginableStatusesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginableStatusesView.swift; sourceTree = "<group>"; };
@ -341,6 +340,7 @@
F8D5444229D4066C002225D6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; F8D5444229D4066C002225D6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
F8D8E0CA2ACC237000AA1374 /* ViewedStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewedStatus.swift; sourceTree = "<group>"; }; F8D8E0CA2ACC237000AA1374 /* ViewedStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewedStatus.swift; sourceTree = "<group>"; };
F8D8E0CE2ACC23B300AA1374 /* ViewedStatusHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewedStatusHandler.swift; sourceTree = "<group>"; }; F8D8E0CE2ACC23B300AA1374 /* ViewedStatusHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewedStatusHandler.swift; sourceTree = "<group>"; };
F8DE749E2AE4F7B500ACD188 /* NotificationsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsService.swift; sourceTree = "<group>"; };
F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewOffsetKey.swift; sourceTree = "<group>"; }; F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewOffsetKey.swift; sourceTree = "<group>"; };
F8DF38E529DDB98A0047F1AA /* SocialsSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialsSectionView.swift; sourceTree = "<group>"; }; F8DF38E529DDB98A0047F1AA /* SocialsSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialsSectionView.swift; sourceTree = "<group>"; };
F8E36E452AB8745300769C55 /* Sizable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sizable.swift; sourceTree = "<group>"; }; F8E36E452AB8745300769C55 /* Sizable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sizable.swift; sourceTree = "<group>"; };
@ -479,7 +479,6 @@
F85D4DFD29B78C8400345267 /* HashtagModel.swift */, F85D4DFD29B78C8400345267 /* HashtagModel.swift */,
F89F57AF29D1C11200001EE3 /* RelationshipModel.swift */, F89F57AF29D1C11200001EE3 /* RelationshipModel.swift */,
F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */, F8DF38E329DD68820047F1AA /* ViewOffsetKey.swift */,
F871F21C29EF0D7000A351EF /* NavigationMenuItemDetails.swift */,
F8624D3C29F2D3AC00204986 /* SelectedMenuItemDetails.swift */, F8624D3C29F2D3AC00204986 /* SelectedMenuItemDetails.swift */,
F8E36E452AB8745300769C55 /* Sizable.swift */, F8E36E452AB8745300769C55 /* Sizable.swift */,
F8B758DD2AB9DD85000C8068 /* ColumnData.swift */, F8B758DD2AB9DD85000C8068 /* ColumnData.swift */,
@ -773,6 +772,7 @@
F878842129A4A4E3003CFAD2 /* AppMetadataService.swift */, F878842129A4A4E3003CFAD2 /* AppMetadataService.swift */,
F86BC9E829EBBB66009415EC /* ImageSaver.swift */, F86BC9E829EBBB66009415EC /* ImageSaver.swift */,
F8D0E5232AE2A88A0061C561 /* HomeTimelineService.swift */, F8D0E5232AE2A88A0061C561 /* HomeTimelineService.swift */,
F8DE749E2AE4F7B500ACD188 /* NotificationsService.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1084,7 +1084,6 @@
F87AEB972986D16D00434FB6 /* AuthorisationError.swift in Sources */, F87AEB972986D16D00434FB6 /* AuthorisationError.swift in Sources */,
F8742FC429990AFB00E9642B /* ClientError.swift in Sources */, F8742FC429990AFB00E9642B /* ClientError.swift in Sources */,
F883402029B62AE900C3E096 /* SearchView.swift in Sources */, F883402029B62AE900C3E096 /* SearchView.swift in Sources */,
F871F21D29EF0D7000A351EF /* NavigationMenuItemDetails.swift in Sources */,
F8E36E462AB8745300769C55 /* Sizable.swift in Sources */, F8E36E462AB8745300769C55 /* Sizable.swift in Sources */,
F85DBF8F296732E20069BF89 /* AccountsView.swift in Sources */, F85DBF8F296732E20069BF89 /* AccountsView.swift in Sources */,
F805DCF129DBEF83006A1FD9 /* ReportView.swift in Sources */, F805DCF129DBEF83006A1FD9 /* ReportView.swift in Sources */,
@ -1109,6 +1108,7 @@
F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */, F8A93D7E2965FD89001D8331 /* UserProfileView.swift in Sources */,
F88C246E295C37B80006098B /* MainView.swift in Sources */, F88C246E295C37B80006098B /* MainView.swift in Sources */,
F8AFF7C429B25EF40087D083 /* ImagesGrid.swift in Sources */, F8AFF7C429B25EF40087D083 /* ImagesGrid.swift in Sources */,
F8DE749F2AE4F7B500ACD188 /* NotificationsService.swift in Sources */,
F8AFF7C129B259150087D083 /* HashtagsView.swift in Sources */, F8AFF7C129B259150087D083 /* HashtagsView.swift in Sources */,
F8DF38E429DD68820047F1AA /* ViewOffsetKey.swift in Sources */, F8DF38E429DD68820047F1AA /* ViewOffsetKey.swift in Sources */,
F89A46DE296EABA20062125F /* StatusPlaceholderView.swift in Sources */, F89A46DE296EABA20062125F /* StatusPlaceholderView.swift in Sources */,

View File

@ -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
}
}

View File

@ -6,11 +6,12 @@
import Foundation import Foundation
class SelectedMenuItemDetails: NavigationMenuItemDetails { class SelectedMenuItemDetails: Identifiable {
public let position: Int public let position: Int
public var viewMode: MainView.ViewMode
init(position: Int, viewMode: MainView.ViewMode) { init(position: Int, viewMode: MainView.ViewMode) {
self.position = position self.position = position
super.init(viewMode: viewMode) self.viewMode = viewMode
} }
} }

View File

@ -105,23 +105,7 @@ public class HomeTimelineService {
return visibleStatuses 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 { private func hasBeenAlreadyOnTimeline(accountId: String, status: Status, modelContext: ModelContext) -> Bool {
return ViewedStatusHandler.shared.hasBeenAlreadyOnTimeline(accountId: accountId, status: status, modelContext: modelContext) return ViewedStatusHandler.shared.hasBeenAlreadyOnTimeline(accountId: accountId, status: status, modelContext: modelContext)
} }

View File

@ -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
}
}

View File

@ -64,8 +64,8 @@ struct VernissageApp: App {
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
Task { Task {
// Refresh indicator of new photos when application is become active. // Refresh indicator of new photos and new statuses when application is become active.
await self.calculateNewPhotosInBackground() _ = await (self.calculateNewPhotosInBackground(), self.calculateNewNotificationsInBackground())
} }
} }
@ -74,8 +74,8 @@ struct VernissageApp: App {
} }
.onReceive(timer) { _ in .onReceive(timer) { _ in
Task { Task {
// Refresh indicator of new photos each two minutes (when application is in the foreground).. // Refresh indicator of new photos and new notifications each two minutes (when application is in the foreground)..
await self.calculateNewPhotosInBackground() _ = await (self.calculateNewPhotosInBackground(), self.calculateNewNotificationsInBackground())
} }
} }
.onChange(of: applicationState.theme) { oldValue, newValue in .onChange(of: applicationState.theme) { oldValue, newValue in
@ -155,14 +155,15 @@ struct VernissageApp: App {
// Refresh application state. // Refresh application state.
self.applicationState.changeApplicationState(accountModel: accountModel, self.applicationState.changeApplicationState(accountModel: accountModel,
instance: instance, instance: instance,
lastSeenStatusId: accountModel.lastSeenStatusId) lastSeenStatusId: accountModel.lastSeenStatusId,
lastSeenNotificationId: accountModel.lastSeenNotificationId)
// Change view displayed by application. // Change view displayed by application.
self.applicationViewMode = .mainView self.applicationViewMode = .mainView
// Check amount of newly added photos. // Check amount of newly added photos.
if checkNewPhotos { if checkNewPhotos {
await self.calculateNewPhotosInBackground() _ = await (self.calculateNewPhotosInBackground(), self.calculateNewNotificationsInBackground())
} }
} }
} }
@ -211,7 +212,7 @@ struct VernissageApp: App {
} }
private func calculateNewPhotosInBackground() async { private func calculateNewPhotosInBackground() async {
let modelContext = self.modelContainer.mainContext let modelContext = self.modelContainer.mainContext
if let account = self.applicationState.account { if let account = self.applicationState.account {
self.applicationState.amountOfNewStatuses = await HomeTimelineService.shared.amountOfNewStatuses( 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)
}
}
} }

View File

@ -21,6 +21,7 @@ extension View {
@MainActor @MainActor
private struct NavigationMenuButtons: ViewModifier { private struct NavigationMenuButtons: ViewModifier {
@Environment(ApplicationState.self) var applicationState
@Environment(RouterPath.self) var routerPath @Environment(RouterPath.self) var routerPath
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@ -28,13 +29,13 @@ private struct NavigationMenuButtons: ViewModifier {
private let onViewModeIconTap: (MainView.ViewMode) -> Void private let onViewModeIconTap: (MainView.ViewMode) -> Void
private let imageFontSize = 20.0 private let imageFontSize = 20.0
private let customMenuItems = [ private let customMenuItems: [MainView.ViewMode] = [
NavigationMenuItemDetails(viewMode: .home), .home,
NavigationMenuItemDetails(viewMode: .local), .local,
NavigationMenuItemDetails(viewMode: .federated), .federated,
NavigationMenuItemDetails(viewMode: .search), .search,
NavigationMenuItemDetails(viewMode: .profile), .profile,
NavigationMenuItemDetails(viewMode: .notifications) .notifications
] ]
@State private var displayedCustomMenuItems = [ @State private var displayedCustomMenuItems = [
@ -168,7 +169,7 @@ private struct NavigationMenuButtons: ViewModifier {
Button { Button {
self.onViewModeIconTap(displayedCustomMenuItem.viewMode) self.onViewModeIconTap(displayedCustomMenuItem.viewMode)
} label: { } label: {
Image(systemName: displayedCustomMenuItem.image) displayedCustomMenuItem.viewMode.getImage(applicationState: applicationState)
.font(.system(size: self.imageFontSize)) .font(.system(size: self.imageFontSize))
.foregroundColor(.mainTextColor.opacity(0.75)) .foregroundColor(.mainTextColor.opacity(0.75))
.padding(.vertical, 10) .padding(.vertical, 10)
@ -183,24 +184,28 @@ private struct NavigationMenuButtons: ViewModifier {
ForEach(self.customMenuItems) { item in ForEach(self.customMenuItems) { item in
Button { Button {
withAnimation { withAnimation {
displayedCustomMenuItem.viewMode = item.viewMode displayedCustomMenuItem.viewMode = item
} }
// Saving in core data. // Saving in database.
switch displayedCustomMenuItem.position { switch displayedCustomMenuItem.position {
case 1: case 1:
ApplicationSettingsHandler.shared.set(customNavigationMenuItem1: item.viewMode.rawValue, modelContext: modelContext) ApplicationSettingsHandler.shared.set(customNavigationMenuItem1: item.rawValue, modelContext: modelContext)
case 2: case 2:
ApplicationSettingsHandler.shared.set(customNavigationMenuItem2: item.viewMode.rawValue, modelContext: modelContext) ApplicationSettingsHandler.shared.set(customNavigationMenuItem2: item.rawValue, modelContext: modelContext)
case 3: case 3:
ApplicationSettingsHandler.shared.set(customNavigationMenuItem3: item.viewMode.rawValue, modelContext: modelContext) ApplicationSettingsHandler.shared.set(customNavigationMenuItem3: item.rawValue, modelContext: modelContext)
default: default:
break break
} }
self.hiddenMenuItems = self.displayedCustomMenuItems.map({ $0.viewMode }) self.hiddenMenuItems = self.displayedCustomMenuItems.map({ $0.viewMode })
} label: { } 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) { private func setCustomMenuItem(position: Int, viewMode: MainView.ViewMode) {
if let displayedCustomMenuItem = self.displayedCustomMenuItems.first(where: { $0.position == position }), if let displayedCustomMenuItem = self.displayedCustomMenuItems.first(where: { $0.position == position }),
let customMenuItem = self.customMenuItems.first(where: { $0.viewMode == viewMode }) { let customMenuItem = self.customMenuItems.first(where: { $0 == viewMode }) {
displayedCustomMenuItem.title = customMenuItem.title displayedCustomMenuItem.viewMode = customMenuItem
displayedCustomMenuItem.viewMode = customMenuItem.viewMode
displayedCustomMenuItem.image = customMenuItem.image
} }
} }
} }

View File

@ -197,10 +197,10 @@ struct HomeTimelineView: View {
modelContext: modelContext) modelContext: modelContext)
// Remeber first status returned by API in user context (when it's newer then remembered). // Remeber first status returned by API in user context (when it's newer then remembered).
try HomeTimelineService.shared.update(lastSeenStatusId: nil, try AccountDataHandler.shared.update(lastSeenStatusId: nil,
lastLoadedStatusId: statuses.first?.id, lastLoadedStatusId: statuses.first?.id,
applicationState: self.applicationState, applicationState: self.applicationState,
modelContext: modelContext) modelContext: modelContext)
// Append statuses to viewed. // Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext) try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)
@ -274,11 +274,11 @@ struct HomeTimelineView: View {
modelContext: modelContext) modelContext: modelContext)
// Remeber first status returned by API in user context (when it's newer then remembered). // Remeber first status returned by API in user context (when it's newer then remembered).
try HomeTimelineService.shared.update(lastSeenStatusId: self.statusViewModels.first?.id, try AccountDataHandler.shared.update(lastSeenStatusId: self.statusViewModels.first?.id,
lastLoadedStatusId: statuses.first?.id, lastLoadedStatusId: statuses.first?.id,
statuses: statuses, statuses: statuses,
applicationState: self.applicationState, applicationState: self.applicationState,
modelContext: modelContext) modelContext: modelContext)
// Append statuses to viewed. // Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext) try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)

View File

@ -33,7 +33,7 @@ struct MainView: View {
@Query(sort: \AccountData.acct, order: .forward) var dbAccounts: [AccountData] @Query(sort: \AccountData.acct, order: .forward) var dbAccounts: [AccountData]
public enum ViewMode: Int { public enum ViewMode: Int, Identifiable {
case home = 1 case home = 1
case local = 2 case local = 2
case federated = 3 case federated = 3
@ -44,6 +44,10 @@ struct MainView: View {
case trendingTags = 8 case trendingTags = 8
case trendingAccounts = 9 case trendingAccounts = 9
var id: Self {
return self
}
public var title: LocalizedStringKey { public var title: LocalizedStringKey {
switch self { switch self {
case .home: case .home:
@ -67,26 +71,36 @@ struct MainView: View {
} }
} }
public var image: String { @ViewBuilder
public func getImage(applicationState: ApplicationState) -> some View {
switch self { switch self {
case .home: case .home:
return "house" Image(systemName: "house")
case .trendingPhotos: case .trendingPhotos:
return "photo.stack" Image(systemName: "photo.stack")
case .trendingTags: case .trendingTags:
return "tag" Image(systemName: "tag")
case .trendingAccounts: case .trendingAccounts:
return "person.3" Image(systemName: "person.3")
case .local: case .local:
return "building" Image(systemName: "building")
case .federated: case .federated:
return "globe.europe.africa" Image(systemName: "globe.europe.africa")
case .profile: case .profile:
return "person.crop.circle" Image(systemName: "person.crop.circle")
case .notifications: 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: case .search:
return "magnifyingglass" Image(systemName: "magnifyingglass")
} }
} }
} }
@ -309,7 +323,8 @@ struct MainView: View {
// Refresh application state. // Refresh application state.
self.applicationState.changeApplicationState(accountModel: signedInAccountModel, self.applicationState.changeApplicationState(accountModel: signedInAccountModel,
instance: instance, instance: instance,
lastSeenStatusId: signedInAccountModel.lastSeenStatusId) lastSeenStatusId: signedInAccountModel.lastSeenStatusId,
lastSeenNotificationId: signedInAccountModel.lastSeenNotificationId)
// Set account as default (application will open this account after restart). // Set account as default (application will open this account after restart).
ApplicationSettingsHandler.shared.set(accountId: signedInAccountModel.id, modelContext: modelContext) ApplicationSettingsHandler.shared.set(accountId: signedInAccountModel.id, modelContext: modelContext)

View File

@ -15,6 +15,7 @@ import WidgetsKit
struct NotificationsView: View { struct NotificationsView: View {
@Environment(ApplicationState.self) var applicationState @Environment(ApplicationState.self) var applicationState
@Environment(Client.self) var client @Environment(Client.self) var client
@Environment(\.modelContext) private var modelContext
@State var accountId: String @State var accountId: String
@State private var notifications: [PixelfedKit.Notification] = [] @State private var notifications: [PixelfedKit.Notification] = []
@ -76,7 +77,7 @@ struct NotificationsView: View {
.listStyle(PlainListStyle()) .listStyle(PlainListStyle())
.refreshable { .refreshable {
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
await self.loadNewNotifications() await self.refreshNotifications()
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
} }
} }
@ -95,6 +96,9 @@ struct NotificationsView: View {
withAnimation { withAnimation {
self.state = .loaded self.state = .loaded
} }
try AccountDataHandler.shared.update(lastSeenNotificationId: linkable.data.first?.id, applicationState: self.applicationState, modelContext: modelContext)
self.applicationState.newNotificationsHasBeenAdded = false
} }
} catch { } catch {
if !Task.isCancelled { if !Task.isCancelled {
@ -122,7 +126,7 @@ struct NotificationsView: View {
} }
} }
private func loadNewNotifications() async { private func refreshNotifications() async {
do { do {
if let linkable = try await self.client.notifications?.notifications(minId: self.minId, limit: self.defaultPageSize) { 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 }) { if let first = linkable.data.first, self.notifications.contains(where: { notification in notification.id == first.id }) {
@ -130,6 +134,9 @@ struct NotificationsView: View {
return 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.minId = linkable.link?.minId
self.notifications.insert(contentsOf: linkable.data, at: 0) self.notifications.insert(contentsOf: linkable.data, at: 0)
} }

View File

@ -198,10 +198,10 @@ struct StatusesView: View {
if self.listType == .home { if self.listType == .home {
// Remeber first status returned by API in user context (when it's newer then remembered). // Remeber first status returned by API in user context (when it's newer then remembered).
try HomeTimelineService.shared.update(lastSeenStatusId: nil, try AccountDataHandler.shared.update(lastSeenStatusId: nil,
lastLoadedStatusId: statuses.first?.id, lastLoadedStatusId: statuses.first?.id,
applicationState: self.applicationState, applicationState: self.applicationState,
modelContext: modelContext) modelContext: modelContext)
// Append statuses to viewed. // Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext) try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)
@ -276,10 +276,10 @@ struct StatusesView: View {
if self.listType == .home { if self.listType == .home {
// Remeber first status returned by API in user context (when it's newer then remembered). // Remeber first status returned by API in user context (when it's newer then remembered).
try HomeTimelineService.shared.update(lastSeenStatusId: self.statusViewModels.first?.id, try AccountDataHandler.shared.update(lastSeenStatusId: self.statusViewModels.first?.id,
lastLoadedStatusId: statuses.first?.id, lastLoadedStatusId: statuses.first?.id,
applicationState: self.applicationState, applicationState: self.applicationState,
modelContext: modelContext) modelContext: modelContext)
// Append statuses to viewed. // Append statuses to viewed.
try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext) try ViewedStatusHandler.shared.append(contentsOf: statuses, accountId: accountId, modelContext: modelContext)

View File

@ -6,8 +6,11 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import EnvironmentKit
struct MainNavigationOptions: View { struct MainNavigationOptions: View {
@Environment(ApplicationState.self) var applicationState
let onViewModeIconTap: (MainView.ViewMode) -> Void let onViewModeIconTap: (MainView.ViewMode) -> Void
@Binding var hiddenMenuItems: [MainView.ViewMode] @Binding var hiddenMenuItems: [MainView.ViewMode]
@ -24,7 +27,7 @@ struct MainNavigationOptions: View {
} label: { } label: {
HStack { HStack {
Text(MainView.ViewMode.home.title) 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: { } label: {
HStack { HStack {
Text(MainView.ViewMode.local.title) 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: { } label: {
HStack { HStack {
Text(MainView.ViewMode.federated.title) 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: { } label: {
HStack { HStack {
Text(MainView.ViewMode.search.title) 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: { } label: {
HStack { HStack {
Text(MainView.ViewMode.trendingPhotos.title) 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: { } label: {
HStack { HStack {
Text(MainView.ViewMode.trendingTags.title) 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: { } label: {
HStack { HStack {
Text(MainView.ViewMode.trendingAccounts.title) Text(MainView.ViewMode.trendingAccounts.title)
Image(systemName: MainView.ViewMode.trendingAccounts.image) MainView.ViewMode.trendingAccounts.getImage(applicationState: applicationState)
} }
} }
} label: { } label: {
@ -106,7 +109,7 @@ struct MainNavigationOptions: View {
} label: { } label: {
HStack { HStack {
Text(MainView.ViewMode.profile.title) 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: { } label: {
HStack { HStack {
Text(MainView.ViewMode.notifications.title) Text(MainView.ViewMode.notifications.title)
Image(systemName: MainView.ViewMode.notifications.image) MainView.ViewMode.notifications.getImage(applicationState: applicationState)
} }
} }
} }

View File

@ -40,7 +40,8 @@ class ShareViewController: UIViewController {
// Set application state (with default instance settings). // Set application state (with default instance settings).
applicationState.changeApplicationState(accountModel: accountModel, applicationState.changeApplicationState(accountModel: accountModel,
instance: nil, instance: nil,
lastSeenStatusId: accountModel.lastSeenStatusId) lastSeenStatusId: accountModel.lastSeenStatusId,
lastSeenNotificationId: accountModel.lastSeenNotificationId)
// Update application settings from database. // Update application settings from database.
ApplicationSettingsHandler.shared.update(applicationState: applicationState, modelContext: modelContext) ApplicationSettingsHandler.shared.update(applicationState: applicationState, modelContext: modelContext)