mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41:00 +00:00
Notifications wip
This commit is contained in:
parent
5540c959f0
commit
00605ff212
26 changed files with 599 additions and 13 deletions
|
@ -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)
|
||||
|
|
|
@ -292,6 +292,16 @@ public extension ContentDatabase {
|
|||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func insert(notifications: [MastodonNotification]) -> AnyPublisher<Never, Error> {
|
||||
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(
|
||||
|
|
23
DB/Sources/DB/Content/NotificationInfo.swift
Normal file
23
DB/Sources/DB/Content/NotificationInfo.swift
Normal file
|
@ -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<T: DerivableRequest>(_ 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<NotificationRecord>) -> QueryInterfaceRequest<Self> {
|
||||
addingIncludes(request).asRequest(of: self)
|
||||
}
|
||||
}
|
31
DB/Sources/DB/Content/NotificationRecord.swift
Normal file
31
DB/Sources/DB/Content/NotificationRecord.swift
Normal file
|
@ -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
|
||||
}
|
||||
}
|
|
@ -16,15 +16,17 @@ struct StatusInfo: Codable, Hashable, FetchableRecord {
|
|||
|
||||
extension StatusInfo {
|
||||
static func addingIncludes<T: DerivableRequest>(_ 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<T: DerivableRequest>(
|
||||
_ request: T) -> T where T.RowDecoder == StatusRecord {
|
||||
addingOptionalIncludes(
|
||||
request
|
||||
.including(optional: AccountInfo.addingIncludes(StatusRecord.account).forKey(CodingKeys.accountInfo)))
|
||||
}
|
||||
|
||||
static func request(_ request: QueryInterfaceRequest<StatusRecord>) -> QueryInterfaceRequest<Self> {
|
||||
|
@ -43,3 +45,16 @@ extension StatusInfo {
|
|||
showAttachmentsToggle != nil || reblogShowAttachmentsToggle != nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension StatusInfo {
|
||||
static func addingOptionalIncludes<T: DerivableRequest>(_ 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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -24,6 +24,8 @@ final class TableViewDataSource: UITableViewDiffableDataSource<Int, CollectionIt
|
|||
accountListCell.viewModel = accountViewModel
|
||||
case let (loadMoreCell as LoadMoreCell, loadMoreViewModel as LoadMoreViewModel):
|
||||
loadMoreCell.viewModel = loadMoreViewModel
|
||||
case let (notificationListCell as NotificationListCell, notificationViewModel as NotificationViewModel):
|
||||
notificationListCell.viewModel = notificationViewModel
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import ViewModels
|
||||
|
||||
extension CollectionItem {
|
||||
static let cellClasses = [StatusListCell.self, AccountListCell.self, LoadMoreCell.self]
|
||||
static let cellClasses = [
|
||||
StatusListCell.self,
|
||||
AccountListCell.self,
|
||||
LoadMoreCell.self,
|
||||
NotificationListCell.self]
|
||||
|
||||
var cellClass: AnyClass {
|
||||
switch self {
|
||||
|
@ -13,6 +18,8 @@ extension CollectionItem {
|
|||
return AccountListCell.self
|
||||
case .loadMore:
|
||||
return LoadMoreCell.self
|
||||
case let .notification(_, statusConfiguration):
|
||||
return statusConfiguration == nil ? NotificationListCell.self : StatusListCell.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Mastodon
|
||||
import UIKit
|
||||
|
||||
extension String {
|
||||
|
@ -21,4 +22,25 @@ extension String {
|
|||
|
||||
return attributed
|
||||
}
|
||||
|
||||
func localizedBolding(displayName: String, emoji: [Emoji], label: UILabel) -> 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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = "<group>"; };
|
||||
D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsView.swift; sourceTree = "<group>"; };
|
||||
D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; };
|
||||
D036AA01254B6101009094DF /* NotificationListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListCell.swift; sourceTree = "<group>"; };
|
||||
D036AA06254B6118009094DF /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = "<group>"; };
|
||||
D036AA0B254B612B009094DF /* NotificationContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentConfiguration.swift; sourceTree = "<group>"; };
|
||||
D03B1B29253818F3008F964B /* MediaPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreferencesView.swift; sourceTree = "<group>"; };
|
||||
D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAndSyncingPreferencesView.swift; sourceTree = "<group>"; };
|
||||
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 */,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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<String, Never>
|
||||
public let navigationService: NavigationService
|
||||
|
||||
private let mastodonAPIClient: MastodonAPIClient
|
||||
private let contentDatabase: ContentDatabase
|
||||
private let nextPageMaxIdSubject: CurrentValueSubject<String, Never>
|
||||
|
||||
init(mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) {
|
||||
self.mastodonAPIClient = mastodonAPIClient
|
||||
self.contentDatabase = contentDatabase
|
||||
|
||||
let nextPageMaxIdSubject = CurrentValueSubject<String, Never>(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<Never, Error> {
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
public init(identification: Identification) {
|
||||
|
|
31
ViewModels/Sources/ViewModels/NotificationViewModel.swift
Normal file
31
ViewModels/Sources/ViewModels/NotificationViewModel.swift
Normal file
|
@ -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<AnyPublisher<CollectionItemEvent, Error>, Never>
|
||||
|
||||
private let notificationService: NotificationService
|
||||
private let identification: Identification
|
||||
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, 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
|
||||
}
|
||||
}
|
18
Views/NotificationContentConfiguration.swift
Normal file
18
Views/NotificationContentConfiguration.swift
Normal file
|
@ -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
|
||||
}
|
||||
}
|
26
Views/NotificationListCell.swift
Normal file
26
Views/NotificationListCell.swift
Normal file
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
120
Views/NotificationView.swift
Normal file
120
Views/NotificationView.swift
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue