Merge pull request #105 from tootsuite/feature/notification

Feature/notification
This commit is contained in:
sxiaojian88 2021-04-19 03:10:50 -07:00 committed by GitHub
commit 19e0a3599c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 2123 additions and 453 deletions

View File

@ -65,6 +65,22 @@
<attribute name="username" attributeType="String"/>
<relationship name="user" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mastodonAuthentication" inverseEntity="MastodonUser"/>
</entity>
<entity name="MastodonNotification" representedClassName=".MastodonNotification" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="typeRaw" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" attributeType="String"/>
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="MastodonUser" representedClassName=".MastodonUser" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="String"/>
@ -239,6 +255,7 @@
<element name="History" positionX="0" positionY="0" width="128" height="119"/>
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
<element name="MastodonNotification" positionX="9" positionY="162" width="128" height="164"/>
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="659"/>
<element name="Mention" positionX="0" positionY="0" width="128" height="149"/>
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>

View File

@ -0,0 +1,111 @@
//
// MastodonNotification.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/4/13.
//
import Foundation
import CoreData
public final class MastodonNotification: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var id: String
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var updatedAt: Date
@NSManaged public private(set) var typeRaw: String
@NSManaged public private(set) var account: MastodonUser
@NSManaged public private(set) var status: Status?
@NSManaged public private(set) var domain: String
@NSManaged public private(set) var userID: String
}
extension MastodonNotification {
public override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(UUID(), forKey: #keyPath(MastodonNotification.identifier))
}
}
public extension MastodonNotification {
@discardableResult
static func insert(
into context: NSManagedObjectContext,
domain: String,
userID: String,
networkDate: Date,
property: Property
) -> MastodonNotification {
let notification: MastodonNotification = context.insertObject()
notification.id = property.id
notification.createAt = property.createdAt
notification.updatedAt = networkDate
notification.typeRaw = property.typeRaw
notification.account = property.account
notification.status = property.status
notification.domain = domain
notification.userID = userID
return notification
}
}
public extension MastodonNotification {
struct Property {
public init(id: String,
typeRaw: String,
account: MastodonUser,
status: Status?,
createdAt: Date
) {
self.id = id
self.typeRaw = typeRaw
self.account = account
self.status = status
self.createdAt = createdAt
}
public let id: String
public let typeRaw: String
public let account: MastodonUser
public let status: Status?
public let createdAt: Date
}
}
extension MastodonNotification {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.domain), domain)
}
static func predicate(userID: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.userID), userID)
}
static func predicate(typeRaw: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.typeRaw), typeRaw)
}
public static func predicate(domain: String, userID: String, typeRaw: String? = nil) -> NSPredicate {
if let typeRaw = typeRaw {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonNotification.predicate(domain: domain),
MastodonNotification.predicate(typeRaw: typeRaw),
MastodonNotification.predicate(userID: userID),
])
} else {
return NSCompoundPredicate(andPredicateWithSubpredicates: [
MastodonNotification.predicate(domain: domain),
MastodonNotification.predicate(userID: userID)
])
}
}
}
extension MastodonNotification: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \MastodonNotification.createAt, ascending: false)]
}
}

View File

@ -329,6 +329,18 @@
},
"favorite": {
"title": "Your Favorites"
},
"notification": {
"title": {
"Everything": "Everything",
"Mentions": "Mentions"
},
"action": {
"follow": "followed you",
"favourite": "favorited your post",
"reblog": "rebloged your post",
"poll": "Your poll has ended",
"mention": "mentioned you"
},
"thread": {
"back_title": "Post",
@ -378,4 +390,4 @@
}
}
}
}
}

View File

