Notifications wip

This commit is contained in:
Justin Mazzocchi 2020-10-30 00:11:24 -07:00
parent 5540c959f0
commit 00605ff212
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
26 changed files with 599 additions and 13 deletions

View File

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

View File

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

View 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)
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

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

View File

@ -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 */,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

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

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

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

View 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"
}
}
}

View File

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