diff --git a/DB/Sources/DB/Content/ContentDatabase+Migration.swift b/DB/Sources/DB/Content/ContentDatabase+Migration.swift index d5ab366..7e3b836 100644 --- a/DB/Sources/DB/Content/ContentDatabase+Migration.swift +++ b/DB/Sources/DB/Content/ContentDatabase+Migration.swift @@ -110,6 +110,13 @@ extension ContentDatabase { t.column("id", .text).notNull() } + try db.create(table: "notificationRecord") { t in + t.column("id", .text).primaryKey(onConflict: .replace) + t.column("type", .text).notNull() + t.column("accountId", .text).notNull().references("accountRecord") + t.column("statusId").references("statusRecord") + } + try db.create(table: "statusAncestorJoin") { t in t.column("parentId", .text).indexed().notNull() .references("statusRecord", onDelete: .cascade) diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 54561da..361feab 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -292,6 +292,16 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func insert(notifications: [MastodonNotification]) -> AnyPublisher { + databaseWriter.writePublisher { + for notification in notifications { + try notification.save($0) + } + } + .ignoreOutput() + .eraseToAnyPublisher() + } + func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[[CollectionItem]], Error> { ValueObservation.tracking( TimelineItemsInfo.request(TimelineRecord.filter(TimelineRecord.Columns.id == timeline.id)).fetchOne) @@ -346,6 +356,28 @@ public extension ContentDatabase { .eraseToAnyPublisher() } + func notificationsPublisher() -> AnyPublisher<[[CollectionItem]], Error> { + ValueObservation.tracking( + NotificationInfo.request( + NotificationRecord.order(NotificationRecord.Columns.id.desc)).fetchAll) + .removeDuplicates() + .publisher(in: databaseWriter) + .map { [$0.map { + let configuration: CollectionItem.StatusConfiguration? + + if $0.record.type == .mention, let statusInfo = $0.statusInfo { + configuration = CollectionItem.StatusConfiguration( + showContentToggled: statusInfo.showContentToggled, + showAttachmentsToggled: statusInfo.showAttachmentsToggled) + } else { + configuration = nil + } + + return .notification(MastodonNotification(info: $0), configuration) + }] } + .eraseToAnyPublisher() + } + func lastReadId(_ markerTimeline: Marker.Timeline) -> String? { try? databaseWriter.read { try String.fetchOne( @@ -366,6 +398,8 @@ private extension ContentDatabase { useHomeTimelineLastReadId: Bool, useNotificationsLastReadId: Bool) throws { try databaseWriter.write { + try NotificationRecord.deleteAll($0) + if useHomeTimelineLastReadId { try TimelineRecord.filter(TimelineRecord.Columns.id != Timeline.home.id).deleteAll($0) var statusIds = try Status.Id.fetchAll( diff --git a/DB/Sources/DB/Content/NotificationInfo.swift b/DB/Sources/DB/Content/NotificationInfo.swift new file mode 100644 index 0000000..c68c908 --- /dev/null +++ b/DB/Sources/DB/Content/NotificationInfo.swift @@ -0,0 +1,23 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct NotificationInfo: Codable, Hashable, FetchableRecord { + let record: NotificationRecord + let accountInfo: AccountInfo + let statusInfo: StatusInfo? +} + +extension NotificationInfo { + static func addingIncludes(_ request: T) -> T where T.RowDecoder == NotificationRecord { + request.including(required: AccountInfo.addingIncludes(NotificationRecord.account) + .forKey(CodingKeys.accountInfo)) + .including(optional: StatusInfo.addingIncludesForNotificationInfo(NotificationRecord.status) + .forKey(CodingKeys.statusInfo)) + } + + static func request(_ request: QueryInterfaceRequest) -> QueryInterfaceRequest { + addingIncludes(request).asRequest(of: self) + } +} diff --git a/DB/Sources/DB/Content/NotificationRecord.swift b/DB/Sources/DB/Content/NotificationRecord.swift new file mode 100644 index 0000000..20c5ca8 --- /dev/null +++ b/DB/Sources/DB/Content/NotificationRecord.swift @@ -0,0 +1,31 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +struct NotificationRecord: ContentDatabaseRecord, Hashable { + let id: String + let type: MastodonNotification.NotificationType + let accountId: Account.Id + let statusId: Status.Id? +} + +extension NotificationRecord { + enum Columns { + static let id = Column(NotificationRecord.CodingKeys.id) + static let type = Column(NotificationRecord.CodingKeys.type) + static let accountId = Column(NotificationRecord.CodingKeys.accountId) + static let statusId = Column(NotificationRecord.CodingKeys.statusId) + } + + static let account = belongsTo(AccountRecord.self) + static let status = belongsTo(StatusRecord.self) + + init(notification: MastodonNotification) { + id = notification.id + type = notification.type + accountId = notification.account.id + statusId = notification.status?.id + } +} diff --git a/DB/Sources/DB/Content/StatusInfo.swift b/DB/Sources/DB/Content/StatusInfo.swift index b2fdc2f..823818f 100644 --- a/DB/Sources/DB/Content/StatusInfo.swift +++ b/DB/Sources/DB/Content/StatusInfo.swift @@ -16,15 +16,17 @@ struct StatusInfo: Codable, Hashable, FetchableRecord { extension StatusInfo { static func addingIncludes(_ request: T) -> T where T.RowDecoder == StatusRecord { - request.including(required: AccountInfo.addingIncludes(StatusRecord.account).forKey(CodingKeys.accountInfo)) - .including(optional: AccountInfo.addingIncludes(StatusRecord.reblogAccount) - .forKey(CodingKeys.reblogAccountInfo)) - .including(optional: StatusRecord.reblog.forKey(CodingKeys.reblogRecord)) - .including(optional: StatusRecord.showContentToggle.forKey(CodingKeys.showContentToggle)) - .including(optional: StatusRecord.reblogShowContentToggle.forKey(CodingKeys.reblogShowContentToggle)) - .including(optional: StatusRecord.showAttachmentsToggle.forKey(CodingKeys.showAttachmentsToggle)) - .including(optional: StatusRecord.reblogShowAttachmentsToggle - .forKey(CodingKeys.reblogShowAttachmentsToggle)) + addingOptionalIncludes( + request + .including(required: AccountInfo.addingIncludes(StatusRecord.account).forKey(CodingKeys.accountInfo))) + } + + // Hack, remove once GRDB supports chaining a required association behind an optional association + static func addingIncludesForNotificationInfo( + _ request: T) -> T where T.RowDecoder == StatusRecord { + addingOptionalIncludes( + request + .including(optional: AccountInfo.addingIncludes(StatusRecord.account).forKey(CodingKeys.accountInfo))) } static func request(_ request: QueryInterfaceRequest) -> QueryInterfaceRequest { @@ -43,3 +45,16 @@ extension StatusInfo { showAttachmentsToggle != nil || reblogShowAttachmentsToggle != nil } } + +private extension StatusInfo { + static func addingOptionalIncludes(_ request: T) -> T where T.RowDecoder == StatusRecord { + request.including(optional: AccountInfo.addingIncludes(StatusRecord.reblogAccount) + .forKey(CodingKeys.reblogAccountInfo)) + .including(optional: StatusRecord.reblog.forKey(CodingKeys.reblogRecord)) + .including(optional: StatusRecord.showContentToggle.forKey(CodingKeys.showContentToggle)) + .including(optional: StatusRecord.reblogShowContentToggle.forKey(CodingKeys.reblogShowContentToggle)) + .including(optional: StatusRecord.showAttachmentsToggle.forKey(CodingKeys.showAttachmentsToggle)) + .including(optional: StatusRecord.reblogShowAttachmentsToggle + .forKey(CodingKeys.reblogShowAttachmentsToggle)) + } +} diff --git a/DB/Sources/DB/Entities/CollectionItem.swift b/DB/Sources/DB/Entities/CollectionItem.swift index ecd4aa3..addae33 100644 --- a/DB/Sources/DB/Entities/CollectionItem.swift +++ b/DB/Sources/DB/Entities/CollectionItem.swift @@ -6,6 +6,7 @@ public enum CollectionItem: Hashable { case status(Status, StatusConfiguration) case loadMore(LoadMore) case account(Account) + case notification(MastodonNotification, StatusConfiguration?) } public extension CollectionItem { diff --git a/DB/Sources/DB/Extensions/MastodonNotification+Extensions.swift b/DB/Sources/DB/Extensions/MastodonNotification+Extensions.swift new file mode 100644 index 0000000..dbadccf --- /dev/null +++ b/DB/Sources/DB/Extensions/MastodonNotification+Extensions.swift @@ -0,0 +1,29 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB +import Mastodon + +extension MastodonNotification { + func save(_ db: Database) throws { + try account.save(db) + try status?.save(db) + try NotificationRecord(notification: self).save(db) + } + + init(info: NotificationInfo) { + let status: Status? + + if let statusInfo = info.statusInfo { + status = .init(info: statusInfo) + } else { + status = nil + } + + self.init( + id: info.record.id, + type: info.record.type, + account: .init(info: info.accountInfo), + status: status) + } +} diff --git a/Data Sources/TableViewDataSource.swift b/Data Sources/TableViewDataSource.swift index e9481d1..c6f21a3 100644 --- a/Data Sources/TableViewDataSource.swift +++ b/Data Sources/TableViewDataSource.swift @@ -24,6 +24,8 @@ final class TableViewDataSource: UITableViewDiffableDataSource NSAttributedString { + let mutableString = NSMutableAttributedString( + string: String.localizedStringWithFormat( + NSLocalizedString(self, comment: ""), + displayName)) + + let range = (mutableString.string as NSString).range(of: displayName) + + if range.location != NSNotFound, + let boldFontDescriptor = label.font.fontDescriptor.withSymbolicTraits([.traitBold]) { + let boldFont = UIFont(descriptor: boldFontDescriptor, size: label.font.pointSize) + + mutableString.setAttributes([NSAttributedString.Key.font: boldFont], range: range) + } + + mutableString.insert(emoji: emoji, view: label) + mutableString.resizeAttachments(toLineHeight: label.font.lineHeight) + + return mutableString + } } diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 7dfe07c..ab93f97 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -93,6 +93,13 @@ "filter.context.thread" = "Conversations"; "filter.context.account" = "Profiles"; "filter.context.unknown" = "Unknown context"; +"notifications" = "Notifications"; +"notifications.reblogged-your-status" = "%@ boosted your status"; +"notifications.favourited-your-status" = "%@ favorited your status"; +"notifications.followed-you" = "%@ followed you"; +"notifications.poll-ended" = "A poll you have voted in has ended"; +"notifications.your-poll-ended" = "Your poll has ended"; +"notifications.unknown" = "Notification from %@"; "status.reblogged-by" = "%@ boosted"; "status.pinned-post" = "Pinned post"; "status.show-more" = "Show More"; diff --git a/Mastodon/Sources/Mastodon/Entities/MastodonNotification.swift b/Mastodon/Sources/Mastodon/Entities/MastodonNotification.swift new file mode 100644 index 0000000..e968507 --- /dev/null +++ b/Mastodon/Sources/Mastodon/Entities/MastodonNotification.swift @@ -0,0 +1,31 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +public struct MastodonNotification: Codable, Hashable { + public let id: String + public let type: NotificationType + public let account: Account + public let status: Status? + + public init(id: String, type: MastodonNotification.NotificationType, account: Account, status: Status?) { + self.id = id + self.type = type + self.account = account + self.status = status + } +} + +extension MastodonNotification { + public enum NotificationType: String, Codable, Unknowable { + case follow + case mention + case reblog + case favourite + case poll + case followRequest = "follow_request" + case unknown + + public static var unknownCase: Self { .unknown } + } +} diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/NotificationsEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/NotificationsEndpoint.swift new file mode 100644 index 0000000..63dd769 --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/NotificationsEndpoint.swift @@ -0,0 +1,24 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import HTTP +import Mastodon + +public enum NotificationsEndpoint { + case notifications +} + +extension NotificationsEndpoint: Endpoint { + public typealias ResultType = [MastodonNotification] + + public var pathComponentsInContext: [String] { + ["notifications"] + } + + public var method: HTTPMethod { + switch self { + case .notifications: + return .get + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 0791815..76ad6e7 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -14,6 +14,9 @@ D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; }; D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */; }; D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; }; + D036AA02254B6101009094DF /* NotificationListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA01254B6101009094DF /* NotificationListCell.swift */; }; + D036AA07254B6118009094DF /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA06254B6118009094DF /* NotificationView.swift */; }; + D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */; }; D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03B1B29253818F3008F964B /* MediaPreferencesView.swift */; }; D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */; }; D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; }; @@ -118,6 +121,9 @@ D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = ""; }; D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsView.swift; sourceTree = ""; }; D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; + D036AA01254B6101009094DF /* NotificationListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListCell.swift; sourceTree = ""; }; + D036AA06254B6118009094DF /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = ""; }; + D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentConfiguration.swift; sourceTree = ""; }; D03B1B29253818F3008F964B /* MediaPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreferencesView.swift; sourceTree = ""; }; D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAndSyncingPreferencesView.swift; sourceTree = ""; }; D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -339,7 +345,10 @@ D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */, D0E569DA2529319100FA1D72 /* LoadMoreView.swift */, D03B1B29253818F3008F964B /* MediaPreferencesView.swift */, + D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */, + D036AA01254B6101009094DF /* NotificationListCell.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, + D036AA06254B6118009094DF /* NotificationView.swift */, D0FE1C8E253686F9003EF1EB /* PlayerView.swift */, D08B8D812544D80000B1EBEF /* PollOptionButton.swift */, D08B8D8C2544E6EC00B1EBEF /* PollResultView.swift */, @@ -599,6 +608,7 @@ D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, D0B32F50250B373600311912 /* RegistrationView.swift in Sources */, D08B8D612540DE3B00B1EBEF /* ZoomDismissalInteractionController.swift in Sources */, + D036AA07254B6118009094DF /* NotificationView.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, @@ -609,6 +619,7 @@ D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */, + D036AA02254B6101009094DF /* NotificationListCell.swift in Sources */, D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, @@ -636,6 +647,7 @@ D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */, D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */, D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */, + D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */, D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */, D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */, D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index a5f87aa..031d870 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -208,6 +208,10 @@ public extension IdentityService { func service(timeline: Timeline) -> TimelineService { TimelineService(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + + func notificationsService() -> NotificationsService { + NotificationsService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) + } } private extension IdentityService { diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift index a53ffe9..5201639 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/NavigationService.swift @@ -72,6 +72,13 @@ public extension NavigationService { func loadMoreService(loadMore: LoadMore) -> LoadMoreService { LoadMoreService(loadMore: loadMore, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } + + func notificationService(notification: MastodonNotification) -> NotificationService { + NotificationService( + notification: notification, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase) + } } private extension NavigationService { diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NotificationService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NotificationService.swift new file mode 100644 index 0000000..7edc0db --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/NotificationService.swift @@ -0,0 +1,24 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import DB +import Foundation +import Mastodon +import MastodonAPI + +public struct NotificationService { + public let notification: MastodonNotification + public let navigationService: NavigationService + private let mastodonAPIClient: MastodonAPIClient + private let contentDatabase: ContentDatabase + + init(notification: MastodonNotification, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { + self.notification = notification + self.navigationService = NavigationService( + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase, + status: nil) + self.mastodonAPIClient = mastodonAPIClient + self.contentDatabase = contentDatabase + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift new file mode 100644 index 0000000..223cb5a --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/NotificationsService.swift @@ -0,0 +1,50 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import DB +import Foundation +import Mastodon +import MastodonAPI + +public struct NotificationsService { + public let sections: AnyPublisher<[[CollectionItem]], Error> + public let nextPageMaxId: AnyPublisher + public let navigationService: NavigationService + + private let mastodonAPIClient: MastodonAPIClient + private let contentDatabase: ContentDatabase + private let nextPageMaxIdSubject: CurrentValueSubject + + init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { + self.mastodonAPIClient = mastodonAPIClient + self.contentDatabase = contentDatabase + + let nextPageMaxIdSubject = CurrentValueSubject(String(Int.max)) + + self.nextPageMaxIdSubject = nextPageMaxIdSubject + sections = contentDatabase.notificationsPublisher() + .handleEvents(receiveOutput: { + guard case let .notification(notification, _) = $0.last?.last, + notification.id < nextPageMaxIdSubject.value + else { return } + + nextPageMaxIdSubject.send(notification.id) + }) + .eraseToAnyPublisher() + nextPageMaxId = nextPageMaxIdSubject.eraseToAnyPublisher() + navigationService = NavigationService(mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) + } +} + +extension NotificationsService: CollectionService { + public func request(maxId: String?, minId: String?) -> AnyPublisher { + mastodonAPIClient.pagedRequest(NotificationsEndpoint.notifications, maxId: maxId, minId: minId) + .handleEvents(receiveOutput: { + guard let maxId = $0.info.maxId, maxId < nextPageMaxIdSubject.value else { return } + + nextPageMaxIdSubject.send(maxId) + }) + .flatMap { contentDatabase.insert(notifications: $0.result) } + .eraseToAnyPublisher() + } +} diff --git a/ViewModels/Sources/ViewModels/AccountViewModel.swift b/ViewModels/Sources/ViewModels/AccountViewModel.swift index 39a6224..da02251 100644 --- a/ViewModels/Sources/ViewModels/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountViewModel.swift @@ -28,7 +28,9 @@ public extension AccountViewModel { } } - var displayName: String { accountService.account.displayName } + var displayName: String { + accountService.account.displayName.isEmpty ? accountService.account.acct : accountService.account.displayName + } var accountName: String { "@".appending(accountService.account.acct) } @@ -36,6 +38,8 @@ public extension AccountViewModel { var emoji: [Emoji] { accountService.account.emojis } + var isSelf: Bool { accountService.account.id == identification.identity.account?.id } + func avatarURL(profile: Bool = false) -> URL { if !identification.appPreferences.shouldReduceMotion, (identification.appPreferences.animateAvatars == .everywhere diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index bcdaa32..ebdcca3 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -126,6 +126,18 @@ extension CollectionItemsViewModel: CollectionViewModel { .navigation(.profile(collectionService .navigationService .profileService(account: account)))) + case let .notification(notification, _): + if let status = notification.status { + eventsSubject.send( + .navigation(.collection(collectionService + .navigationService + .contextService(id: status.displayStatus.id)))) + } else { + eventsSubject.send( + .navigation(.profile(collectionService + .navigationService + .profileService(account: notification.account)))) + } } } @@ -195,6 +207,27 @@ extension CollectionItemsViewModel: CollectionViewModel { cache(viewModel: viewModel, forItem: item) + return viewModel + case let .notification(notification, statusConfiguration): + let viewModel: CollectionItemViewModel + + if let cachedViewModel = cachedViewModel { + viewModel = cachedViewModel + } else if let status = notification.status, let statusConfiguration = statusConfiguration { + let statusViewModel = StatusViewModel( + statusService: collectionService.navigationService.statusService(status: status), + identification: identification) + statusViewModel.configuration = statusConfiguration + viewModel = statusViewModel + cache(viewModel: viewModel, forItem: item) + } else { + viewModel = NotificationViewModel( + notificationService: collectionService.navigationService.notificationService( + notification: notification), + identification: identification) + cache(viewModel: viewModel, forItem: item) + } + return viewModel } } diff --git a/ViewModels/Sources/ViewModels/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/NavigationViewModel.swift index 32c8e98..13d0b65 100644 --- a/ViewModels/Sources/ViewModels/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/NavigationViewModel.swift @@ -18,9 +18,23 @@ public final class NavigationViewModel: ObservableObject { @Published public private(set) var timelinesAndLists: [Timeline] @Published public var presentingSecondaryNavigation = false @Published public var alertItem: AlertItem? - public var selectedTab: Tab? = .timelines public private(set) var timelineViewModel: CollectionItemsViewModel + public var notificationsViewModel: CollectionViewModel? { + if identification.identity.authenticated { + if _notificationsViewModel == nil { + _notificationsViewModel = CollectionItemsViewModel( + collectionService: identification.service.notificationsService(), + identification: identification) + } + + return _notificationsViewModel + } else { + return nil + } + } + + private var _notificationsViewModel: CollectionViewModel? private var cancellables = Set() public init(identification: Identification) { diff --git a/ViewModels/Sources/ViewModels/NotificationViewModel.swift b/ViewModels/Sources/ViewModels/NotificationViewModel.swift new file mode 100644 index 0000000..504e92c --- /dev/null +++ b/ViewModels/Sources/ViewModels/NotificationViewModel.swift @@ -0,0 +1,31 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import ServiceLayer + +public final class NotificationViewModel: CollectionItemViewModel, ObservableObject { + public let accountViewModel: AccountViewModel + public let events: AnyPublisher, Never> + + private let notificationService: NotificationService + private let identification: Identification + private let eventsSubject = PassthroughSubject, Never>() + + init(notificationService: NotificationService, identification: Identification) { + self.notificationService = notificationService + self.identification = identification + self.accountViewModel = AccountViewModel( + accountService: notificationService.navigationService.accountService( + account: notificationService.notification.account), + identification: identification) + self.events = eventsSubject.eraseToAnyPublisher() + } +} + +public extension NotificationViewModel { + var type: MastodonNotification.NotificationType { + notificationService.notification.type + } +} diff --git a/Views/NotificationContentConfiguration.swift b/Views/NotificationContentConfiguration.swift new file mode 100644 index 0000000..18b5ac9 --- /dev/null +++ b/Views/NotificationContentConfiguration.swift @@ -0,0 +1,18 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +struct NotificationContentConfiguration { + let viewModel: NotificationViewModel +} + +extension NotificationContentConfiguration: UIContentConfiguration { + func makeContentView() -> UIView & UIContentView { + NotificationView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> NotificationContentConfiguration { + self + } +} diff --git a/Views/NotificationListCell.swift b/Views/NotificationListCell.swift new file mode 100644 index 0000000..89c7149 --- /dev/null +++ b/Views/NotificationListCell.swift @@ -0,0 +1,26 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +class NotificationListCell: UITableViewCell { + var viewModel: NotificationViewModel? + + override func updateConfiguration(using state: UICellConfigurationState) { + guard let viewModel = viewModel else { return } + + contentConfiguration = NotificationContentConfiguration(viewModel: viewModel).updated(for: state) + } + + override func layoutSubviews() { + super.layoutSubviews() + + if UIDevice.current.userInterfaceIdiom == .phone { + separatorInset.left = 0 + separatorInset.right = 0 + } else { + separatorInset.left = layoutMargins.left + separatorInset.right = layoutMargins.right + } + } +} diff --git a/Views/NotificationView.swift b/Views/NotificationView.swift new file mode 100644 index 0000000..c197bd9 --- /dev/null +++ b/Views/NotificationView.swift @@ -0,0 +1,120 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Mastodon +import UIKit + +class NotificationView: UIView { + private let iconImageView = UIImageView() + private let typeLabel = UILabel() + private var notificationConfiguration: NotificationContentConfiguration + + init(configuration: NotificationContentConfiguration) { + notificationConfiguration = configuration + + super.init(frame: .zero) + + initialSetup() + applyNotificationConfiguration() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension NotificationView: UIContentView { + var configuration: UIContentConfiguration { + get { notificationConfiguration } + set { + guard let notificationConfiguration = newValue as? NotificationContentConfiguration else { return } + + self.notificationConfiguration = notificationConfiguration + + applyNotificationConfiguration() + } + } +} + +private extension NotificationView { + func initialSetup() { + let stackView = UIStackView() + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = .compactSpacing + + stackView.addArrangedSubview(iconImageView) + iconImageView.setContentHuggingPriority(.required, for: .horizontal) + + stackView.addArrangedSubview(typeLabel) + typeLabel.font = .preferredFont(forTextStyle: .body) + typeLabel.adjustsFontForContentSizeCategory = true + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), + stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor) + ]) + } + + func applyNotificationConfiguration() { + let viewModel = notificationConfiguration.viewModel + + switch viewModel.type { + case .follow: + typeLabel.attributedText = "notifications.followed-you".localizedBolding( + displayName: viewModel.accountViewModel.displayName, + emoji: viewModel.accountViewModel.emoji, + label: typeLabel) + iconImageView.tintColor = nil + case .reblog: + typeLabel.attributedText = "notifications.reblogged-your-status".localizedBolding( + displayName: viewModel.accountViewModel.displayName, + emoji: viewModel.accountViewModel.emoji, + label: typeLabel) + iconImageView.tintColor = .systemGreen + case .favourite: + typeLabel.attributedText = "notifications.favourited-your-status".localizedBolding( + displayName: viewModel.accountViewModel.displayName, + emoji: viewModel.accountViewModel.emoji, + label: typeLabel) + iconImageView.tintColor = .systemYellow + case .poll: + typeLabel.text = NSLocalizedString( + viewModel.accountViewModel.isSelf + ? "notifications.your-poll-ended" + : "notifications.poll-ended", + comment: "") + iconImageView.tintColor = nil + default: + typeLabel.attributedText = "notifications.unknown".localizedBolding( + displayName: viewModel.accountViewModel.displayName, + emoji: viewModel.accountViewModel.emoji, + label: typeLabel) + iconImageView.tintColor = nil + } + + iconImageView.image = UIImage( + systemName: viewModel.type.systemImageName, + withConfiguration: UIImage.SymbolConfiguration(scale: .medium)) + } +} + +extension MastodonNotification.NotificationType { + var systemImageName: String { + switch self { + case .follow, .followRequest: + return "person.badge.plus" + case .reblog: + return "arrow.2.squarepath" + case .favourite: + return "star.fill" + case .poll: + return "chart.bar.doc.horizontal" + case .mention, .unknown: + return "at" + } + } +} diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index b289252..9a8fa31 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -8,13 +8,14 @@ struct TabNavigationView: View { @ObservedObject var viewModel: NavigationViewModel @EnvironmentObject var rootViewModel: RootViewModel @Environment(\.displayScale) var displayScale: CGFloat + @State var selectedTab = NavigationViewModel.Tab.timelines var body: some View { Group { if viewModel.identification.identity.pending { pendingView } else { - TabView(selection: $viewModel.selectedTab) { + TabView(selection: $selectedTab) { ForEach(viewModel.tabs) { tab in NavigationView { view(tab: tab) @@ -91,6 +92,15 @@ private extension TabNavigationView { Image(systemName: viewModel.timeline.systemImageName) .padding([.leading, .top, .bottom]) }) + case .notifications: + if let notificationsViewModel = viewModel.notificationsViewModel { + TableView(viewModel: notificationsViewModel) + .id(tab) + .edgesIgnoringSafeArea(.all) + .navigationTitle("notifications") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: secondaryNavigationButton) + } default: Text(tab.title) } }