@ -30,18 +30,22 @@
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; };
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */; };
2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */; };
2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; };
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; };
2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */; };
2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; };
2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; };
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; };
2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; };
2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; };
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; };
2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */; };
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */; };
2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */; };
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; };
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; };
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; };
@ -49,6 +53,8 @@
2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; };
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; };
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; };
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35237926256D920031AF25 /* NotificationSection.swift */; };
2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */; };
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */; };
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */; };
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; };
@ -76,6 +82,9 @@
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; };
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; };
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; };
2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D607AD726242FC500B70763 /* NotificationViewModel.swift */; };
2D6125472625436B00299647 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6125462625436B00299647 /* Notification.swift */; };
2D61254D262547C200299647 /* APIService+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61254C262547C200299647 /* APIService+Notification.swift */; };
2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; };
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; };
2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; };
@ -91,6 +100,7 @@
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; };
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; };
2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; };
2D7867192625B77500211898 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7867182625B77500211898 /* NotificationItem.swift */; };
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */; };
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; };
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; };
@ -110,6 +120,7 @@
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; };
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; };
2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; };
2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; };
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; };
2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; };
2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; };
@ -122,7 +133,6 @@
2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; };
2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; };
2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; };
2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; };
5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; };
5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; };
5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; };
@ -137,6 +147,7 @@
5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; };
5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; };
5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; };
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */; };
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */; };
5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */; };
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */; };
@ -432,18 +443,22 @@
0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = "<group>"; };
0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = "<group>"; };
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+diffable.swift"; sourceTree = "<group>"; };
2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadLatestState.swift"; sourceTree = "<group>"; };
2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = "<group>"; };
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = "<group>"; };
2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBottomLoader.swift; sourceTree = "<group>"; };
2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = "<group>"; };
2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = "<group>"; };
2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = "<group>"; };
2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = "<group>"; };
2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = "<group>"; };
2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStatusTableViewCell.swift; sourceTree = "<group>"; };
2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = "<group>"; };
2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadOldestState.swift"; sourceTree = "<group>"; };
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = "<group>"; };
2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; };
@ -451,6 +466,8 @@
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = "<group>"; };
2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = "<group>"; };
2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = "<group>"; };
2D35237926256D920031AF25 /* NotificationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSection.swift; sourceTree = "<group>"; };
2D35238026256F690031AF25 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = "<group>"; };
2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewController.swift; sourceTree = "<group>"; };
2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModel.swift; sourceTree = "<group>"; };
2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = "<group>"; };
@ -476,6 +493,9 @@
2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = "<group>"; };
2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = "<group>"; };
2D607AD726242FC500B70763 /* NotificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewModel.swift; sourceTree = "<group>"; };
2D6125462625436B00299647 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = "<group>"; };
2D61254C262547C200299647 /* APIService+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Notification.swift"; sourceTree = "<group>"; };
2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Status.swift"; sourceTree = "<group>"; };
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; };
@ -490,6 +510,7 @@
2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = "<group>"; };
2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = "<group>"; };
2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
2D7867182625B77500211898 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = "<group>"; };
2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Tag.swift"; sourceTree = "<group>"; };
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = "<group>"; };
2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; };
@ -509,6 +530,7 @@
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = "<group>"; };
2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = "<group>"; };
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = "<group>"; };
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = "<group>"; };
2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = "<group>"; };
@ -521,7 +543,6 @@
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = "<group>"; };
2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = "<group>"; };
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = "<group>"; };
2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraint.swift"; sourceTree = "<group>"; };
2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = "<group>"; };
3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -539,6 +560,7 @@
5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = "<group>"; };
5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = "<group>"; };
5DA732CB2629CEF500A92342 /* UIView+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Remove.swift"; sourceTree = "<group>"; };
5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = "<group>"; };
5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Tag.swift"; sourceTree = "<group>"; };
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = "<group>"; };
@ -925,6 +947,15 @@
path = CollectionViewCell;
sourceTree = "<group>";
};
2D35237F26256F470031AF25 /* TableViewCell */ = {
isa = PBXGroup;
children = (
2D35238026256F690031AF25 /* NotificationTableViewCell.swift */,
2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */,
);
path = TableViewCell;
sourceTree = "<group>";
};
2D364F7025E66D5B00204FDC /* ResendEmail */ = {
isa = PBXGroup;
children = (
@ -1079,6 +1110,7 @@
DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */,
2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */,
2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */,
2D35237926256D920031AF25 /* NotificationSection.swift */,
2D198648261C0B8500F0B013 /* SearchResultSection.swift */,
DB66729525F9F91600D60309 /* ComposeStatusSection.swift */,
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */,
@ -1132,6 +1164,7 @@
children = (
2D7631B225C159F700929FB9 /* Item.swift */,
2D198642261BF09500F0B013 /* SearchResultItem.swift */,
2D7867182625B77500211898 /* NotificationItem.swift */,
DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */,
DB1E347725F519300079D7DF /* PickServerItem.swift */,
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */,
@ -1170,7 +1203,6 @@
isa = PBXGroup;
children = (
2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */,
2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */,
);
path = TableViewCell;
sourceTree = "<group>";
@ -1391,6 +1423,7 @@
DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */,
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
DB9A488326034BD7008B817C /* APIService+Status.swift */,
2D61254C262547C200299647 /* APIService+Notification.swift */,
DB9A488F26035963008B817C /* APIService+Media.swift */,
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
@ -1472,6 +1505,7 @@
5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */,
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */,
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */,
);
path = MastodonSDK;
sourceTree = "<group>";
@ -1568,6 +1602,7 @@
isa = PBXGroup;
children = (
DB89BA2625C110B4008580ED /* Status.swift */,
2D6125462625436B00299647 /* Notification.swift */,
2D0B7A1C261D839600B44727 /* SearchHistory.swift */,
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
@ -1668,8 +1703,9 @@
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */,
DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */,
2D32EAB925CB9B0500C9ED86 /* UIView.swift */,
2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */,
5DA732CB2629CEF500A92342 /* UIView+Remove.swift */,
DB8AF55C25C138B7002E6C99 /* UIViewController.swift */,
2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */,
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
2D84350425FF858100EECE90 /* UIScrollView.swift */,
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
@ -1742,6 +1778,11 @@
isa = PBXGroup;
children = (
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */,
2D607AD726242FC500B70763 /* NotificationViewModel.swift */,
2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */,
2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */,
2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */,
2D35237F26256F470031AF25 /* TableViewCell */,
);
path = Notification;
sourceTree = "<group>";
@ -2313,6 +2354,7 @@
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */,
2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */,
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
@ -2333,8 +2375,8 @@
2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */,
5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */,
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */,
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */,
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */,
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
@ -2351,6 +2393,7 @@
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */,
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
@ -2370,6 +2413,7 @@
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */,
DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */,
2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */,
@ -2398,6 +2442,7 @@
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */,
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
@ -2433,14 +2478,17 @@
DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */,
DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */,
2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */,
2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */,
DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */,
2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */,
DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */,
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */,
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
2D61254D262547C200299647 /* APIService+Notification.swift in Sources */,
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */,
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */,
DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */,
@ -2472,8 +2520,11 @@
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */,
DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */,
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */,
2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */,
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */,
2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */,
DB084B5725CBC56C00F898ED /* Status.swift in Sources */,
DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */,
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */,
@ -2486,9 +2537,9 @@
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */,
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
DB98338725C945ED00AD9700 /* Strings.swift in Sources */,
2D7867192625B77500211898 /* NotificationItem.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */,
2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */,
2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */,
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
@ -2594,6 +2645,7 @@
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */,
DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */,
DB89BA4425C1165F008580ED /* Managed.swift in Sources */,
2D6125472625436B00299647 /* Notification.swift in Sources */,
DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */,
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */,
DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */,

View File

@ -0,0 +1,39 @@
//
// NotificationItem.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/13.
//
import CoreData
import Foundation
enum NotificationItem {
case notification(objectID: NSManagedObjectID)
case bottomLoader
}
extension NotificationItem: Equatable {
static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
switch (lhs, rhs) {
case (.notification(let idLeft), .notification(let idRight)):
return idLeft == idRight
case (.bottomLoader, .bottomLoader):
return true
default:
return false
}
}
}
extension NotificationItem: Hashable {
func hash(into hasher: inout Hasher) {
switch self {
case .notification(let id):
hasher.combine(id)
case .bottomLoader:
hasher.combine(String(describing: NotificationItem.bottomLoader.self))
}
}
}

View File

@ -0,0 +1,118 @@
//
// NotificationSection.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/13.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
enum NotificationSection: Equatable, Hashable {
case main
}
extension NotificationSection {
static func tableViewDiffableDataSource(
for tableView: UITableView,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
managedObjectContext: NSManagedObjectContext,
delegate: NotificationTableViewCellDelegate,
dependency: NeedsDependency,
requestUserID: String
) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> {
UITableViewDiffableDataSource(tableView: tableView) {
[weak delegate, weak dependency]
(tableView, indexPath, notificationItem) -> UITableViewCell? in
guard let dependency = dependency else { return nil }
switch notificationItem {
case .notification(let objectID):
let notification = managedObjectContext.object(with: objectID) as! MastodonNotification
guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else {
assertionFailure()
return nil
}
let timeText = notification.createAt.shortTimeAgoSinceNow
let actionText = type.actionText
let actionImageName = type.actionImageName
let color = type.color
if let status = notification.status {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
cell.delegate = delegate
let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height)
StatusSection.configure(cell: cell,
dependency: dependency,
readableLayoutFrame: frame,
timestampUpdatePublisher: timestampUpdatePublisher,
status: status,
requestUserID: requestUserID,
statusItemAttribute: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false))
timestampUpdatePublisher
.sink { _ in
let timeText = notification.createAt.shortTimeAgoSinceNow
cell.actionLabel.text = actionText + " · " + timeText
}
.store(in: &cell.disposeBag)
cell.actionImageBackground.backgroundColor = color
cell.actionLabel.text = actionText + " · " + timeText
cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName
if let url = notification.account.avatarImageURL() {
cell.avatatImageView.af.setImage(
withURL: url,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
}
cell.avatatImageView.gesture().sink { [weak cell] _ in
cell?.delegate?.userAvatarDidPressed(notification: notification)
}
.store(in: &cell.disposeBag)
if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) {
cell.actionImageView.image = actionImage
}
return cell
} else {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell
cell.delegate = delegate
timestampUpdatePublisher
.sink { _ in
let timeText = notification.createAt.shortTimeAgoSinceNow
cell.actionLabel.text = actionText + " · " + timeText
}
.store(in: &cell.disposeBag)
cell.actionImageBackground.backgroundColor = color
cell.actionLabel.text = actionText + " · " + timeText
cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName
if let url = notification.account.avatarImageURL() {
cell.avatatImageView.af.setImage(
withURL: url,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
}
cell.avatatImageView.gesture().sink { [weak cell] _ in
cell?.delegate?.userAvatarDidPressed(notification: notification)
}
.store(in: &cell.disposeBag)
if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) {
cell.actionImageView.image = actionImage
}
return cell
}
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
}
}
}
}

View File

@ -44,7 +44,7 @@ extension SearchResultSection {
cell.config(with: user)
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
return cell
}

View File

@ -10,6 +10,12 @@ import CoreData
import CoreDataStack
import os.log
import UIKit
import AVKit
protocol StatusCell : DisposeBagCollectable {
var statusView: StatusView { get }
var pollCountdownSubscription: AnyCancellable? { get set }
}
enum StatusSection: Equatable, Hashable {
case main
@ -127,7 +133,7 @@ extension StatusSection {
extension StatusSection {
static func configure(
cell: StatusTableViewCell,
cell: StatusCell,
dependency: NeedsDependency,
readableLayoutFrame: CGRect?,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
@ -260,14 +266,27 @@ extension StatusSection {
if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment)
{
let parent = cell.delegate?.parent()
var parent: UIViewController?
var playerViewControllerDelegate: AVPlayerViewControllerDelegate? = nil
switch cell {
case is StatusTableViewCell:
let statusTableViewCell = cell as! StatusTableViewCell
parent = statusTableViewCell.delegate?.parent()
playerViewControllerDelegate = statusTableViewCell.delegate?.playerViewControllerDelegate
case is NotificationTableViewCell:
let notificationTableViewCell = cell as! NotificationTableViewCell
parent = notificationTableViewCell.delegate?.parent()
default:
parent = nil
assertionFailure("unknown cell")
}
let playerContainerView = cell.statusView.playerContainerView
let playerViewController = playerContainerView.setupPlayer(
aspectRatio: videoPlayerViewModel.videoSize,
maxSize: playerViewMaxSize,
parent: parent
)
playerViewController.delegate = cell.delegate?.playerViewControllerDelegate
playerViewController.delegate = playerViewControllerDelegate
playerViewController.player = videoPlayerViewModel.player
playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
@ -325,7 +344,9 @@ extension StatusSection {
StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID)
// separator line
cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden
if let statusTableViewCell = cell as? StatusTableViewCell {
statusTableViewCell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden
}
// set date
let createdAt = (status.reblog ?? status).createdAt
@ -388,7 +409,7 @@ extension StatusSection {
static func configureHeader(
cell: StatusTableViewCell,
cell: StatusCell,
status: Status
) {
if status.reblog != nil {
@ -416,7 +437,7 @@ extension StatusSection {
}
static func configureActionToolBar(
cell: StatusTableViewCell,
cell: StatusCell,
status: Status,
requestUserID: String
) {
@ -447,7 +468,7 @@ extension StatusSection {
}
static func configurePoll(
cell: StatusTableViewCell,
cell: StatusCell,
poll: Poll?,
requestUserID: String,
updateProgressAnimated: Bool,

View File

@ -0,0 +1,75 @@
//
// Mastodon+Entity+Notification+Type.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/19.
//
import Foundation
import MastodonSDK
import UIKit
extension Mastodon.Entity.Notification.NotificationType {
public var color: UIColor {
get {
var color: UIColor
switch self {
case .follow:
color = Asset.Colors.brandBlue.color
case .favourite:
color = Asset.Colors.Notification.favourite.color
case .reblog:
color = Asset.Colors.Notification.reblog.color
case .mention:
color = Asset.Colors.Notification.mention.color
case .poll:
color = Asset.Colors.brandBlue.color
default:
color = .clear
}
return color
}
}
public var actionText: String {
get {
var actionText: String
switch self {
case .follow:
actionText = L10n.Scene.Notification.Action.follow
case .favourite:
actionText = L10n.Scene.Notification.Action.favourite
case .reblog:
actionText = L10n.Scene.Notification.Action.reblog
case .mention:
actionText = L10n.Scene.Notification.Action.mention
case .poll:
actionText = L10n.Scene.Notification.Action.poll
default:
actionText = ""
}
return actionText
}
}
public var actionImageName: String {
get {
var actionImageName: String
switch self {
case .follow:
actionImageName = "person.crop.circle.badge.checkmark"
case .favourite:
actionImageName = "star.fill"
case .reblog:
actionImageName = "arrow.2.squarepath"
case .mention:
actionImageName = "at"
case .poll:
actionImageName = "list.bullet"
default:
actionImageName = ""
}
return actionImageName
}
}
}

View File

@ -1,261 +0,0 @@
//
// UIView+Constraint.swift
// Mastodon
//
// Created by sxiaojian on 2021/3/31.
//
import UIKit
enum Dimension {
case width
case height
var layoutAttribute: NSLayoutConstraint.Attribute {
switch self {
case .width:
return .width
case .height:
return .height
}
}
}
extension UIView {
func constrain(toSuperviewEdges: UIEdgeInsets?) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return}
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
NSLayoutConstraint(item: self,
attribute: .leading,
relatedBy: .equal,
toItem: view,
attribute: .leading,
multiplier: 1.0,
constant: toSuperviewEdges?.left ?? 0.0),
NSLayoutConstraint(item: self,
attribute: .top,
relatedBy: .equal,
toItem: view,
attribute: .top,
multiplier: 1.0,
constant: toSuperviewEdges?.top ?? 0.0),
NSLayoutConstraint(item: view,
attribute: .trailing,
relatedBy: .equal,
toItem: self,
attribute: .trailing,
multiplier: 1.0,
constant: toSuperviewEdges?.right ?? 0.0),
NSLayoutConstraint(item: view,
attribute: .bottom,
relatedBy: .equal,
toItem: self,
attribute: .bottom,
multiplier: 1.0,
constant: toSuperviewEdges?.bottom ?? 0.0)
])
}
func constrain(_ constraints: [NSLayoutConstraint?]) {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate(constraints.compactMap { $0 })
}
func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView, constant: CGFloat?) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil}
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: constant ?? 0.0)
}
func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil}
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: 0.0)
}
func constraint(_ dimension: Dimension, constant: CGFloat) -> NSLayoutConstraint? {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil }
translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self,
attribute: dimension.layoutAttribute,
relatedBy: .equal,
toItem: nil,
attribute: .notAnAttribute,
multiplier: 1.0,
constant: constant)
}
func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view, constant: sidePadding),
NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: topPadding),
constraint(.trailing, toView: view, constant: -sidePadding)
])
}
func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view, constant: sidePadding),
constraint(.top, toView: view, constant: topPadding),
constraint(.trailing, toView: view, constant: -sidePadding)
])
}
func constrainTopCorners(height: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view),
constraint(.top, toView: view),
constraint(.trailing, toView: view),
constraint(.height, constant: height)
])
}
func constrainBottomCorners(sidePadding: CGFloat, bottomPadding: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view, constant: sidePadding),
constraint(.bottom, toView: view, constant: -bottomPadding),
constraint(.trailing, toView: view, constant: -sidePadding)
])
}
func constrainBottomCorners(height: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.leading, toView: view),
constraint(.bottom, toView: view),
constraint(.trailing, toView: view),
constraint(.height, constant: height)
])
}
func constrainLeadingCorners() {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.top, toView: view),
constraint(.leading, toView: view),
constraint(.bottom, toView: view)
])
}
func constrainTrailingCorners() {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.top, toView: view),
constraint(.trailing, toView: view),
constraint(.bottom, toView: view)
])
}
func constrainToCenter() {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
constraint(.centerX, toView: view),
constraint(.centerY, toView: view)
])
}
func pin(toSize: CGSize) {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
widthAnchor.constraint(equalToConstant: toSize.width),
heightAnchor.constraint(equalToConstant: toSize.height)])
}
func pin(top: CGFloat?,left: CGFloat?,bottom: CGFloat?, right: CGFloat?) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
var constraints = [NSLayoutConstraint]()
if let topConstant = top {
constraints.append(topAnchor.constraint(equalTo: view.topAnchor, constant: topConstant))
}
if let leftConstant = left {
constraints.append(leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leftConstant))
}
if let bottomConstant = bottom {
constraints.append(view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomConstant))
}
if let rightConstant = right {
constraints.append(view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightConstant))
}
constrain(constraints)
}
func pinTopLeft(padding: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding),
topAnchor.constraint(equalTo: view.topAnchor, constant: padding)])
}
func pinTopLeft(top: CGFloat, left: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: left),
topAnchor.constraint(equalTo: view.topAnchor, constant: top)])
}
func pinTopRight(padding: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding),
topAnchor.constraint(equalTo: view.topAnchor, constant: padding)])
}
func pinTopRight(top: CGFloat, right: CGFloat) {
guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: right),
topAnchor.constraint(equalTo: view.topAnchor, constant: top)])
}
func pinTopLeft(toView: UIView, topPadding: CGFloat) {
guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return }
translatesAutoresizingMaskIntoConstraints = false
constrain([
leadingAnchor.constraint(equalTo: toView.leadingAnchor),
topAnchor.constraint(equalTo: toView.bottomAnchor, constant: topPadding)])
}
/// Cross-fades between two views by animating their alpha then setting one or the other hidden.
/// - parameters:
/// - lhs: left view
/// - rhs: right view
/// - toRight: fade to the right view if true, fade to the left view if false
/// - duration: animation duration
///
static func crossfade(_ lhs: UIView, _ rhs: UIView, toRight: Bool, duration: TimeInterval) {
lhs.alpha = toRight ? 1.0 : 0.0
rhs.alpha = toRight ? 0.0 : 1.0
lhs.isHidden = false
rhs.isHidden = false
UIView.animate(withDuration: duration, animations: {
lhs.alpha = toRight ? 0.0 : 1.0
rhs.alpha = toRight ? 1.0 : 0.0
}, completion: { _ in
lhs.isHidden = toRight
rhs.isHidden = !toRight
})
}
}

View File

@ -0,0 +1,93 @@
//
// UIView+Gesture.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/14.
//
import Combine
import Foundation
import UIKit
struct GesturePublisher: Publisher {
typealias Output = GestureType
typealias Failure = Never
private let view: UIView
private let gestureType: GestureType
init(view: UIView, gestureType: GestureType) {
self.view = view
self.gestureType = gestureType
}
func receive<S>(subscriber: S) where S: Subscriber,
GesturePublisher.Failure == S.Failure, GesturePublisher.Output
== S.Input
{
let subscription = GestureSubscription(
subscriber: subscriber,
view: view,
gestureType: gestureType
)
subscriber.receive(subscription: subscription)
}
}
enum GestureType {
case tap(UITapGestureRecognizer = .init())
case swipe(UISwipeGestureRecognizer = .init())
case longPress(UILongPressGestureRecognizer = .init())
case pan(UIPanGestureRecognizer = .init())
case pinch(UIPinchGestureRecognizer = .init())
case edge(UIScreenEdgePanGestureRecognizer = .init())
func get() -> UIGestureRecognizer {
switch self {
case let .tap(tapGesture):
return tapGesture
case let .swipe(swipeGesture):
return swipeGesture
case let .longPress(longPressGesture):
return longPressGesture
case let .pan(panGesture):
return panGesture
case let .pinch(pinchGesture):
return pinchGesture
case let .edge(edgePanGesture):
return edgePanGesture
}
}
}
class GestureSubscription<S: Subscriber>: Subscription where S.Input == GestureType, S.Failure == Never {
private var subscriber: S?
private var gestureType: GestureType
private var view: UIView
init(subscriber: S, view: UIView, gestureType: GestureType) {
self.subscriber = subscriber
self.view = view
self.gestureType = gestureType
configureGesture(gestureType)
}
private func configureGesture(_ gestureType: GestureType) {
let gesture = gestureType.get()
gesture.addTarget(self, action: #selector(handler))
view.addGestureRecognizer(gesture)
}
func request(_ demand: Subscribers.Demand) {}
func cancel() {
subscriber = nil
}
@objc
private func handler() {
_ = subscriber?.receive(gestureType)
}
}
extension UIView {
func gesture(_ gestureType: GestureType = .tap()) -> GesturePublisher {
isUserInteractionEnabled = true
return GesturePublisher(view: self, gestureType: gestureType)
}
}

View File

@ -0,0 +1,18 @@
//
// UIView+Remove.swift
// Mastodon
//
// Created by xiaojian sun on 2021/4/16.
//
import Foundation
import UIKit
extension UIView {
func removeFromStackView() {
if let stackView = self.superview as? UIStackView {
stackView.removeArrangedSubview(self)
}
self.removeFromSuperview()
}
}

View File

@ -52,6 +52,7 @@ internal enum Asset {
internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background")
}
internal enum Border {
internal static let notification = ColorAsset(name: "Colors/Border/notification")
internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard")
}
internal enum Button {
@ -69,6 +70,11 @@ internal enum Asset {
internal static let primary = ColorAsset(name: "Colors/Label/primary")
internal static let secondary = ColorAsset(name: "Colors/Label/secondary")
}
internal enum Notification {
internal static let favourite = ColorAsset(name: "Colors/Notification/favourite")
internal static let mention = ColorAsset(name: "Colors/Notification/mention")
internal static let reblog = ColorAsset(name: "Colors/Notification/reblog")
}
internal enum Shadow {
internal static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard")
}

View File

@ -357,6 +357,26 @@ internal enum L10n {
internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing")
}
}
internal enum Notification {
internal enum Action {
/// favorited your post
internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite")
/// followed you
internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow")
/// mentioned you
internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention")
/// Your poll has ended
internal static let poll = L10n.tr("Localizable", "Scene.Notification.Action.Poll")
/// rebloged your post
internal static let reblog = L10n.tr("Localizable", "Scene.Notification.Action.Reblog")
}
internal enum Title {
/// Everything
internal static let everything = L10n.tr("Localizable", "Scene.Notification.Title.Everything")
/// Mentions
internal static let mentions = L10n.tr("Localizable", "Scene.Notification.Title.Mentions")
}
}
internal enum Profile {
/// %@ posts
internal static func subtitle(_ p1: Any) -> String {

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xE8",
"green" : "0xE1",
"red" : "0xD9"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "60",
"green" : "58",
"red" : "58"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0",
"green" : "204",
"red" : "255"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "222",
"green" : "82",
"red" : "175"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "242",
"green" : "90",
"red" : "191"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "89",
"green" : "199",
"red" : "52"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "75",
"green" : "215",
"red" : "20"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -120,6 +120,13 @@ tap the link to confirm your account.";
"Scene.HomeTimeline.NavigationBarState.Published" = "Published!";
"Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post...";
"Scene.HomeTimeline.Title" = "Home";
"Scene.Notification.Action.Favourite" = "favorited your post";
"Scene.Notification.Action.Follow" = "followed you";
"Scene.Notification.Action.Mention" = "mentioned you";
"Scene.Notification.Action.Poll" = "Your poll has ended";
"Scene.Notification.Action.Reblog" = "rebloged your post";
"Scene.Notification.Title.Everything" = "Everything";
"Scene.Notification.Title.Mentions" = "Mentions";
"Scene.Profile.Dashboard.Followers" = "followers";
"Scene.Profile.Dashboard.Following" = "following";
"Scene.Profile.Dashboard.Posts" = "posts";

View File

@ -123,9 +123,11 @@ extension HashtagTimelineViewModel.LoadOldestState {
assertionFailure()
return
}
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
DispatchQueue.main.async {
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
}
}
}
}

View File

@ -103,9 +103,11 @@ extension HomeTimelineViewModel.LoadOldestState {
assertionFailure()
return
}
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
DispatchQueue.main.async {
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
}
}
}
}

View File

@ -2,23 +2,227 @@
// NotificationViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-2-23.
// Created by sxiaojian on 2021/4/12.
//
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
import OSLog
import UIKit
final class NotificationViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
private(set) lazy var viewModel = NotificationViewModel(context: context)
let segmentControl: UISegmentedControl = {
let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything, L10n.Scene.Notification.Title.mentions])
control.selectedSegmentIndex = NotificationViewModel.NotificationSegment.EveryThing.rawValue
return control
}()
let tableView: UITableView = {
let tableView = ControlContainableTableView()
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .singleLine
tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self))
tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self))
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.tableFooterView = UIView()
tableView.estimatedRowHeight = UITableView.automaticDimension
return tableView
}()
let refreshControl = UIRefreshControl()
}
extension NotificationViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
navigationItem.titleView = segmentControl
segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(NotificationViewController.refreshControlValueChanged(_:)), for: .valueChanged)
tableView.delegate = self
viewModel.tableView = tableView
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self)
viewModel.viewDidLoad.send()
// bind refresh control
viewModel.isFetchingLatestNotification
.receive(on: DispatchQueue.main)
.sink { [weak self] isFetching in
guard let self = self else { return }
if !isFetching {
UIView.animate(withDuration: 0.5) { [weak self] in
guard let self = self else { return }
self.refreshControl.endRefreshing()
}
}
}
.store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// needs trigger manually after onboarding dismiss
setNeedsStatusBarAppearanceUpdate()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 {
self.viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
}
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate { _ in
// do nothing
} completion: { _ in
self.tableView.reloadData()
}
}
}
extension NotificationViewController {
@objc private func segmentedControlValueChanged(_ sender: UISegmentedControl) {
os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", (#file as NSString).lastPathComponent, #line, #function, sender.selectedSegmentIndex)
guard let domain = viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = viewModel.activeMastodonAuthenticationBox.value?.userID else {
return
}
if sender.selectedSegmentIndex == NotificationViewModel.NotificationSegment.EveryThing.rawValue {
viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID)
} else {
viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue)
}
viewModel.selectedIndex.value = NotificationViewModel.NotificationSegment(rawValue: sender.selectedSegmentIndex)!
}
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
guard viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) else {
sender.endRefreshing()
return
}
}
}
extension NotificationViewController {
func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
let key = item.hashValue
let frame = cell.frame
viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key))
}
func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let diffableDataSource = viewModel.diffableDataSource else { return UITableView.automaticDimension }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension }
guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
if case .bottomLoader = item {
return TimelineLoaderTableViewCell.cellHeight
} else {
return UITableView.automaticDimension
}
}
return ceil(frame.height)
}
}
// MARK: - UITableViewDelegate
extension NotificationViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .notification(let objectID):
let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification
if let status = notification.status {
let viewModel = ThreadViewModel(context: context, optionalStatus: status)
coordinator.present(scene: .thread(viewModel: viewModel), from: self, transition: .show)
} else {
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account)
coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
}
default:
break
}
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .bottomLoader:
if !tableView.isDragging, !tableView.isDecelerating {
viewModel.loadoldestStateMachine.enter(NotificationViewModel.LoadOldestState.Loading.self)
}
default:
break
}
}
}
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
extension NotificationViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
func navigationBar() -> UINavigationBar? {
navigationController?.navigationBar
}
}
extension NotificationViewController: NotificationTableViewCellDelegate {
func userAvatarDidPressed(notification: MastodonNotification) {
let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account)
DispatchQueue.main.async {
self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show)
}
}
func parent() -> UIViewController {
self
}
}
// MARK: - UIScrollViewDelegate
extension NotificationViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
handleScrollViewDidScroll(scrollView)
}
}
extension NotificationViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = NotificationViewModel.LoadOldestState.Loading
var loadMoreConfigurableTableView: UITableView { tableView }
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }
}

View File

@ -0,0 +1,95 @@
//
// NotificationViewModel+LoadLatestState.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/13.
//
import CoreData
import CoreDataStack
import Foundation
import GameplayKit
import MastodonSDK
import os.log
import func QuartzCore.CACurrentMediaTime
extension NotificationViewModel {
class LoadLatestState: GKState {
weak var viewModel: NotificationViewModel?
init(viewModel: NotificationViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription)
viewModel?.loadLatestStateMachinePublisher.send(self)
}
}
}
extension NotificationViewModel.LoadLatestState {
class Initial: NotificationViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Loading.self
}
}
class Loading: NotificationViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Fail.self || stateClass == Idle.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else {
// sign out when loading will enter here
stateMachine.enter(Fail.self)
return
}
let query = Mastodon.API.Notifications.Query(
maxID: nil,
sinceID: nil,
minID: nil,
limit: nil,
excludeTypes: [.followRequest],
accountID: nil
)
viewModel.context.apiService.allNotifications(
domain: activeMastodonAuthenticationBox.domain,
query: query,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.sink { completion in
switch completion {
case .failure(let error):
viewModel.isFetchingLatestNotification.value = false
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
stateMachine.enter(Idle.self)
} receiveValue: { response in
if response.value.isEmpty {
viewModel.isFetchingLatestNotification.value = false
}
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: NotificationViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Loading.self || stateClass == Idle.self
}
}
class Idle: NotificationViewModel.LoadLatestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Loading.self
}
}
}

View File

@ -0,0 +1,145 @@
//
// NotificationViewModel+LoadOldestState.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/14.
//
import CoreDataStack
import Foundation
import GameplayKit
import MastodonSDK
import os.log
extension NotificationViewModel {
class LoadOldestState: GKState {
weak var viewModel: NotificationViewModel?
init(viewModel: NotificationViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription)
viewModel?.loadOldestStateMachinePublisher.send(self)
}
}
}
extension NotificationViewModel.LoadOldestState {
class Initial: NotificationViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false }
return stateClass == Loading.self
}
}
class Loading: NotificationViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else {
assertionFailure()
stateMachine.enter(Fail.self)
return
}
let notifications: [MastodonNotification]? = {
let request = MastodonNotification.sortedFetchRequest
request.predicate = MastodonNotification.predicate(domain: activeMastodonAuthenticationBox.domain, userID: activeMastodonAuthenticationBox.userID)
request.returnsObjectsAsFaults = false
do {
return try self.viewModel?.context.managedObjectContext.fetch(request)
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
guard let last = notifications?.last else {
stateMachine.enter(Idle.self)
return
}
let maxID = last.id
let query = Mastodon.API.Notifications.Query(
maxID: maxID,
sinceID: nil,
minID: nil,
limit: nil,
excludeTypes: [.followRequest],
accountID: nil)
viewModel.context.apiService.allNotifications(
domain: activeMastodonAuthenticationBox.domain,
query: query,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
case .finished:
// handle isFetchingLatestTimeline in fetch controller delegate
break
}
stateMachine.enter(Idle.self)
} receiveValue: { [weak viewModel] response in
guard let viewModel = viewModel else { return }
switch viewModel.selectedIndex.value {
case .EveryThing:
if response.value.isEmpty {
stateMachine.enter(NoMore.self)
} else {
stateMachine.enter(Idle.self)
}
case .Mentions:
viewModel.noMoreNotification.value = response.value.isEmpty
let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention }
if list.isEmpty {
stateMachine.enter(NoMore.self)
} else {
stateMachine.enter(Idle.self)
}
}
}
.store(in: &viewModel.disposeBag)
}
}
class Fail: NotificationViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Loading.self || stateClass == Idle.self
}
}
class Idle: NotificationViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
stateClass == Loading.self
}
}
class NoMore: NotificationViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// reset state if needs
stateClass == Idle.self
}
override func didEnter(from previousState: GKState?) {
guard let viewModel = viewModel else { return }
guard let diffableDataSource = viewModel.diffableDataSource else {
assertionFailure()
return
}
DispatchQueue.main.async {
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
}
}
}
}

View File

@ -0,0 +1,127 @@
//
// NotificationViewModel+diffable.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/13.
//
import CoreData
import CoreDataStack
import os.log
import UIKit
extension NotificationViewModel {
func setupDiffableDataSource(
for tableView: UITableView,
delegate: NotificationTableViewCellDelegate,
dependency: NeedsDependency
) {
let timestampUpdatePublisher = Timer.publish(every: 30.0, on: .main, in: .common)
.autoconnect()
.share()
.eraseToAnyPublisher()
guard let userid = activeMastodonAuthenticationBox.value?.userID else {
return
}
diffableDataSource = NotificationSection.tableViewDiffableDataSource(
for: tableView,
timestampUpdatePublisher: timestampUpdatePublisher,
managedObjectContext: context.managedObjectContext,
delegate: delegate,
dependency: dependency,
requestUserID: userid
)
}
}
extension NotificationViewModel: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function)
guard let tableView = self.tableView else { return }
guard let navigationBar = contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
let predicate = fetchedResultsController.fetchRequest.predicate
let parentManagedObjectContext = fetchedResultsController.managedObjectContext
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
managedObjectContext.parent = parentManagedObjectContext
managedObjectContext.perform {
let notifications: [MastodonNotification] = {
let request = MastodonNotification.sortedFetchRequest
request.returnsObjectsAsFaults = false
request.predicate = predicate
do {
return try managedObjectContext.fetch(request)
} catch {
assertionFailure(error.localizedDescription)
return []
}
}()
DispatchQueue.main.async {
let oldSnapshot = diffableDataSource.snapshot()
var newSnapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>()
newSnapshot.appendSections([.main])
newSnapshot.appendItems(notifications.map { NotificationItem.notification(objectID: $0.objectID) }, toSection: .main)
if !notifications.isEmpty, self.noMoreNotification.value == false {
newSnapshot.appendItems([.bottomLoader], toSection: .main)
}
guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else {
diffableDataSource.apply(newSnapshot, animatingDifferences: false)
self.isFetchingLatestNotification.value = false
tableView.reloadData()
return
}
diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false)
tableView.contentOffset.y = tableView.contentOffset.y - difference.offset
self.isFetchingLatestNotification.value = false
}
}
}
}
private struct Difference<T> {
let item: T
let sourceIndexPath: IndexPath
let targetIndexPath: IndexPath
let offset: CGFloat
}
private func calculateReloadSnapshotDifference<T: Hashable>(
navigationBar: UINavigationBar,
tableView: UITableView,
oldSnapshot: NSDiffableDataSourceSnapshot<NotificationSection, T>,
newSnapshot: NSDiffableDataSourceSnapshot<NotificationSection, T>
) -> Difference<T>? {
guard oldSnapshot.numberOfItems != 0 else { return nil }
// old snapshot not empty. set source index path to first item if not match
let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0)
guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil }
if oldSnapshot.itemIdentifiers.elementsEqual(newSnapshot.itemIdentifiers) {
return nil
}
let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row]
guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil }
let targetIndexPath = IndexPath(row: itemIndex, section: 0)
let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar)
return Difference(
item: timelineItem,
sourceIndexPath: sourceIndexPath,
targetIndexPath: targetIndexPath,
offset: offset
)
}
}

View File

@ -0,0 +1,130 @@
//
// NotificationViewModel.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/12.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import GameplayKit
import MastodonSDK
import UIKit
final class NotificationViewModel: NSObject {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
weak var tableView: UITableView?
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
let viewDidLoad = PassthroughSubject<Void, Never>()
let selectedIndex = CurrentValueSubject<NotificationSegment, Never>(.EveryThing)
let noMoreNotification = CurrentValueSubject<Bool, Never>(false)
let activeMastodonAuthenticationBox: CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>
let fetchedResultsController: NSFetchedResultsController<MastodonNotification>!
let notificationPredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
let cellFrameCache = NSCache<NSNumber, NSValue>()
let isFetchingLatestNotification = CurrentValueSubject<Bool, Never>(false)
// output
var diffableDataSource: UITableViewDiffableDataSource<NotificationSection, NotificationItem>!
// top loader
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
LoadLatestState.Initial(viewModel: self),
LoadLatestState.Loading(viewModel: self),
LoadLatestState.Fail(viewModel: self),
LoadLatestState.Idle(viewModel: self),
])
stateMachine.enter(LoadLatestState.Initial.self)
return stateMachine
}()
lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil)
// bottom loader
private(set) lazy var loadoldestStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
LoadOldestState.Initial(viewModel: self),
LoadOldestState.Loading(viewModel: self),
LoadOldestState.Fail(viewModel: self),
LoadOldestState.Idle(viewModel: self),
LoadOldestState.NoMore(viewModel: self),
])
stateMachine.enter(LoadOldestState.Initial.self)
return stateMachine
}()
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
init(context: AppContext) {
self.context = context
self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
self.fetchedResultsController = {
let fetchRequest = MastodonNotification.sortedFetchRequest
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(MastodonNotification.status), #keyPath(MastodonNotification.account)]
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context.managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
fetchedResultsController.delegate = self
context.authenticationService.activeMastodonAuthenticationBox
.sink(receiveValue: { [weak self] box in
guard let self = self else { return }
self.activeMastodonAuthenticationBox.value = box
if let domain = box?.domain, let userID = box?.userID {
self.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID)
}
})
.store(in: &disposeBag)
notificationPredicate
.compactMap { $0 }
.sink { [weak self] predicate in
guard let self = self else { return }
self.fetchedResultsController.fetchRequest.predicate = predicate
do {
self.diffableDataSource?.defaultRowAnimation = .fade
try self.fetchedResultsController.performFetch()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self = self else { return }
self.diffableDataSource?.defaultRowAnimation = .automatic
}
} catch {
assertionFailure(error.localizedDescription)
}
}
.store(in: &disposeBag)
viewDidLoad
.sink { [weak self] in
guard let domain = self?.activeMastodonAuthenticationBox.value?.domain, let userID = self?.activeMastodonAuthenticationBox.value?.userID else { return }
self?.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID)
}
.store(in: &disposeBag)
}
}
extension NotificationViewModel {
enum NotificationSegment: Int {
case EveryThing
case Mentions
}
}

View File

@ -0,0 +1,209 @@
//
// NotificationStatusTableViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/14.
//
import Combine
import Foundation
import UIKit
final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
static let actionImageBorderWidth: CGFloat = 2
static let statusPadding = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24)
var disposeBag = Set<AnyCancellable>()
var pollCountdownSubscription: AnyCancellable?
var delegate: NotificationTableViewCellDelegate?
let avatatImageView: UIImageView = {
let imageView = UIImageView()
imageView.layer.cornerRadius = 4
imageView.layer.cornerCurve = .continuous
imageView.clipsToBounds = true
return imageView
}()
let actionImageView: UIImageView = {
let imageView = UIImageView()
imageView.tintColor = Asset.Colors.Background.systemBackground.color
return imageView
}()
let actionImageBackground: UIView = {
let view = UIView()
view.layer.cornerRadius = (24 + NotificationStatusTableViewCell.actionImageBorderWidth) / 2
view.layer.cornerCurve = .continuous
view.clipsToBounds = true
view.layer.borderWidth = NotificationStatusTableViewCell.actionImageBorderWidth
view.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
view.tintColor = Asset.Colors.Background.systemBackground.color
return view
}()
let avatarContainer: UIView = {
let view = UIView()
return view
}()
let actionLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.secondary.color
label.font = UIFont.preferredFont(forTextStyle: .body)
label.lineBreakMode = .byTruncatingTail
return label
}()
let nameLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.brandBlue.color
label.font = .systemFont(ofSize: 15, weight: .semibold)
label.lineBreakMode = .byTruncatingTail
return label
}()
let statusBorder: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.layer.cornerRadius = 6
view.layer.borderWidth = 2
view.layer.cornerCurve = .continuous
view.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
view.clipsToBounds = true
return view
}()
let statusView = StatusView()
override func prepareForReuse() {
super.prepareForReuse()
avatatImageView.af.cancelImageRequest()
statusView.isStatusTextSensitive = false
statusView.cleanUpContentWarning()
statusView.pollTableView.dataSource = nil
statusView.playerContainerView.reset()
statusView.playerContainerView.isHidden = true
disposeBag.removeAll()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
override func layoutSubviews() {
super.layoutSubviews()
DispatchQueue.main.async {
self.statusView.drawContentWarningImageView()
}
}
}
extension NotificationStatusTableViewCell {
func configure() {
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.alignment = .top
containerStackView.spacing = 4
containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
])
containerStackView.addArrangedSubview(avatarContainer)
avatarContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1),
avatarContainer.widthAnchor.constraint(equalToConstant: 47).priority(.required - 1)
])
avatarContainer.addSubview(avatatImageView)
avatatImageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
avatatImageView.heightAnchor.constraint(equalToConstant: 35).priority(.required - 1),
avatatImageView.widthAnchor.constraint(equalToConstant: 35).priority(.required - 1),
avatatImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
avatatImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor)
])
avatarContainer.addSubview(actionImageBackground)
actionImageBackground.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
actionImageBackground.heightAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1),
actionImageBackground.widthAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1),
actionImageBackground.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
actionImageBackground.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor)
])
avatarContainer.addSubview(actionImageView)
actionImageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
actionImageView.centerXAnchor.constraint(equalTo: actionImageBackground.centerXAnchor),
actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor)
])
let actionStackView = UIStackView()
actionStackView.axis = .horizontal
actionStackView.distribution = .fill
actionStackView.spacing = 4
actionStackView.translatesAutoresizingMaskIntoConstraints = false
nameLabel.translatesAutoresizingMaskIntoConstraints = false
actionStackView.addArrangedSubview(nameLabel)
actionLabel.translatesAutoresizingMaskIntoConstraints = false
actionStackView.addArrangedSubview(actionLabel)
nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
let statusStackView = UIStackView()
statusStackView.axis = .vertical
statusStackView.distribution = .fill
statusStackView.spacing = 4
statusStackView.translatesAutoresizingMaskIntoConstraints = false
statusView.translatesAutoresizingMaskIntoConstraints = false
statusStackView.addArrangedSubview(actionStackView)
statusBorder.translatesAutoresizingMaskIntoConstraints = false
statusView.translatesAutoresizingMaskIntoConstraints = false
statusBorder.addSubview(statusView)
NSLayoutConstraint.activate([
statusView.topAnchor.constraint(equalTo: statusBorder.topAnchor, constant: 12),
statusView.leadingAnchor.constraint(equalTo: statusBorder.leadingAnchor, constant: 12),
statusBorder.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 12),
statusBorder.trailingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 12),
])
statusStackView.addArrangedSubview(statusBorder)
containerStackView.addArrangedSubview(statusStackView)
statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
statusView.isUserInteractionEnabled = false
// remove item don't display
statusView.actionToolbarContainer.removeFromStackView()
// it affect stackView's height,need remove
statusView.avatarView.removeFromStackView()
statusView.usernameLabel.removeFromStackView()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor
actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
}
}

View File

@ -0,0 +1,155 @@
//
// NotificationTableViewCell.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/13.
//
import Combine
import CoreDataStack
import Foundation
import UIKit
protocol NotificationTableViewCellDelegate: AnyObject {
var context: AppContext! { get }
func parent() -> UIViewController
func userAvatarDidPressed(notification: MastodonNotification)
}
final class NotificationTableViewCell: UITableViewCell {
static let actionImageBorderWidth: CGFloat = 2
var disposeBag = Set<AnyCancellable>()
var delegate: NotificationTableViewCellDelegate?
let avatatImageView: UIImageView = {
let imageView = UIImageView()
imageView.layer.cornerRadius = 4
imageView.layer.cornerCurve = .continuous
imageView.clipsToBounds = true
return imageView
}()
let actionImageView: UIImageView = {
let imageView = UIImageView()
imageView.tintColor = Asset.Colors.Background.systemBackground.color
return imageView
}()
let actionImageBackground: UIView = {
let view = UIView()
view.layer.cornerRadius = (24 + NotificationTableViewCell.actionImageBorderWidth) / 2
view.layer.cornerCurve = .continuous
view.clipsToBounds = true
view.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth
view.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
view.tintColor = Asset.Colors.Background.systemBackground.color
return view
}()
let avatarContainer: UIView = {
let view = UIView()
return view
}()
let actionLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.Label.secondary.color
label.font = UIFont.preferredFont(forTextStyle: .body)
label.lineBreakMode = .byTruncatingTail
return label
}()
let nameLabel: UILabel = {
let label = UILabel()
label.textColor = Asset.Colors.brandBlue.color
label.font = .systemFont(ofSize: 15, weight: .semibold)
label.lineBreakMode = .byTruncatingTail
return label
}()
override func prepareForReuse() {
super.prepareForReuse()
avatatImageView.af.cancelImageRequest()
disposeBag.removeAll()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configure()
}
}
extension NotificationTableViewCell {
func configure() {
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.alignment = .center
containerStackView.spacing = 4
containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
])
containerStackView.addArrangedSubview(avatarContainer)
avatarContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1),
avatarContainer.widthAnchor.constraint(equalToConstant: 47).priority(.required - 1)
])
avatarContainer.addSubview(avatatImageView)
avatatImageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
avatatImageView.heightAnchor.constraint(equalToConstant: 35).priority(.required - 1),
avatatImageView.widthAnchor.constraint(equalToConstant: 35).priority(.required - 1),
avatatImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
avatatImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor)
])
avatarContainer.addSubview(actionImageBackground)
actionImageBackground.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
actionImageBackground.heightAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1),
actionImageBackground.widthAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1),
actionImageBackground.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
actionImageBackground.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor)
])
avatarContainer.addSubview(actionImageView)
actionImageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
actionImageView.centerXAnchor.constraint(equalTo: actionImageBackground.centerXAnchor),
actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor)
])
nameLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(nameLabel)
actionLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(actionLabel)
nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor
}
}

View File

@ -62,6 +62,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell {
let followButton: HighlightDimmableButton = {
let button = HighlightDimmableButton(type: .custom)
button.setInsets(forContentPadding: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16), imageTitlePadding: 0)
button.setTitleColor(.white, for: .normal)
button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
@ -97,50 +98,70 @@ extension SearchRecommendAccountsCollectionViewCell {
headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
}
override open func layoutSubviews() {
super.layoutSubviews()
followButton.layer.cornerRadius = followButton.frame.height/2
}
private func configure() {
headerImageView.backgroundColor = Asset.Colors.brandBlue.color
layer.cornerRadius = 10
layer.cornerCurve = .continuous
clipsToBounds = false
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
headerImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(headerImageView)
headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0)
NSLayoutConstraint.activate([
headerImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
headerImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
headerImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
headerImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.distribution = .fill
containerStackView.alignment = .center
containerStackView.spacing = 6
containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(avatarImageView)
avatarImageView.pin(toSize: CGSize(width: 88, height: 88))
avatarImageView.constrain([
avatarImageView.constraint(.top, toView: contentView),
avatarImageView.constraint(.centerX, toView: contentView)
NSLayoutConstraint.activate([
avatarImageView.widthAnchor.constraint(equalToConstant: 88),
avatarImageView.heightAnchor.constraint(equalToConstant: 88)
])
containerStackView.addArrangedSubview(avatarImageView)
containerStackView.setCustomSpacing(20, after: avatarImageView)
displayNameLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(displayNameLabel)
containerStackView.setCustomSpacing(0, after: displayNameLabel)
contentView.addSubview(displayNameLabel)
displayNameLabel.constrain([
displayNameLabel.constraint(.top, toView: contentView, constant: 108),
displayNameLabel.constraint(.leading, toView: contentView),
displayNameLabel.constraint(.trailing, toView: contentView),
displayNameLabel.constraint(.centerX, toView: contentView)
])
acctLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(acctLabel)
containerStackView.setCustomSpacing(7, after: acctLabel)
contentView.addSubview(acctLabel)
acctLabel.constrain([
acctLabel.constraint(.top, toView: contentView, constant: 132),
acctLabel.constraint(.leading, toView: contentView),
acctLabel.constraint(.trailing, toView: contentView),
acctLabel.constraint(.centerX, toView: contentView)
])
contentView.addSubview(followButton)
followButton.pin(toSize: CGSize(width: 76, height: 24))
followButton.constrain([
followButton.constraint(.top, toView: contentView, constant: 159),
followButton.constraint(.centerX, toView: contentView)
followButton.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(followButton)
NSLayoutConstraint.activate([
followButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
followButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 24)
])
containerStackView.addArrangedSubview(followButton)
}
func config(with mastodonUser: MastodonUser) {
displayNameLabel.text = mastodonUser.displayName.isEmpty ? mastodonUser.username : mastodonUser.displayName
acctLabel.text = mastodonUser.acct
acctLabel.text = "@" + mastodonUser.acct
avatarImageView.af.setImage(
withURL: URL(string: mastodonUser.avatar)!,
placeholderImage: UIImage.placeholder(color: .systemFill),
@ -153,7 +174,13 @@ extension SearchRecommendAccountsCollectionViewCell {
) { [weak self] _ in
guard let self = self else { return }
self.headerImageView.addSubview(self.visualEffectView)
self.visualEffectView.pin(top: 0, left: 0, bottom: 0, right: 0)
self.visualEffectView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
self.visualEffectView.topAnchor.constraint(equalTo: self.headerImageView.topAnchor),
self.visualEffectView.leadingAnchor.constraint(equalTo: self.headerImageView.leadingAnchor),
self.visualEffectView.trailingAnchor.constraint(equalTo: self.headerImageView.trailingAnchor),
self.visualEffectView.bottomAnchor.constraint(equalTo: self.headerImageView.bottomAnchor)
])
}
delegate?.configFollowButton(with: mastodonUser, followButton: followButton)
followButton.publisher(for: .touchUpInside)

View File

@ -12,7 +12,6 @@ import UIKit
class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
let backgroundImageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
@ -20,7 +19,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
let label = UILabel()
label.textColor = .white
label.font = .systemFont(ofSize: 20, weight: .semibold)
label.translatesAutoresizingMaskIntoConstraints = false
label.lineBreakMode = .byTruncatingTail
return label
}()
@ -29,7 +27,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
let label = UILabel()
label.textColor = .white
label.font = .preferredFont(forTextStyle: .body)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
@ -38,7 +35,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
let image = UIImage(systemName: "flame.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))!.withRenderingMode(.alwaysTemplate)
imageView.image = image
imageView.tintColor = .white
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
@ -74,17 +70,49 @@ extension SearchRecommendTagsCollectionViewCell {
layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor
applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0)
backgroundImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(backgroundImageView)
backgroundImageView.constrain(toSuperviewEdges: nil)
NSLayoutConstraint.activate([
backgroundImageView.topAnchor.constraint(equalTo: contentView.topAnchor),
backgroundImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
backgroundImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
backgroundImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
contentView.addSubview(hashtagTitleLabel)
hashtagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42)
contentView.addSubview(peopleLabel)
peopleLabel.pinTopLeft(top: 46, left: 16)
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.distribution = .fill
containerStackView.spacing = 6
containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
])
contentView.addSubview(flameIconView)
flameIconView.pinTopRight(padding: 16)
let horizontalStackView = UIStackView()
horizontalStackView.axis = .horizontal
horizontalStackView.translatesAutoresizingMaskIntoConstraints = false
horizontalStackView.distribution = .fill
hashtagTitleLabel.translatesAutoresizingMaskIntoConstraints = false
hashtagTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
horizontalStackView.addArrangedSubview(hashtagTitleLabel)
horizontalStackView.setContentHuggingPriority(.required - 1, for: .vertical)
flameIconView.translatesAutoresizingMaskIntoConstraints = false
horizontalStackView.addArrangedSubview(flameIconView)
containerStackView.addArrangedSubview(horizontalStackView)
peopleLabel.translatesAutoresizingMaskIntoConstraints = false
peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
containerStackView.addArrangedSubview(peopleLabel)
}
func config(with tag: Mastodon.Entity.Tag) {

View File

@ -23,8 +23,9 @@ extension SearchViewController {
hashtagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self))
hashtagCollectionView.delegate = self
hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(hashtagCollectionView)
hashtagCollectionView.constrain([
NSLayoutConstraint.activate([
hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130)
])
}
@ -39,8 +40,9 @@ extension SearchViewController {
accountsCollectionView.register(SearchRecommendAccountsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self))
accountsCollectionView.delegate = self
accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(accountsCollectionView)
accountsCollectionView.constrain([
NSLayoutConstraint.activate([
accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202)
])
}

View File

@ -15,16 +15,18 @@ import UIKit
extension SearchViewController {
func setupSearchingTableView() {
searchingTableView.delegate = self
searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self))
searchingTableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self))
searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
searchingTableView.estimatedRowHeight = 66
searchingTableView.rowHeight = 66
view.addSubview(searchingTableView)
searchingTableView.constrain([
searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor)
searchingTableView.delegate = self
searchingTableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
searchingTableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
searchingTableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
searchingTableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
searchingTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
searchingTableView.tableFooterView = UIView()
viewModel.isSearching
@ -50,18 +52,23 @@ extension SearchViewController {
}
func setupSearchHeader() {
searchHeader.addSubview(recentSearchesLabel)
recentSearchesLabel.constrain([
recentSearchesLabel.constraint(.leading, toView: searchHeader, constant: 16),
recentSearchesLabel.constraint(.centerY, toView: searchHeader)
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.distribution = .fill
containerStackView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
containerStackView.isLayoutMarginsRelativeArrangement = true
searchHeader.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: searchHeader.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: searchHeader.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: searchHeader.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: searchHeader.bottomAnchor)
])
searchHeader.addSubview(clearSearchHistoryButton)
recentSearchesLabel.constrain([
searchHeader.trailingAnchor.constraint(equalTo: clearSearchHistoryButton.trailingAnchor, constant: 16),
clearSearchHistoryButton.constraint(.centerY, toView: searchHeader)
])
recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(recentSearchesLabel)
clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(clearSearchHistoryButton)
clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside)
}
}
@ -75,15 +82,9 @@ extension SearchViewController {
// MARK: - UITableViewDelegate
extension SearchViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
66
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
66
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.searchResultItemDidSelected(item: item, from: self)

View File

@ -82,7 +82,7 @@ final class SearchViewController: UIViewController, NeedsDependency {
// searching
let searchingTableView: UITableView = {
let tableView = UITableView()
tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
tableView.backgroundColor = Asset.Colors.Background.systemBackground.color
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .singleLine
tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
@ -135,14 +135,16 @@ extension SearchViewController {
func setupSearchBar() {
searchBar.delegate = self
view.addSubview(searchBar)
searchBar.constrain([
searchBar.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
searchBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
searchBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
])
view.addSubview(statusBar)
statusBar.constrain([
statusBar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(statusBar)
NSLayoutConstraint.activate([
statusBar.topAnchor.constraint(equalTo: view.topAnchor),
statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
@ -151,8 +153,9 @@ extension SearchViewController {
}
func setupScrollView() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
scrollView.constrain([
NSLayoutConstraint.activate([
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
@ -160,8 +163,9 @@ extension SearchViewController {
scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor),
])
stackView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stackView)
stackView.constrain([
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
@ -227,7 +231,7 @@ extension SearchViewController: UISearchBarDelegate {
}
extension SearchViewController: LoadMoreConfigurableTableViewContainer {
typealias BottomLoaderTableViewCell = SearchBottomLoader
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
typealias LoadingState = SearchViewModel.LoadOldestState.Loading
var loadMoreConfigurableTableView: UITableView { searchingTableView }
var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine }

View File

@ -136,9 +136,11 @@ extension SearchViewModel.LoadOldestState {
assertionFailure()
return
}
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
DispatchQueue.main.async {
var snapshot = diffableDataSource.snapshot()
snapshot.deleteItems([.bottomLoader])
diffableDataSource.apply(snapshot)
}
}
}
}

View File

@ -237,10 +237,10 @@ final class SearchViewModel: NSObject {
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription)
promise(.failure(error))
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function)
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function)
promise(.success(()))
}
} receiveValue: { [weak self] accounts in

View File

@ -1,47 +0,0 @@
//
// SearchBottomLoader.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/6.
//
import Foundation
import UIKit
final class SearchBottomLoader: UITableViewCell {
let activityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
activityIndicatorView.tintColor = Asset.Colors.Label.primary.color
activityIndicatorView.hidesWhenStopped = true
return activityIndicatorView
}()
override func prepareForReuse() {
super.prepareForReuse()
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
func startAnimating() {
activityIndicatorView.startAnimating()
}
func stopAnimating() {
activityIndicatorView.stopAnimating()
}
func _init() {
selectionStyle = .none
backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
contentView.addSubview(activityIndicatorView)
activityIndicatorView.constrainToCenter()
}
}

View File

@ -55,19 +55,40 @@ final class SearchingTableViewCell: UITableViewCell {
extension SearchingTableViewCell {
private func configure() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(_imageView)
_imageView.pin(toSize: CGSize(width: 42, height: 42))
_imageView.constrain([
_imageView.constraint(.leading, toView: contentView, constant: 21),
_imageView.constraint(.centerY, toView: contentView)
let containerStackView = UIStackView()
containerStackView.axis = .horizontal
containerStackView.distribution = .fill
containerStackView.spacing = 12
containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
contentView.addSubview(_titleLabel)
_titleLabel.pin(top: 12, left: 75, bottom: nil, right: 0)
_imageView.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(_imageView)
NSLayoutConstraint.activate([
_imageView.widthAnchor.constraint(equalToConstant: 42),
_imageView.heightAnchor.constraint(equalToConstant: 42),
])
contentView.addSubview(_subTitleLabel)
_subTitleLabel.pin(top: 34, left: 75, bottom: nil, right: 0)
let textStackView = UIStackView()
textStackView.axis = .vertical
textStackView.distribution = .fill
textStackView.translatesAutoresizingMaskIntoConstraints = false
_titleLabel.translatesAutoresizingMaskIntoConstraints = false
textStackView.addArrangedSubview(_titleLabel)
_subTitleLabel.translatesAutoresizingMaskIntoConstraints = false
textStackView.addArrangedSubview(_subTitleLabel)
_subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
containerStackView.addArrangedSubview(textStackView)
}
func config(with account: Mastodon.Entity.Account) {

View File

@ -47,14 +47,35 @@ extension SearchRecommendCollectionHeader {
private func configure() {
backgroundColor = .clear
translatesAutoresizingMaskIntoConstraints = false
addSubview(titleLabel)
titleLabel.pinTopLeft(top: 31, left: 16)
addSubview(descriptionLabel)
descriptionLabel.constrain(toSuperviewEdges: UIEdgeInsets(top: 60, left: 16, bottom: 16, right: 16))
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.layoutMargins = UIEdgeInsets(top: 31, left: 16, bottom: 16, right: 16)
containerStackView.isLayoutMarginsRelativeArrangement = true
containerStackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor)
])
addSubview(seeAllButton)
seeAllButton.pinTopRight(top: 26, right: 16)
let horizontalStackView = UIStackView()
horizontalStackView.axis = .horizontal
horizontalStackView.alignment = .center
horizontalStackView.translatesAutoresizingMaskIntoConstraints = false
horizontalStackView.distribution = .fill
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
horizontalStackView.addArrangedSubview(titleLabel)
seeAllButton.translatesAutoresizingMaskIntoConstraints = false
horizontalStackView.addArrangedSubview(seeAllButton)
containerStackView.addArrangedSubview(horizontalStackView)
descriptionLabel.translatesAutoresizingMaskIntoConstraints = false
containerStackView.addArrangedSubview(descriptionLabel)
}
}

View File

@ -46,7 +46,7 @@ extension StatusTableViewCellDelegate {
}
}
final class StatusTableViewCell: UITableViewCell {
final class StatusTableViewCell: UITableViewCell, StatusCell {
static let bottomPaddingHeight: CGFloat = 10

View File

@ -11,7 +11,7 @@ import UIKit
class AudioContainerViewModel {
static func configure(
cell: StatusTableViewCell,
cell: StatusCell,
audioAttachment: Attachment,
audioService: AudioPlaybackService
) {
@ -51,7 +51,7 @@ class AudioContainerViewModel {
}
static func observePlayer(
cell: StatusTableViewCell,
cell: StatusCell,
audioAttachment: Attachment,
audioService: AudioPlaybackService
) {

View File

@ -0,0 +1,67 @@
//
// APIService+Notification.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/13.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import OSLog
extension APIService {
func allNotifications(
domain: String,
query: Mastodon.API.Notifications.Query,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let userID = mastodonAuthenticationBox.userID
return Mastodon.API.Notifications.getNotifications(
session: session,
domain: domain,
query: query,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, Error> in
let log = OSLog.api
return self.backgroundManagedObjectContext.performChanges {
response.value.forEach { notification in
let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log)
var status: Status?
if let statusEntity = notification.status {
let (statusInCoreData, _, _) = APIService.CoreData.createOrMergeStatus(
into: self.backgroundManagedObjectContext,
for: nil,
domain: domain,
entity: statusEntity,
statusCache: nil,
userCache: nil,
networkDate: Date(),
log: log
)
status = statusInCoreData
}
// use constrain to avoid repeated save
let property = MastodonNotification.Property(id: notification.id, typeRaw: notification.type.rawValue, account: mastodonUser, status: status, createdAt: notification.createdAt)
let notification = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, userID: userID, networkDate: response.networkDate, property: property)
os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)", (#file as NSString).lastPathComponent, #line, #function, notification.typeRaw, notification.account.username)
}
}
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Notification]> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -1,18 +1,19 @@
//
// File.swift
//
//
//
// Created by BradGao on 2021/4/1.
//
import Foundation
import Combine
import Foundation
extension Mastodon.API.Notifications {
static func notificationsEndpointURL(domain: String) -> URL {
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications")
internal static func notificationsEndpointURL(domain: String) -> URL {
Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications")
}
static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL {
internal static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL {
notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID)
}
@ -27,15 +28,15 @@ extension Mastodon.API.Notifications {
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: `GetAllNotificationsQuery` with query parameters
/// - query: `NotificationsQuery` with query parameters
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Token` nested in the response
public static func getAll(
public static func getNotifications(
session: URLSession,
domain: String,
query: GetAllNotificationsQuery,
authorization: Mastodon.API.OAuth.Authorization?
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, Error> {
query: Mastodon.API.Notifications.Query,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, Error> {
let request = Mastodon.API.get(
url: notificationsEndpointURL(domain: domain),
query: query,
@ -63,12 +64,12 @@ extension Mastodon.API.Notifications {
/// - notificationID: ID of the notification.
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Token` nested in the response
public static func get(
public static func getNotification(
session: URLSession,
domain: String,
notificationID: String,
authorization: Mastodon.API.OAuth.Authorization?
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> {
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> {
let request = Mastodon.API.get(
url: getNotificationEndpointURL(domain: domain, notificationID: notificationID),
query: nil,
@ -81,13 +82,15 @@ extension Mastodon.API.Notifications {
}
.eraseToAnyPublisher()
}
public struct GetAllNotificationsQuery: Codable, PagedQueryType, GetQuery {
}
extension Mastodon.API.Notifications {
public struct Query: PagedQueryType, GetQuery {
public let maxID: Mastodon.Entity.Status.ID?
public let sinceID: Mastodon.Entity.Status.ID?
public let minID: Mastodon.Entity.Status.ID?
public let limit: Int?
public let excludeTypes: [String]?
public let excludeTypes: [Mastodon.Entity.Notification.NotificationType]?
public let accountID: String?
public init(
@ -95,7 +98,7 @@ extension Mastodon.API.Notifications {
sinceID: Mastodon.Entity.Status.ID? = nil,
minID: Mastodon.Entity.Status.ID? = nil,
limit: Int? = nil,
excludeTypes: [String]? = nil,
excludeTypes: [Mastodon.Entity.Notification.NotificationType]? = nil,
accountID: String? = nil
) {
self.maxID = maxID
@ -114,7 +117,7 @@ extension Mastodon.API.Notifications {
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
if let excludeTypes = excludeTypes {
excludeTypes.forEach {
items.append(URLQueryItem(name: "exclude_types[]", value: $0))
items.append(URLQueryItem(name: "exclude_types[]", value: $0.rawValue))
}
}
accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) }

View File

@ -37,6 +37,7 @@ extension Mastodon.Entity {
}
extension Mastodon.Entity.Notification {
public typealias NotificationType = Type
public enum `Type`: RawRepresentable, Codable {
case follow
case followRequest