diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index 5ed4021a..eb095669 100644
--- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
+++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
@@ -1,5 +1,5 @@
-
+
@@ -115,6 +115,7 @@
+
@@ -209,7 +210,7 @@
-
+
@@ -217,4 +218,4 @@
-
+
\ No newline at end of file
diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift
index 9559ea5d..864ca494 100644
--- a/CoreDataStack/Entity/Mention.swift
+++ b/CoreDataStack/Entity/Mention.swift
@@ -10,6 +10,9 @@ import Foundation
public final class Mention: NSManagedObject {
public typealias ID = UUID
+
+ @NSManaged public private(set) var index: NSNumber
+
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var id: String
@NSManaged public private(set) var createAt: Date
@@ -32,9 +35,11 @@ public extension Mention {
@discardableResult
static func insert(
into context: NSManagedObjectContext,
- property: Property
+ property: Property,
+ index: Int
) -> Mention {
let mention: Mention = context.insertObject()
+ mention.index = NSNumber(value: index)
mention.id = property.id
mention.username = property.username
mention.acct = property.acct
diff --git a/Localization/app.json b/Localization/app.json
index 120458f7..5d8ad264 100644
--- a/Localization/app.json
+++ b/Localization/app.json
@@ -89,7 +89,8 @@
"timeline": {
"loader": {
"load_missing_posts": "Load missing posts",
- "loading_missing_posts": "Loading missing posts..."
+ "loading_missing_posts": "Loading missing posts...",
+ "show_more_replies": "Show more replies"
},
"header": {
"no_status_found": "No Status Found",
@@ -198,7 +199,7 @@
},
"confirm_email": {
"title": "One last thing.",
- "subtitle": "We just sent an email to %@,\ntap the link to confirm your account.",
+ "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.",
"button": {
"open_email_app": "Open Email App",
"dont_receive_email": "I never got an email"
@@ -239,6 +240,7 @@
},
"content_input_placeholder": "Type or paste what's on your mind",
"compose_action": "Publish",
+ "replying_to_user": "replying to %s",
"attachment": {
"photo": "photo",
"video": "video",
@@ -253,7 +255,8 @@
"six_hours": "6 Hours",
"one_day": "1 Day",
"three_days": "3 Days",
- "seven_days": "7 Days"
+ "seven_days": "7 Days",
+ "option_number": "Option %ld"
},
"content_warning": {
"placeholder": "Write an accurate warning here..."
@@ -321,6 +324,18 @@
},
"favorite": {
"title": "Your Favorites"
+ },
+ "thread": {
+ "back_title": "Post",
+ "title": "Post from %s",
+ "reblog": {
+ "single": "%s reblog",
+ "multiple": "%s reblogs"
+ },
+ "favorite": {
+ "single": "%s favorite",
+ "multiple": "%s favorites"
+ }
}
}
-}
+}
\ No newline at end of file
diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj
index b5213989..f6bf54a2 100644
--- a/Mastodon.xcodeproj/project.pbxproj
+++ b/Mastodon.xcodeproj/project.pbxproj
@@ -144,6 +144,8 @@
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; };
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; };
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
+ DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; };
+ DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; };
DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; };
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; };
DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; };
@@ -218,7 +220,7 @@
DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; };
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; };
DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; };
- DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */; };
+ DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; };
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; };
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; };
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; };
@@ -263,6 +265,15 @@
DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; };
DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; };
DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */; };
+ DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */; };
+ DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */; };
+ DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */; };
+ DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */; };
+ DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */; };
+ DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */; };
+ DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; };
+ DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */; };
+ DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */; };
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; };
DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; };
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; };
@@ -306,6 +317,7 @@
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525632612C988002F1F29 /* MeProfileViewModel.swift */; };
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */; };
DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; };
+ DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; };
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; };
@@ -524,6 +536,8 @@
DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; };
DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; };
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; };
+ DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; };
+ DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; };
DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; };
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; };
DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
@@ -604,7 +618,7 @@
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; };
DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; };
DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; };
- DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = ""; };
+ DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; };
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; };
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; };
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; };
@@ -651,6 +665,15 @@
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; };
DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = ""; };
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionTableViewCell.swift; sourceTree = ""; };
+ DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewController.swift; sourceTree = ""; };
+ DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = ""; };
+ DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedThreadViewModel.swift; sourceTree = ""; };
+ DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteThreadViewModel.swift; sourceTree = ""; };
+ DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+LoadThreadState.swift"; sourceTree = ""; };
+ DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Thread.swift"; sourceTree = ""; };
+ DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = ""; };
+ DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+StatusProvider.swift"; sourceTree = ""; };
+ DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = ""; };
DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; };
DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; };
DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; };
@@ -693,6 +716,7 @@
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; };
DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardView.swift; sourceTree = ""; };
DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = ""; };
+ DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; };
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; };
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; };
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = ""; };
@@ -865,6 +889,7 @@
DB87D44A2609C11900D12C0D /* PollOptionView.swift */,
DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */,
0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */,
+ DBB9759B262462E1004620BD /* ThreadMetaView.swift */,
);
path = Content;
sourceTree = "";
@@ -1070,9 +1095,11 @@
children = (
2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */,
2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */,
+ DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */,
2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */,
2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */,
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */,
+ DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */,
DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */,
);
path = TableviewCell;
@@ -1310,6 +1337,7 @@
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */,
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */,
DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */,
+ DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */,
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
DB9A488326034BD7008B817C /* APIService+Status.swift */,
DB9A488F26035963008B817C /* APIService+Media.swift */,
@@ -1430,7 +1458,7 @@
children = (
DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */,
DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */,
- DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */,
+ DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */,
DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */,
DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */,
DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */,
@@ -1554,6 +1582,7 @@
DB9D6BFD25E4F57B0051B173 /* Notification */,
DB9D6C0825E4F5A60051B173 /* Profile */,
DB789A1025F9F29B0071ACA0 /* Compose */,
+ DB938EEB2623F52600E5B6C1 /* Thread */,
);
path = Scene;
sourceTree = "";
@@ -1594,6 +1623,20 @@
path = Extension;
sourceTree = "";
};
+ DB938EEB2623F52600E5B6C1 /* Thread */ = {
+ isa = PBXGroup;
+ children = (
+ DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */,
+ DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */,
+ DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */,
+ DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */,
+ DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */,
+ DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */,
+ DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */,
+ );
+ path = Thread;
+ sourceTree = "";
+ };
DB98335F25C93B0400AD9700 /* Recovered References */ = {
isa = PBXGroup;
children = (
@@ -1700,6 +1743,7 @@
children = (
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */,
DB59F11725EFA35B001F1DAB /* StripProgressView.swift */,
+ DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */,
);
path = Control;
sourceTree = "";
@@ -2200,6 +2244,7 @@
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */,
2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */,
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
+ DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */,
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */,
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */,
@@ -2208,6 +2253,7 @@
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
+ DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */,
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
@@ -2221,6 +2267,7 @@
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */,
2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */,
DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */,
+ DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */,
0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */,
DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */,
DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */,
@@ -2242,13 +2289,14 @@
DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */,
2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */,
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
+ DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */,
DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */,
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */,
2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */,
DB98338825C945ED00AD9700 /* Assets.swift in Sources */,
- DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */,
+ DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
@@ -2265,6 +2313,7 @@
DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */,
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
+ DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */,
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */,
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
@@ -2331,8 +2380,10 @@
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */,
DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */,
+ DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */,
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */,
2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */,
+ DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */,
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
@@ -2350,11 +2401,13 @@
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */,
+ DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */,
DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */,
2D206B8625F5FB0900143C56 /* Double.swift in Sources */,
DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */,
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */,
DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */,
+ DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */,
2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */,
DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */,
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */,
@@ -2401,6 +2454,7 @@
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */,
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
+ DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */,
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
@@ -2422,7 +2476,9 @@
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */,
DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */,
+ DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */,
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
+ DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */,
DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */,
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */,
diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
index 6ec23cf5..fd1ce69a 100644
--- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,7 +7,7 @@
CoreDataStack.xcscheme_^#shared#^_
orderHint
- 10
+ 20
Mastodon - RTL.xcscheme_^#shared#^_
diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift
index 36d42745..8874a69e 100644
--- a/Mastodon/Coordinator/SceneCoordinator.swift
+++ b/Mastodon/Coordinator/SceneCoordinator.swift
@@ -51,6 +51,9 @@ extension SceneCoordinator {
// compose
case compose(viewModel: ComposeViewModel)
+ // thread
+ case thread(viewModel: ThreadViewModel)
+
// Hashtag Timeline
case hashtagTimeline(viewModel: HashtagTimelineViewModel)
@@ -226,6 +229,10 @@ private extension SceneCoordinator {
let _viewController = ComposeViewController()
_viewController.viewModel = viewModel
viewController = _viewController
+ case .thread(let viewModel):
+ let _viewController = ThreadViewController()
+ _viewController.viewModel = viewModel
+ viewController = _viewController
case .hashtagTimeline(let viewModel):
let _viewController = HashtagTimelineViewController()
_viewController.viewModel = viewModel
diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift
index 9f82f6ca..da345520 100644
--- a/Mastodon/Diffiable/Item/Item.swift
+++ b/Mastodon/Diffiable/Item/Item.swift
@@ -14,6 +14,12 @@ import MastodonSDK
enum Item {
// timeline
case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute)
+
+ // thread
+ case root(statusObjectID: NSManagedObjectID, attribute: StatusAttribute)
+ case reply(statusObjectID: NSManagedObjectID, attribute: StatusAttribute)
+ case leaf(statusObjectID: NSManagedObjectID, attribute: StatusAttribute)
+ case leafBottomLoader(statusObjectID: NSManagedObjectID)
// normal list
case status(objectID: NSManagedObjectID, attribute: StatusAttribute)
@@ -21,6 +27,7 @@ enum Item {
// loader
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
case publicMiddleLoader(statusID: String)
+ case topLoader
case bottomLoader
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
@@ -35,13 +42,16 @@ extension Item {
class StatusAttribute: StatusContentWarningAttribute {
var isStatusTextSensitive: Bool?
var isStatusSensitive: Bool?
+ var isSeparatorLineHidden: Bool
init(
isStatusTextSensitive: Bool? = nil,
- isStatusSensitive: Bool? = nil
+ isStatusSensitive: Bool? = nil,
+ isSeparatorLineHidden: Bool = false
) {
self.isStatusTextSensitive = isStatusTextSensitive
self.isStatusSensitive = isStatusSensitive
+ self.isSeparatorLineHidden = isSeparatorLineHidden
}
// delay attribute init
@@ -59,6 +69,23 @@ extension Item {
}
}
+// class LeafAttribute {
+// let identifier = UUID()
+// let statusID: Status.ID
+// var level: Int = 0
+// var hasReply: Bool = true
+//
+// init(
+// statusID: Status.ID,
+// level: Int,
+// hasReply: Bool = true
+// ) {
+// self.statusID = statusID
+// self.level = level
+// self.hasReply = hasReply
+// }
+// }
+
class EmptyStateHeaderAttribute: Hashable {
let id = UUID()
let reason: Reason
@@ -99,12 +126,22 @@ extension Item: Equatable {
switch (lhs, rhs) {
case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)):
return objectIDLeft == objectIDRight
+ case (.root(let objectIDLeft, _), .root(let objectIDRight, _)):
+ return objectIDLeft == objectIDRight
+ case (.reply(let objectIDLeft, _), .reply(let objectIDRight, _)):
+ return objectIDLeft == objectIDRight
+ case (.leaf(let objectIDLeft, _), .leaf(let objectIDRight, _)):
+ return objectIDLeft == objectIDRight
+ case (.leafBottomLoader(let objectIDLeft), .leafBottomLoader(let objectIDRight)):
+ return objectIDLeft == objectIDRight
case (.status(let objectIDLeft, _), .status(let objectIDRight, _)):
return objectIDLeft == objectIDRight
case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)):
return upperLeft == upperRight
case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)):
return upperLeft == upperRight
+ case (.topLoader, .topLoader):
+ return true
case (.bottomLoader, .bottomLoader):
return true
case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)):
@@ -120,6 +157,14 @@ extension Item: Hashable {
switch self {
case .homeTimelineIndex(let objectID, _):
hasher.combine(objectID)
+ case .root(let objectID, _):
+ hasher.combine(objectID)
+ case .reply(let objectID, _):
+ hasher.combine(objectID)
+ case .leaf(let objectID, _):
+ hasher.combine(objectID)
+ case .leafBottomLoader(let objectID):
+ hasher.combine(objectID)
case .status(let objectID, _):
hasher.combine(objectID)
case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper):
@@ -128,6 +173,8 @@ extension Item: Hashable {
case .publicMiddleLoader(let upper):
hasher.combine(String(describing: Item.publicMiddleLoader.self))
hasher.combine(upper)
+ case .topLoader:
+ hasher.combine(String(describing: Item.topLoader.self))
case .bottomLoader:
hasher.combine(String(describing: Item.bottomLoader.self))
case .emptyStateHeader(let attribute):
diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift
index 56aa3279..0e7c574b 100644
--- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift
+++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift
@@ -34,6 +34,7 @@ extension ComposeStatusSection {
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext,
composeKind: ComposeKind,
+ repliedToCellFrameSubscriber: CurrentValueSubject,
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
@@ -50,8 +51,29 @@ extension ComposeStatusSection {
weak composeStatusPollExpiresOptionCollectionViewCellDelegate
] collectionView, indexPath, item -> UICollectionViewCell? in
switch item {
- case .replyTo(let repliedToStatusObjectID):
+ case .replyTo(let replyToStatusObjectID):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell
+ managedObjectContext.perform {
+ guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
+ return
+ }
+ let status = replyTo.reblog ?? replyTo
+
+ // set avatar
+ cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
+ // set name username
+ cell.statusView.nameLabel.text = {
+ let author = status.author
+ return author.displayName.isEmpty ? author.username : author.displayName
+ }()
+ cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
+ // set text
+ cell.statusView.activeTextLabel.configure(content: status.content)
+ // set date
+ cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow
+
+ cell.framePublisher.assign(to: \.value, on: repliedToCellFrameSubscriber).store(in: &cell.disposeBag)
+ }
return cell
case .input(let replyToStatusObjectID, let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
@@ -63,16 +85,22 @@ extension ComposeStatusSection {
return
}
cell.statusView.headerContainerStackView.isHidden = false
- cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)"
+ cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
+ cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback)
}
- ComposeStatusSection.configure(cell: cell, attribute: attribute)
+ ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute)
cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate
cell.composeContent
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { text in
// self size input cell
+ // needs restore content offset to resolve issue #83
+ let oldContentOffset = collectionView.contentOffset
collectionView.collectionViewLayout.invalidateLayout()
+ collectionView.layoutIfNeeded()
+ collectionView.contentOffset = oldContentOffset
+
// bind input data
attribute.composeContent.value = text
}
@@ -167,6 +195,7 @@ extension ComposeStatusSection {
case .pollOption(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell
cell.pollOptionView.optionTextField.text = attribute.option.value
+ cell.pollOptionView.optionTextField.placeholder = L10n.Scene.Compose.Poll.optionNumber(indexPath.item + 1)
cell.pollOption
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: attribute.option)
@@ -196,7 +225,7 @@ extension ComposeStatusSection {
extension ComposeStatusSection {
- static func configure(
+ static func configureStatusContent(
cell: ComposeStatusContentCollectionViewCell,
attribute: ComposeStatusItem.ComposeStatusAttribute
) {
diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift
index fe720e0f..36d4853a 100644
--- a/Mastodon/Diffiable/Section/StatusSection.swift
+++ b/Mastodon/Diffiable/Section/StatusSection.swift
@@ -22,9 +22,16 @@ extension StatusSection {
managedObjectContext: NSManagedObjectContext,
timestampUpdatePublisher: AnyPublisher,
statusTableViewCellDelegate: StatusTableViewCellDelegate,
- timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
+ timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?,
+ threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate?
) -> UITableViewDiffableDataSource {
- UITableViewDiffableDataSource(tableView: tableView) { [weak statusTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in
+ UITableViewDiffableDataSource(tableView: tableView) { [
+ weak dependency,
+ weak statusTableViewCellDelegate,
+ weak timelineMiddleLoaderTableViewCellDelegate,
+ weak threadReplyLoaderTableViewCellDelegate
+ ] tableView, indexPath, item -> UITableViewCell? in
+ guard let dependency = dependency else { return UITableViewCell() }
guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() }
switch item {
@@ -46,7 +53,10 @@ extension StatusSection {
}
cell.delegate = statusTableViewCellDelegate
return cell
- case .status(let objectID, let attribute):
+ case .status(let objectID, let attribute),
+ .root(let objectID, let attribute),
+ .reply(let objectID, let attribute),
+ .leaf(let objectID, let attribute):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
@@ -62,8 +72,30 @@ extension StatusSection {
requestUserID: requestUserID,
statusItemAttribute: attribute
)
+
+ switch item {
+ case .root:
+ StatusSection.configureThreadMeta(cell: cell, status: status)
+ ManagedObjectObserver.observe(object: status.reblog ?? status)
+ .receive(on: DispatchQueue.main)
+ .sink { _ in
+ // do nothing
+ } receiveValue: { change in
+ guard case .update(let object) = change.changeType,
+ let status = object as? Status else { return }
+ StatusSection.configureThreadMeta(cell: cell, status: status)
+ }
+ .store(in: &cell.disposeBag)
+ default:
+ break
+ }
}
cell.delegate = statusTableViewCellDelegate
+
+ return cell
+ case .leafBottomLoader:
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self), for: indexPath) as! ThreadReplyLoaderTableViewCell
+ cell.delegate = threadReplyLoaderTableViewCellDelegate
return cell
case .publicMiddleLoader(let upperTimelineStatusID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
@@ -75,6 +107,10 @@ extension StatusSection {
cell.delegate = timelineMiddleLoaderTableViewCellDelegate
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID)
return cell
+ case .topLoader:
+ let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
+ cell.startAnimating()
+ return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.startAnimating()
@@ -288,6 +324,9 @@ extension StatusSection {
// toolbar
StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID)
+ // separator line
+ cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden
+
// set date
let createdAt = (status.reblog ?? status).createdAt
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
@@ -312,6 +351,41 @@ extension StatusSection {
}
.store(in: &cell.disposeBag)
}
+
+ static func configureThreadMeta(
+ cell: StatusTableViewCell,
+ status: Status
+ ) {
+ cell.selectionStyle = .none
+ cell.threadMetaView.dateLabel.text = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .short
+ return formatter.string(from: status.createdAt)
+ }()
+ let reblogCountTitle: String = {
+ let count = status.reblogsCount.intValue
+ if count > 1 {
+ return L10n.Scene.Thread.Reblog.multiple(String(count))
+ } else {
+ return L10n.Scene.Thread.Reblog.single(String(count))
+ }
+ }()
+ cell.threadMetaView.reblogButton.setTitle(reblogCountTitle, for: .normal)
+
+ let favoriteCountTitle: String = {
+ let count = status.favouritesCount.intValue
+ if count > 1 {
+ return L10n.Scene.Thread.Favorite.multiple(String(count))
+ } else {
+ return L10n.Scene.Thread.Favorite.single(String(count))
+ }
+ }()
+ cell.threadMetaView.favoriteButton.setTitle(favoriteCountTitle, for: .normal)
+
+ cell.threadMetaView.isHidden = false
+ }
+
static func configureHeader(
cell: StatusTableViewCell,
@@ -319,16 +393,19 @@ extension StatusSection {
) {
if status.reblog != nil {
cell.statusView.headerContainerStackView.isHidden = false
- cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage)
+ cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
cell.statusView.headerInfoLabel.text = {
let author = status.author
let name = author.displayName.isEmpty ? author.username : author.displayName
return L10n.Common.Controls.Status.userReblogged(name)
}()
- } else if let replyTo = status.replyTo {
+ } else if status.inReplyToID != nil {
cell.statusView.headerContainerStackView.isHidden = false
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
cell.statusView.headerInfoLabel.text = {
+ guard let replyTo = status.replyTo else {
+ return L10n.Common.Controls.Status.userRepliedTo("-")
+ }
let author = replyTo.author
let name = author.displayName.isEmpty ? author.username : author.displayName
return L10n.Common.Controls.Status.userRepliedTo(name)
diff --git a/Mastodon/Extension/CGImage.swift b/Mastodon/Extension/CGImage.swift
index 252f1289..cced4abe 100644
--- a/Mastodon/Extension/CGImage.swift
+++ b/Mastodon/Extension/CGImage.swift
@@ -26,7 +26,7 @@ extension CGImage {
let pointer = CFDataGetBytePtr(data) else { return nil }
let length = CFDataGetLength(data)
- guard length > 0 else { return nil}
+ guard length > 0 else { return nil }
var luma: CGFloat = 0.0
for i in stride(from: 0, to: length, by: 4) {
diff --git a/Mastodon/Extension/UIBarButtonItem.swift b/Mastodon/Extension/UIBarButtonItem.swift
index 8a0630f0..cf1f84e9 100644
--- a/Mastodon/Extension/UIBarButtonItem.swift
+++ b/Mastodon/Extension/UIBarButtonItem.swift
@@ -17,4 +17,3 @@ extension UIBarButtonItem {
}
}
-
diff --git a/Mastodon/Extension/UIImage.swift b/Mastodon/Extension/UIImage.swift
index 072a3d4d..35766c0b 100644
--- a/Mastodon/Extension/UIImage.swift
+++ b/Mastodon/Extension/UIImage.swift
@@ -59,7 +59,7 @@ extension UIImage {
}
}
-public extension UIImage {
+extension UIImage {
func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? {
let maxRadius = min(size.width, size.height) / 2
let cornerRadius: CGFloat = {
@@ -75,3 +75,18 @@ public extension UIImage {
}
}
}
+
+extension UIImage {
+ static func adaptiveUserInterfaceStyleImage(lightImage: UIImage, darkImage: UIImage) -> UIImage {
+ let imageAsset = UIImageAsset()
+ imageAsset.register(lightImage, with: UITraitCollection(traitsFrom: [
+ UITraitCollection(displayScale: 1.0),
+ UITraitCollection(userInterfaceStyle: .light)
+ ]))
+ imageAsset.register(darkImage, with: UITraitCollection(traitsFrom: [
+ UITraitCollection(displayScale: 1.0),
+ UITraitCollection(userInterfaceStyle: .dark)
+ ]))
+ return imageAsset.image(with: UITraitCollection.current)
+ }
+}
diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift
index 843fce02..cd655e07 100644
--- a/Mastodon/Generated/Assets.swift
+++ b/Mastodon/Generated/Assets.swift
@@ -44,7 +44,6 @@ internal enum Asset {
internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor")
internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar")
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
- internal static let searchResult = ColorAsset(name: "Colors/Background/searchResult")
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background")
internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background")
@@ -91,26 +90,35 @@ internal enum Asset {
internal enum Connectivity {
internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split")
}
- internal enum Profile {
- internal enum Banner {
- internal static let bioEditBackgroundGray = ColorAsset(name: "Profile/Banner/bio.edit.background.gray")
- internal static let nameEditBackgroundGray = ColorAsset(name: "Profile/Banner/name.edit.background.gray")
- internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray")
- }
+ internal enum Human {
+ internal static let faceSmilingAdaptive = ImageAsset(name: "Human/face.smiling.adaptive")
}
- internal enum Welcome {
- internal enum Illustration {
- internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan")
- internal static let cloudBase = ImageAsset(name: "Welcome/illustration/cloud.base")
- internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Welcome/illustration/elephant.on.airplane.with.contrail")
- internal static let elephantThreeOnGrass = ImageAsset(name: "Welcome/illustration/elephant.three.on.grass")
- internal static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Welcome/illustration/elephant.three.on.grass.with.tree.three")
- internal static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Welcome/illustration/elephant.three.on.grass.with.tree.two")
+ internal enum Scene {
+ internal enum Compose {
+ internal static let background = ColorAsset(name: "Scene/Compose/background")
+ internal static let toolbarBackground = ColorAsset(name: "Scene/Compose/toolbar.background")
+ }
+ internal enum Profile {
+ internal enum Banner {
+ internal static let bioEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/bio.edit.background.gray")
+ internal static let nameEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/name.edit.background.gray")
+ internal static let usernameGray = ColorAsset(name: "Scene/Profile/Banner/username.gray")
+ }
+ }
+ internal enum Welcome {
+ internal enum Illustration {
+ internal static let backgroundCyan = ColorAsset(name: "Scene/Welcome/illustration/background.cyan")
+ internal static let cloudBase = ImageAsset(name: "Scene/Welcome/illustration/cloud.base")
+ internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Scene/Welcome/illustration/elephant.on.airplane.with.contrail")
+ internal static let elephantThreeOnGrass = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass")
+ internal static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three")
+ internal static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two")
+ }
+ internal static let mastodonLogoBlack = ImageAsset(name: "Scene/Welcome/mastodon.logo.black")
+ internal static let mastodonLogoBlackLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.black.large")
+ internal static let mastodonLogo = ImageAsset(name: "Scene/Welcome/mastodon.logo")
+ internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large")
}
- internal static let mastodonLogoBlack = ImageAsset(name: "Welcome/mastodon.logo.black")
- internal static let mastodonLogoBlackLarge = ImageAsset(name: "Welcome/mastodon.logo.black.large")
- internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo")
- internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large")
}
}
// swiftlint:enable identifier_name line_length nesting type_body_length type_name
diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift
index 14b99388..6eed41a2 100644
--- a/Mastodon/Generated/Strings.swift
+++ b/Mastodon/Generated/Strings.swift
@@ -203,6 +203,8 @@ internal enum L10n {
internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts")
/// Load missing posts
internal static let loadMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadMissingPosts")
+ /// Show more replies
+ internal static let showMoreReplies = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.ShowMoreReplies")
}
}
}
@@ -222,6 +224,10 @@ internal enum L10n {
internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction")
/// Type or paste what's on your mind
internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder")
+ /// replying to %@
+ internal static func replyingToUser(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Scene.Compose.ReplyingToUser", String(describing: p1))
+ }
internal enum Attachment {
/// This %@ is broken and can't be\nuploaded to Mastodon.
internal static func attachmentBroken(_ p1: Any) -> String {
@@ -257,6 +263,10 @@ internal enum L10n {
internal static let oneDay = L10n.tr("Localizable", "Scene.Compose.Poll.OneDay")
/// 1 Hour
internal static let oneHour = L10n.tr("Localizable", "Scene.Compose.Poll.OneHour")
+ /// Option %ld
+ internal static func optionNumber(_ p1: Int) -> String {
+ return L10n.tr("Localizable", "Scene.Compose.Poll.OptionNumber", p1)
+ }
/// 7 Days
internal static let sevenDays = L10n.tr("Localizable", "Scene.Compose.Poll.SevenDays")
/// 6 Hours
@@ -581,6 +591,34 @@ internal enum L10n {
internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm")
}
}
+ internal enum Thread {
+ /// Post
+ internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle")
+ /// Post from %@
+ internal static func title(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Scene.Thread.Title", String(describing: p1))
+ }
+ internal enum Favorite {
+ /// %@ favorites
+ internal static func multiple(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Scene.Thread.Favorite.Multiple", String(describing: p1))
+ }
+ /// %@ favorite
+ internal static func single(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Scene.Thread.Favorite.Single", String(describing: p1))
+ }
+ }
+ internal enum Reblog {
+ /// %@ reblogs
+ internal static func multiple(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Scene.Thread.Reblog.Multiple", String(describing: p1))
+ }
+ /// %@ reblog
+ internal static func single(_ p1: Any) -> String {
+ return L10n.tr("Localizable", "Scene.Thread.Reblog.Single", String(describing: p1))
+ }
+ }
+ }
internal enum Welcome {
/// Social networking\nback in your hands.
internal static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan")
diff --git a/Mastodon/Helper/MastodonField.swift b/Mastodon/Helper/MastodonField.swift
index e828602e..5f652b32 100644
--- a/Mastodon/Helper/MastodonField.swift
+++ b/Mastodon/Helper/MastodonField.swift
@@ -11,7 +11,7 @@ import ActiveLabel
enum MastodonField {
static func parse(field string: String) -> ParseResult {
- let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?)")
+ let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)")
let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))")
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift
index f8c99c13..25322e21 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift
@@ -33,6 +33,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
// MARK: - ActionToolbarContainerDelegate
extension StatusTableViewCellDelegate where Self: StatusProvider {
+ func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, replyButtonDidPressed sender: UIButton) {
+ StatusProviderFacade.responseToStatusReplyAction(provider: self, cell: cell)
+ }
+
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) {
StatusProviderFacade.responseToStatusReblogAction(provider: self, cell: cell)
}
@@ -46,9 +50,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
guard let item = item(for: cell, indexPath: nil) else { return }
switch item {
- case .homeTimelineIndex(_, let attribute):
- attribute.isStatusTextSensitive = false
- case .status(_, let attribute):
+ case .homeTimelineIndex(_, let attribute),
+ .status(_, let attribute),
+ .root(_, let attribute),
+ .reply(_, let attribute),
+ .leaf(_, let attribute):
attribute.isStatusTextSensitive = false
default:
return
@@ -81,9 +87,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
guard let item = item(for: cell, indexPath: nil) else { return }
switch item {
- case .homeTimelineIndex(_, let attribute):
- attribute.isStatusSensitive = false
- case .status(_, let attribute):
+ case .homeTimelineIndex(_, let attribute),
+ .status(_, let attribute),
+ .root(_, let attribute),
+ .reply(_, let attribute),
+ .leaf(_, let attribute):
attribute.isStatusSensitive = false
default:
return
diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift
index 32915baf..cd6cbf58 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift
@@ -12,9 +12,6 @@ import os.log
import UIKit
extension StatusTableViewCellDelegate where Self: StatusProvider {
- // TODO:
- // func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
- // }
func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// update poll when status appear
@@ -102,6 +99,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
}
.store(in: &disposeBag)
}
+
+ func handleTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, indexPath: indexPath)
+ }
+
}
extension StatusTableViewCellDelegate where Self: StatusProvider {}
diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift
index e16343ee..8e27a220 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift
@@ -13,7 +13,7 @@ import CoreDataStack
protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController {
// async
func status() -> Future
- func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future
+ func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future
func status(for cell: UICollectionViewCell) -> Future
// sync
diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
index abdc2790..0e26614c 100644
--- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
+++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift
@@ -60,6 +60,54 @@ extension StatusProviderFacade {
}
.store(in: &provider.disposeBag)
}
+
+}
+
+extension StatusProviderFacade {
+
+ static func coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, indexPath: IndexPath) {
+ _coordinateToStatusThreadScene(
+ for: target,
+ provider: provider,
+ status: provider.status(for: nil, indexPath: indexPath)
+ )
+ }
+
+ static func coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, cell: UITableViewCell) {
+ _coordinateToStatusThreadScene(
+ for: target,
+ provider: provider,
+ status: provider.status(for: cell, indexPath: nil)
+ )
+ }
+
+ private static func _coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, status: Future) {
+ status
+ .sink { [weak provider] status in
+ guard let provider = provider else { return }
+ let _status: Status? = {
+ switch target {
+ case .primary: return status?.reblog ?? status // original status
+ case .secondary: return status // reblog or status
+ }
+ }()
+ guard let status = _status else { return }
+
+ let threadViewModel = CachedThreadViewModel(context: provider.context, status: status)
+ DispatchQueue.main.async {
+ if provider.navigationController == nil {
+ let from = provider.presentingViewController ?? provider
+ provider.dismiss(animated: true) {
+ provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: from, transition: .show)
+ }
+ } else {
+ provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: provider, transition: .show)
+ }
+ }
+ }
+ .store(in: &provider.disposeBag)
+ }
+
}
extension StatusProviderFacade {
@@ -229,7 +277,6 @@ extension StatusProviderFacade {
}
extension StatusProviderFacade {
-
static func responseToStatusReblogAction(provider: StatusProvider) {
_responseToStatusReblogAction(
@@ -337,10 +384,41 @@ extension StatusProviderFacade {
}
+extension StatusProviderFacade {
+
+ static func responseToStatusReplyAction(provider: StatusProvider) {
+ _responseToStatusReplyAction(
+ provider: provider,
+ status: provider.status()
+ )
+ }
+
+ static func responseToStatusReplyAction(provider: StatusProvider, cell: UITableViewCell) {
+ _responseToStatusReplyAction(
+ provider: provider,
+ status: provider.status(for: cell, indexPath: nil)
+ )
+ }
+
+ private static func _responseToStatusReplyAction(provider: StatusProvider, status: Future) {
+ status
+ .sink { [weak provider] status in
+ guard let provider = provider else { return }
+ guard let status = status?.reblog ?? status else { return }
+
+ let composeViewModel = ComposeViewModel(context: provider.context, composeKind: .reply(repliedToStatusObjectID: status.objectID))
+ provider.coordinator.present(scene: .compose(viewModel: composeViewModel), from: provider, transition: .modal(animated: true, completion: nil))
+ }
+ .store(in: &provider.context.disposeBag)
+
+ }
+
+}
+
extension StatusProviderFacade {
enum Target {
- case primary // original
- case secondary // attachment reblog or reply
+ case primary // original status
+ case secondary // wrapper status or reply (when needs. e.g tap header of status view)
}
}
diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift
index ecd8291f..f96998ea 100644
--- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift
+++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift
@@ -9,10 +9,12 @@ import UIKit
import AVKit
// Check List Last Updated
-// - FavoriteViewController: 2021/4/8
+// - HomeViewController: 2021/4/13
+// - FavoriteViewController: 2021/4/14
// - HashtagTimelineViewController: 2021/4/8
-// - UserTimelineViewController: 2021/4/8
-// * StatusTableViewControllerAspect: 2021/4/7
+// - UserTimelineViewController: 2021/4/13
+// - ThreadViewController: 2021/4/13
+// * StatusTableViewControllerAspect: 2021/4/12
// (Fake) Aspect protocol to group common protocol extension implementations
// Needs update related view controller when aspect interface changes
@@ -69,7 +71,7 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat
}
}
-// [B4] StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:)
+// [B4] aspectTableView(_:didEndDisplaying:forRowAt:)
extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
/// [Media] hook to notify video service
func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
@@ -93,6 +95,14 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat
}
}
+// [B5] aspectTableView(_:didSelectRowAt:)
+extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider {
+ /// [UI] hook to coordinator to thread
+ func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ handleTableView(tableView, didSelectRowAt: indexPath)
+ }
+}
+
// MARK: - UITableViewDataSourcePrefetching [C]
// [C1] aspectTableView(:prefetchRowsAt)
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json
index 6bce2b69..bd6f07f2 100644
--- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json
@@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0x00",
- "green" : "0x00",
- "red" : "0x00"
+ "blue" : "0x2E",
+ "green" : "0x2C",
+ "red" : "0x2C"
}
},
"idiom" : "universal"
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json
index 55f84c26..23d03492 100644
--- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json
@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0xFE",
- "green" : "0xFF",
- "red" : "0xFE"
+ "blue" : "254",
+ "green" : "255",
+ "red" : "254"
}
},
"idiom" : "universal"
@@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0x2E",
- "green" : "0x2C",
- "red" : "0x2C"
+ "blue" : "0x00",
+ "green" : "0x00",
+ "red" : "0x00"
}
},
"idiom" : "universal"
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json
index d8f32572..9fa2b261 100644
--- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json
@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0xFF",
+ "blue" : "0xFE",
"green" : "0xFF",
- "red" : "0xFF"
+ "red" : "0xFE"
}
},
"idiom" : "universal"
@@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0x2B",
- "green" : "0x23",
- "red" : "0x1F"
+ "blue" : "0x3C",
+ "green" : "0x3A",
+ "red" : "0x3A"
}
},
"idiom" : "universal"
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json
index d4705004..5da572b1 100644
--- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json
+++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json
@@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0x2B",
- "green" : "0x23",
- "red" : "0x1F"
+ "blue" : "0x3C",
+ "green" : "0x3A",
+ "red" : "0x3A"
}
},
"idiom" : "universal"
diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json b/Mastodon/Resources/Assets.xcassets/Human/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Human/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json
new file mode 100644
index 00000000..df869a35
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json
@@ -0,0 +1,25 @@
+{
+ "images" : [
+ {
+ "filename" : "emojiIconLight.pdf",
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "emojiIconDark.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf
new file mode 100644
index 00000000..77c6c2d3
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf
@@ -0,0 +1,97 @@
+%PDF-1.7
+
+1 0 obj
+ << >>
+endobj
+
+2 0 obj
+ << /Length 3 0 R >>
+stream
+/DeviceRGB CS
+/DeviceRGB cs
+q
+1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
+0.225600 0.613812 0.894400 scn
+48.000000 0.000000 m
+74.509674 0.000000 96.000000 21.490326 96.000000 48.000000 c
+96.000000 74.509666 74.509674 96.000000 48.000000 96.000000 c
+21.490332 96.000000 0.000000 74.509666 0.000000 48.000000 c
+0.000000 21.490326 21.490332 0.000000 48.000000 0.000000 c
+h
+48.000023 39.999962 m
+38.338688 39.999962 31.928020 41.125294 24.000021 42.666630 c
+22.189354 43.015961 18.666687 42.666630 18.666687 37.333294 c
+18.666687 26.666626 30.920021 13.333298 48.000023 13.333298 c
+65.077354 13.333298 77.333359 26.666626 77.333359 37.333294 c
+77.333359 42.666630 73.810692 43.018627 72.000023 42.666630 c
+64.072021 41.125294 57.661354 39.999962 48.000023 39.999962 c
+h
+38.666645 59.999981 m
+38.666645 54.845322 35.681877 50.666649 31.999979 50.666649 c
+28.318081 50.666649 25.333313 54.845322 25.333313 59.999981 c
+25.333313 65.154640 28.318081 69.333313 31.999979 69.333313 c
+35.681877 69.333313 38.666645 65.154640 38.666645 59.999981 c
+h
+63.999977 50.666649 m
+67.681877 50.666649 70.666641 54.845322 70.666641 59.999981 c
+70.666641 65.154640 67.681877 69.333313 63.999977 69.333313 c
+60.318081 69.333313 57.333313 65.154640 57.333313 59.999981 c
+57.333313 54.845322 60.318081 50.666649 63.999977 50.666649 c
+h
+48.000000 34.666645 m
+32.000000 34.666645 24.000000 37.333313 24.000000 37.333313 c
+24.000000 37.333313 29.333334 26.666649 48.000000 26.666649 c
+66.666672 26.666649 72.000000 37.333313 72.000000 37.333313 c
+72.000000 37.333313 64.000000 34.666645 48.000000 34.666645 c
+h
+f*
+n
+Q
+
+endstream
+endobj
+
+3 0 obj
+ 1603
+endobj
+
+4 0 obj
+ << /Annots []
+ /Type /Page
+ /MediaBox [ 0.000000 0.000000 96.000000 96.000000 ]
+ /Resources 1 0 R
+ /Contents 2 0 R
+ /Parent 5 0 R
+ >>
+endobj
+
+5 0 obj
+ << /Kids [ 4 0 R ]
+ /Count 1
+ /Type /Pages
+ >>
+endobj
+
+6 0 obj
+ << /Type /Catalog
+ /Pages 5 0 R
+ >>
+endobj
+
+xref
+0 7
+0000000000 65535 f
+0000000010 00000 n
+0000000034 00000 n
+0000001693 00000 n
+0000001716 00000 n
+0000001889 00000 n
+0000001963 00000 n
+trailer
+<< /ID [ (some) (id) ]
+ /Root 6 0 R
+ /Size 7
+>>
+startxref
+2022
+%%EOF
\ No newline at end of file
diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf
new file mode 100644
index 00000000..61f471d6
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf
@@ -0,0 +1,103 @@
+%PDF-1.7
+
+1 0 obj
+ << >>
+endobj
+
+2 0 obj
+ << /Length 3 0 R >>
+stream
+/DeviceRGB CS
+/DeviceRGB cs
+q
+1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
+0.168627 0.564706 0.850980 scn
+90.000000 48.000000 m
+90.000000 24.804031 71.195969 6.000000 48.000000 6.000000 c
+24.804039 6.000000 6.000000 24.804031 6.000000 48.000000 c
+6.000000 71.195961 24.804041 90.000000 48.000000 90.000000 c
+71.195969 90.000000 90.000000 71.195961 90.000000 48.000000 c
+h
+48.000000 0.000000 m
+74.509674 0.000000 96.000000 21.490326 96.000000 48.000000 c
+96.000000 74.509666 74.509674 96.000000 48.000000 96.000000 c
+21.490332 96.000000 0.000000 74.509666 0.000000 48.000000 c
+0.000000 21.490326 21.490332 0.000000 48.000000 0.000000 c
+h
+38.666645 59.999981 m
+38.666645 54.845322 35.681877 50.666649 31.999979 50.666649 c
+28.318081 50.666649 25.333313 54.845322 25.333313 59.999981 c
+25.333313 65.154640 28.318081 69.333313 31.999979 69.333313 c
+35.681877 69.333313 38.666645 65.154640 38.666645 59.999981 c
+h
+63.999977 50.666649 m
+67.681877 50.666649 70.666641 54.845322 70.666641 59.999981 c
+70.666641 65.154640 67.681877 69.333313 63.999977 69.333313 c
+60.318081 69.333313 57.333313 65.154640 57.333313 59.999981 c
+57.333313 54.845322 60.318081 50.666649 63.999977 50.666649 c
+h
+48.000023 39.999962 m
+38.338688 39.999962 31.928020 41.125294 24.000021 42.666630 c
+22.189354 43.015961 18.666687 42.666630 18.666687 37.333294 c
+18.666687 26.666626 30.920021 13.333298 48.000023 13.333298 c
+65.077354 13.333298 77.333359 26.666626 77.333359 37.333294 c
+77.333359 42.666630 73.810684 43.018627 72.000023 42.666630 c
+64.072021 41.125294 57.661354 39.999962 48.000023 39.999962 c
+h
+24.000000 37.333313 m
+24.000000 37.333313 32.000000 34.666645 48.000000 34.666645 c
+64.000000 34.666645 72.000000 37.333313 72.000000 37.333313 c
+72.000000 37.333313 66.666672 26.666649 48.000000 26.666649 c
+29.333334 26.666649 24.000000 37.333313 24.000000 37.333313 c
+h
+f*
+n
+Q
+
+endstream
+endobj
+
+3 0 obj
+ 1869
+endobj
+
+4 0 obj
+ << /Annots []
+ /Type /Page
+ /MediaBox [ 0.000000 0.000000 96.000000 96.000000 ]
+ /Resources 1 0 R
+ /Contents 2 0 R
+ /Parent 5 0 R
+ >>
+endobj
+
+5 0 obj
+ << /Kids [ 4 0 R ]
+ /Count 1
+ /Type /Pages
+ >>
+endobj
+
+6 0 obj
+ << /Type /Catalog
+ /Pages 5 0 R
+ >>
+endobj
+
+xref
+0 7
+0000000000 65535 f
+0000000010 00000 n
+0000000034 00000 n
+0000001959 00000 n
+0000001982 00000 n
+0000002155 00000 n
+0000002229 00000 n
+trailer
+<< /ID [ (some) (id) ]
+ /Root 6 0 R
+ /Size 7
+>>
+startxref
+2288
+%%EOF
\ No newline at end of file
diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Profile/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Compose/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json
similarity index 76%
rename from Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json
index 3338422a..82edd034 100644
--- a/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json
+++ b/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json
@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0xFE",
- "green" : "0xFF",
- "red" : "0xFE"
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
}
},
"idiom" : "universal"
@@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0x00",
- "green" : "0x00",
- "red" : "0x00"
+ "blue" : "30",
+ "green" : "28",
+ "red" : "28"
}
},
"idiom" : "universal"
diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json
new file mode 100644
index 00000000..4ef70f63
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "222",
+ "green" : "216",
+ "red" : "214"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "43",
+ "green" : "43",
+ "red" : "43"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json
new file mode 100644
index 00000000..6e965652
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json
new file mode 100644
index 00000000..6e965652
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json
new file mode 100644
index 00000000..6e965652
--- /dev/null
+++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json
@@ -0,0 +1,9 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "provides-namespace" : true
+ }
+}
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/background.cyan.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/background.cyan.colorset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.imageset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.large.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.large.imageset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/logotypeFull1.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/logotypeFull1.pdf
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/Contents.json
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json
diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf
similarity index 100%
rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf
rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf
diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings
index 40000bef..c35d6e63 100644
--- a/Mastodon/Resources/en.lproj/Localizable.strings
+++ b/Mastodon/Resources/en.lproj/Localizable.strings
@@ -68,6 +68,7 @@ Your account looks like this to them.";
"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended.";
"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts";
"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts...";
+"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies";
"Common.Countable.Photo.Multiple" = "photos";
"Common.Countable.Photo.Single" = "photo";
"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be
@@ -85,10 +86,12 @@ uploaded to Mastodon.";
"Scene.Compose.Poll.DurationTime" = "Duration: %@";
"Scene.Compose.Poll.OneDay" = "1 Day";
"Scene.Compose.Poll.OneHour" = "1 Hour";
+"Scene.Compose.Poll.OptionNumber" = "Option %ld";
"Scene.Compose.Poll.SevenDays" = "7 Days";
"Scene.Compose.Poll.SixHours" = "6 Hours";
"Scene.Compose.Poll.ThirtyMinutes" = "30 minutes";
"Scene.Compose.Poll.ThreeDays" = "3 Days";
+"Scene.Compose.ReplyingToUser" = "replying to %@";
"Scene.Compose.Title.NewPost" = "New Post";
"Scene.Compose.Title.NewReply" = "New Reply";
"Scene.Compose.Visibility.Direct" = "Only people I mention";
@@ -186,5 +189,11 @@ any server.";
"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@.";
"Scene.ServerRules.TermsOfService" = "terms of service";
"Scene.ServerRules.Title" = "Some ground rules.";
+"Scene.Thread.BackTitle" = "Post";
+"Scene.Thread.Favorite.Multiple" = "%@ favorites";
+"Scene.Thread.Favorite.Single" = "%@ favorite";
+"Scene.Thread.Reblog.Multiple" = "%@ reblogs";
+"Scene.Thread.Reblog.Single" = "%@ reblog";
+"Scene.Thread.Title" = "Post from %@";
"Scene.Welcome.Slogan" = "Social networking
back in your hands.";
\ No newline at end of file
diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift
index 0163a54c..95e9b4f1 100644
--- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift
+++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift
@@ -6,9 +6,24 @@
//
import UIKit
+import Combine
final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCell {
+ var disposeBag = Set()
+
+ let statusView = StatusView()
+
+ let framePublisher = PassthroughSubject()
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+
+ statusView.isStatusTextSensitive = false
+ statusView.cleanUpContentWarning()
+ disposeBag.removeAll()
+ }
+
override init(frame: CGRect) {
super.init(frame: frame)
_init()
@@ -19,12 +34,29 @@ final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCel
_init()
}
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ framePublisher.send(bounds)
+ }
+
}
extension ComposeRepliedToStatusContentCollectionViewCell {
private func _init() {
+ backgroundColor = .clear
+ statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color
+
+ statusView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(statusView)
+ NSLayoutConstraint.activate([
+ statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20),
+ statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
+ contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor),
+ contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10),
+ ])
+ statusView.actionToolbarContainer.isHidden = true
}
}
diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift
similarity index 98%
rename from Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift
rename to Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift
index bc087c99..141a944f 100644
--- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift
+++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift
@@ -1,5 +1,5 @@
//
-// ComposeStatusAttachmentTableViewCell.swift
+// ComposeStatusAttachmentCollectionViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-17.
diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift
index f1fe6b54..2b71e55f 100644
--- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift
+++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift
@@ -91,7 +91,7 @@ extension ComposeStatusContentCollectionViewCell {
statusContentWarningEditorView.containerView.isHidden = true
}
-
+
}
// MARK: - TextEditorViewChangeObserver
diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift
index 2c321f51..39a12f95 100644
--- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift
+++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift
@@ -29,7 +29,7 @@ final class ComposeStatusPollOptionAppendEntryCollectionViewCell: UICollectionVi
override var isHighlighted: Bool {
didSet {
- pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? Asset.Colors.Background.secondarySystemBackground.color : Asset.Colors.Background.systemBackground.color
+ pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? Asset.Colors.Background.tertiarySystemBackground.color : Asset.Colors.Background.secondarySystemBackground.color
pollOptionView.plusCircleImageView.tintColor = isHighlighted ? Asset.Colors.Button.normal.color.withAlphaComponent(0.5) : Asset.Colors.Button.normal.color
}
}
@@ -82,7 +82,7 @@ extension ComposeStatusPollOptionAppendEntryCollectionViewCell {
pollOptionView.optionTextField.isHidden = true
pollOptionView.plusCircleImageView.isHidden = false
- pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemBackground.color
+ pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
setupBorderColor()
pollOptionView.addGestureRecognizer(singleTagGestureRecognizer)
diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift
index 3e82cd51..b463f13a 100644
--- a/Mastodon/Scene/Compose/ComposeViewController.swift
+++ b/Mastodon/Scene/Compose/ComposeViewController.swift
@@ -31,7 +31,7 @@ final class ComposeViewController: UIViewController, NeedsDependency {
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted)
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
button.setTitleColor(.white, for: .normal)
- button.contentEdgeInsets = UIEdgeInsets(top: 3, left: 16, bottom: 3, right: 16)
+ button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
button.adjustsImageWhenHighlighted = false
return button
}()
@@ -49,7 +49,8 @@ final class ComposeViewController: UIViewController, NeedsDependency {
collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self))
collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self))
collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self))
- collectionView.backgroundColor = Asset.Colors.Background.systemBackground.color
+ collectionView.backgroundColor = Asset.Scene.Compose.background.color
+ collectionView.alwaysBounceVertical = true
return collectionView
}()
@@ -66,20 +67,9 @@ final class ComposeViewController: UIViewController, NeedsDependency {
return view
}()
- let composeToolbarView: ComposeToolbarView = {
- let composeToolbarView = ComposeToolbarView()
- let text = UITextView()
- let inputView = UIInputView(frame: .init(x: 0, y: 0, width: 40, height: 40), inputViewStyle: .keyboard)
- text.inputAccessoryView = inputView
- composeToolbarView.backgroundColor = inputView.backgroundColor
- return composeToolbarView
- }()
+ let composeToolbarView = ComposeToolbarView()
var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
- let composeToolbarBackgroundView: UIView = {
- let backgroundView = UIView()
- backgroundView.backgroundColor = .secondarySystemBackground
- return backgroundView
- }()
+ let composeToolbarBackgroundView = UIView()
private(set) lazy var imagePicker: PHPickerViewController = {
var configuration = PHPickerConfiguration()
@@ -135,7 +125,7 @@ extension ComposeViewController {
self.title = title
}
.store(in: &disposeBag)
- view.backgroundColor = Asset.Colors.Background.systemBackground.color
+ view.backgroundColor = Asset.Scene.Compose.background.color
navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:)))
navigationItem.rightBarButtonItem = publishBarButtonItem
publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
@@ -202,14 +192,27 @@ extension ComposeViewController {
)
.sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in
guard let self = self else { return }
+
+ let extraMargin: CGFloat = {
+ if self.view.safeAreaInsets.bottom == .zero {
+ // needs extra margin for zero inset device to workaround UIKit issue
+ return self.composeToolbarView.frame.height
+ } else {
+ // default some magic 16 extra margin
+ return 16
+ }
+ }()
+
+ // update keyboard background color
guard isShow, state == .dock else {
- self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom
- self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom
+ self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
+ self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
self.view.layoutIfNeeded()
}
+ self.updateKeyboardBackground(isKeyboardDisplay: isShow)
return
}
// isShow AND dock state
@@ -218,22 +221,23 @@ extension ComposeViewController {
let contentFrame = self.view.convert(self.collectionView.frame, to: nil)
let padding = contentFrame.maxY - endFrame.minY
guard padding > 0 else {
- self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom
- self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom
+ self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
+ self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
self.view.layoutIfNeeded()
}
+ self.updateKeyboardBackground(isKeyboardDisplay: false)
return
}
- // add 16pt margin
- self.collectionView.contentInset.bottom = padding + 16
- self.collectionView.verticalScrollIndicatorInsets.bottom = padding + 16
+ self.collectionView.contentInset.bottom = padding + extraMargin
+ self.collectionView.verticalScrollIndicatorInsets.bottom = padding + extraMargin
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = padding
self.view.layoutIfNeeded()
}
+ self.updateKeyboardBackground(isKeyboardDisplay: isShow)
})
.store(in: &disposeBag)
@@ -266,13 +270,17 @@ extension ComposeViewController {
.store(in: &disposeBag)
// bind visibility toolbar UI
- viewModel.selectedStatusVisibility
- .receive(on: DispatchQueue.main)
- .sink { [weak self] type in
- guard let self = self else { return }
- self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal)
- }
- .store(in: &disposeBag)
+ Publishers.CombineLatest(
+ viewModel.selectedStatusVisibility,
+ viewModel.traitCollectionDidChangePublisher
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] type, _ in
+ guard let self = self else { return }
+ let image = type.image(interfaceStyle: self.traitCollection.userInterfaceStyle)
+ self.composeToolbarView.visibilityButton.setImage(image, for: .normal)
+ }
+ .store(in: &disposeBag)
viewModel.characterCount
.receive(on: DispatchQueue.main)
@@ -324,6 +332,24 @@ extension ComposeViewController {
}
})
.store(in: &disposeBag)
+
+ // setup snap behavior
+ Publishers.CombineLatest(
+ viewModel.repliedToCellFrame.removeDuplicates().eraseToAnyPublisher(),
+ viewModel.collectionViewState.eraseToAnyPublisher()
+ )
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] repliedToCellFrame, collectionViewState in
+ guard let self = self else { return }
+ guard repliedToCellFrame != .zero else { return }
+ switch collectionViewState {
+ case .fold:
+ self.collectionView.contentInset.top = -repliedToCellFrame.height
+ case .expand:
+ self.collectionView.contentInset.top = 0
+ }
+ }
+ .store(in: &disposeBag)
}
override func viewWillAppear(_ animated: Bool) {
@@ -336,6 +362,12 @@ extension ComposeViewController {
}
}
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+
+ viewModel.traitCollectionDidChangePublisher.send()
+ }
+
}
extension ComposeViewController {
@@ -463,6 +495,20 @@ extension ComposeViewController {
imagePicker.delegate = self
return imagePicker
}
+
+ private func updateKeyboardBackground(isKeyboardDisplay: Bool) {
+ guard isKeyboardDisplay else {
+ composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
+ return
+ }
+ composeToolbarBackgroundView.backgroundColor = UIColor(dynamicProvider: { traitCollection -> UIColor in
+ // avoid elevated color
+ switch traitCollection.userInterfaceStyle {
+ case .light: return .white
+ default: return .black
+ }
+ })
+ }
}
@@ -538,7 +584,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string)
let stringRange = NSRange(location: 0, length: string.length)
- let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?|#([^\\s.]+))")
+ let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))")
// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
// precondition :\B with following space
let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))")
@@ -727,6 +773,32 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
}
+// MARK: - UIScrollViewDelegate
+extension ComposeViewController {
+ func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) {
+ guard scrollView === collectionView else { return }
+
+ let repliedToCellFrame = viewModel.repliedToCellFrame.value
+ guard repliedToCellFrame != .zero else { return }
+ let throttle = viewModel.repliedToCellFrame.value.height - scrollView.adjustedContentInset.top
+ // print("\(throttle) - \(scrollView.contentOffset.y)")
+
+ switch viewModel.collectionViewState.value {
+ case .fold:
+ if scrollView.contentOffset.y < throttle {
+ viewModel.collectionViewState.value = .expand
+ }
+ os_log("%{public}s[%{public}ld], %{public}s: fold", ((#file as NSString).lastPathComponent), #line, #function)
+
+ case .expand:
+ if scrollView.contentOffset.y > -44 {
+ viewModel.collectionViewState.value = .fold
+ os_log("%{public}s[%{public}ld], %{public}s: expand", ((#file as NSString).lastPathComponent), #line, #function)
+ }
+ }
+ }
+}
+
// MARK: - UITableViewDelegate
extension ComposeViewController: UICollectionViewDelegate {
@@ -763,6 +835,10 @@ extension ComposeViewController: UICollectionViewDelegate {
// MARK: - UIAdaptivePresentationControllerDelegate
extension ComposeViewController: UIAdaptivePresentationControllerDelegate {
+
+ func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
+ return .fullScreen
+ }
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return viewModel.shouldDismiss.value
diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift
index 4d5a39be..6581e1fb 100644
--- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift
+++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift
@@ -27,6 +27,7 @@ extension ComposeViewModel {
dependency: dependency,
managedObjectContext: context.managedObjectContext,
composeKind: composeKind,
+ repliedToCellFrameSubscriber: repliedToCellFrame,
customEmojiPickerInputViewModel: customEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate,
composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate,
diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift
index c3e90381..fd3f5bce 100644
--- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift
+++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift
@@ -8,6 +8,7 @@
import os.log
import Foundation
import Combine
+import CoreDataStack
import GameplayKit
import MastodonSDK
@@ -64,6 +65,15 @@ extension ComposeViewModel.PublishState {
guard viewModel.isPollComposing.value else { return nil }
return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds
}()
+ let inReplyToID: Mastodon.Entity.Status.ID? = {
+ guard case let .reply(repliedToStatusObjectID) = viewModel.composeKind else { return nil }
+ var id: Mastodon.Entity.Status.ID?
+ viewModel.context.managedObjectContext.performAndWait {
+ guard let replyTo = viewModel.context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return }
+ id = replyTo.id
+ }
+ return id
+ }()
let sensitive: Bool = viewModel.isContentWarningComposing.value
let spoilerText: String? = {
let text = viewModel.composeStatusAttribute.contentWarningContent.value.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -105,6 +115,7 @@ extension ComposeViewModel.PublishState {
mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs,
pollOptions: pollOptions,
pollExpiresIn: pollExpiresIn,
+ inReplyToID: inReplyToID,
sensitive: sensitive,
spoilerText: spoilerText,
visibility: visibility
diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift
index f52c38a1..ef744d0b 100644
--- a/Mastodon/Scene/Compose/ComposeViewModel.swift
+++ b/Mastodon/Scene/Compose/ComposeViewModel.swift
@@ -26,9 +26,11 @@ final class ComposeViewModel {
let isPollComposing = CurrentValueSubject(false)
let isCustomEmojiComposing = CurrentValueSubject(false)
let isContentWarningComposing = CurrentValueSubject(false)
- let selectedStatusVisibility = CurrentValueSubject(.public)
+ let selectedStatusVisibility: CurrentValueSubject
let activeAuthentication: CurrentValueSubject
let activeAuthenticationBox: CurrentValueSubject
+ let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make intial event emit
+ let repliedToCellFrame = CurrentValueSubject(.zero)
// output
var diffableDataSource: UICollectionViewDiffableDataSource!
@@ -55,6 +57,7 @@ final class ComposeViewModel {
let isMediaToolbarButtonEnabled = CurrentValueSubject(true)
let isPollToolbarButtonEnabled = CurrentValueSubject(true)
let characterCount = CurrentValueSubject(0)
+ let collectionViewState = CurrentValueSubject(.fold)
// for hashtag: #' '
// for mention: @' '
@@ -83,10 +86,40 @@ final class ComposeViewModel {
case .post, .hashtag, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost)
case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
}
+ self.selectedStatusVisibility = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value?.user.locked == true ? .private : .public)
self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value)
self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
// end init
- if case let .hashtag(text) = composeKind {
+ if case let .reply(repliedToStatusObjectID) = composeKind {
+ context.managedObjectContext.performAndWait {
+ guard let status = context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return }
+ let composeAuthor: MastodonUser? = {
+ guard let objectID = self.activeAuthentication.value?.user.objectID else { return nil }
+ guard let author = context.managedObjectContext.object(with: objectID) as? MastodonUser else { return nil }
+ return author
+ }()
+
+ var mentionAccts: [String] = []
+ if composeAuthor?.id != status.author.id {
+ mentionAccts.append("@" + status.author.acct)
+ }
+ let mentions = (status.mentions ?? Set())
+ .sorted(by: { $0.index.intValue < $1.index.intValue })
+ .filter { $0.id != composeAuthor?.id }
+ for mention in mentions {
+ mentionAccts.append("@" + mention.acct)
+ }
+ for acct in mentionAccts {
+ UITextChecker.learnWord(acct)
+ }
+
+ let initialComposeContent = mentionAccts.joined(separator: " ")
+ let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
+ self.preInsertedContent = preInsertedContent
+ self.composeStatusAttribute.composeContent.value = preInsertedContent
+ }
+
+ } else if case let .hashtag(text) = composeKind {
let initialComposeContent = "#" + text
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
@@ -346,6 +379,13 @@ final class ComposeViewModel {
}
+extension ComposeViewModel {
+ enum CollectionViewState {
+ case fold // snap to input
+ case expand // snap to reply
+ }
+}
+
extension ComposeViewModel {
func createNewPollOptionIfPossible() {
guard pollOptionAttributes.value.count < 4 else { return }
diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift
index efe40826..99288a5e 100644
--- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift
+++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift
@@ -41,7 +41,10 @@ final class ComposeToolbarView: UIView {
let emojiButton: UIButton = {
let button = HighlightDimmableButton()
ComposeToolbarView.configureToolbarButtonAppearance(button: button)
- button.setImage(UIImage(systemName: "face.smiling", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal)
+ let image = Asset.Human.faceSmilingAdaptive.image
+ .af.imageScaled(to: CGSize(width: 20, height: 20))
+ .withRenderingMode(.alwaysTemplate)
+ button.setImage(image, for: .normal)
return button
}()
@@ -80,8 +83,12 @@ final class ComposeToolbarView: UIView {
}
extension ComposeToolbarView {
+
private func _init() {
- backgroundColor = .secondarySystemBackground
+ // magic keyboard color (iOS 14):
+ // light with white background: RGB 214 216 222
+ // dark with black background: RGB 43 43 43
+ backgroundColor = Asset.Scene.Compose.toolbarBackground.color
let stackView = UIStackView()
stackView.axis = .horizontal
@@ -125,9 +132,18 @@ extension ComposeToolbarView {
pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside)
emojiButton.addTarget(self, action: #selector(ComposeToolbarView.emojiButtonDidPressed(_:)), for: .touchUpInside)
contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside)
- visibilityButton.menu = createVisibilityContextMenu()
+ visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle)
visibilityButton.showsMenuAsPrimaryAction = true
+
+ updateToolbarButtonUserInterfaceStyle()
}
+
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+
+ updateToolbarButtonUserInterfaceStyle()
+ }
+
}
extension ComposeToolbarView {
@@ -152,12 +168,16 @@ extension ComposeToolbarView {
}
}
- var image: UIImage {
+ func image(interfaceStyle: UIUserInterfaceStyle) -> UIImage {
switch self {
- case .public: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))!
- case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))!
- case .private: return UIImage(systemName: "person.crop.circle.badge.plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))!
- case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))!
+ case .public:
+ switch interfaceStyle {
+ case .light: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))!
+ default: return UIImage(systemName: "person.3.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))!
+ }
+ case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))!
+ case .private: return UIImage(systemName: "person.crop.circle.badge.plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))!
+ case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 19, weight: .regular))!
}
}
@@ -182,6 +202,23 @@ extension ComposeToolbarView {
button.layer.cornerCurve = .continuous
}
+ private func updateToolbarButtonUserInterfaceStyle() {
+ switch traitCollection.userInterfaceStyle {
+ case .light:
+ mediaButton.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
+ contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
+
+ case .dark:
+ mediaButton.setImage(UIImage(systemName: "photo.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
+ contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
+
+ default:
+ assertionFailure()
+ }
+
+ visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle)
+ }
+
private func createMediaContextMenu() -> UIMenu {
var children: [UIMenuElement] = []
let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
@@ -208,9 +245,9 @@ extension ComposeToolbarView {
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
}
- private func createVisibilityContextMenu() -> UIMenu {
+ private func createVisibilityContextMenu(interfaceStyle: UIUserInterfaceStyle) -> UIMenu {
let children: [UIMenuElement] = VisibilitySelectionType.allCases.map { type in
- UIAction(title: type.title, image: type.image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in
+ UIAction(title: type.title, image: type.image(interfaceStyle: interfaceStyle), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in
guard let self = self else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: visibilitySelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, type.rawValue)
self.delegate?.composeToolbarView(self, visibilityButtonDidPressed: self.visibilityButton, visibilitySelectionType: type)
diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift
index 23068b7b..191ad374 100644
--- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift
@@ -18,14 +18,14 @@ extension HashtagTimelineViewController: StatusProvider {
return Future { promise in promise(.success(nil)) }
}
- func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future {
+ func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
- guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
+ guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift
index c9bf8741..ea1a03aa 100644
--- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift
@@ -57,7 +57,7 @@ extension HashtagTimelineViewController {
titleView.update(title: viewModel.hashtag, subtitle: nil)
navigationItem.titleView = titleView
- view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
navigationItem.rightBarButtonItem = composeBarButtonItem
@@ -218,6 +218,10 @@ extension HashtagTimelineViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
+
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, didSelectRowAt: indexPath)
+ }
}
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift
index 26f32a33..ed7b3a84 100644
--- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift
+++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift
@@ -28,7 +28,8 @@ extension HashtagTimelineViewModel {
managedObjectContext: context.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
statusTableViewCellDelegate: statusTableViewCellDelegate,
- timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
+ timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,
+ threadReplyLoaderTableViewCellDelegate: nil
)
}
}
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
index 0c43af79..401e4fc1 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift
@@ -33,6 +33,10 @@ extension HomeTimelineViewController {
guard let self = self else { return }
self.showProfileAction(action)
},
+ UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in
+ guard let self = self else { return }
+ self.showThreadAction(action)
+ },
UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in
guard let self = self else { return }
self.signOutAction(action)
@@ -304,5 +308,20 @@ extension HomeTimelineViewController {
coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
}
+ @objc private func showThreadAction(_ sender: UIAction) {
+ let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert)
+ alertController.addTextField()
+ let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in
+ guard let self = self else { return }
+ guard let textField = alertController?.textFields?.first else { return }
+ let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "")
+ self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show)
+ }
+ alertController.addAction(showAction)
+ let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
+ alertController.addAction(cancelAction)
+ coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil))
+ }
+
}
#endif
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift
index 9e191530..aea931a6 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift
@@ -18,14 +18,14 @@ extension HomeTimelineViewController: StatusProvider {
return Future { promise in promise(.success(nil)) }
}
- func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future {
+ func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
- guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
+ guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift
index 1f3dea81..53909b2d 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift
@@ -47,7 +47,6 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
-
return tableView
}()
@@ -71,7 +70,7 @@ extension HomeTimelineViewController {
super.viewDidLoad()
title = L10n.Scene.HomeTimeline.title
- view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
navigationItem.leftBarButtonItem = settingBarButtonItem
navigationItem.titleView = titleView
titleView.delegate = self
@@ -179,6 +178,8 @@ extension HomeTimelineViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
+ aspectViewWillAppear(animated)
+
// needs trigger manually after onboarding dismiss
setNeedsStatusBarAppearanceUpdate()
}
@@ -198,8 +199,8 @@ extension HomeTimelineViewController {
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
- context.videoPlaybackService.viewDidDisappear(from: self)
- context.audioPlaybackService.viewDidDisappear(from: self)
+
+ aspectViewDidDisappear(animated)
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
@@ -262,11 +263,19 @@ extension HomeTimelineViewController {
}
+// MARK: - StatusTableViewControllerAspect
+extension HomeTimelineViewController: StatusTableViewControllerAspect { }
+
+extension HomeTimelineViewController: TableViewCellHeightCacheableContainer {
+ var cellFrameCache: NSCache { return viewModel.cellFrameCache }
+}
+
// MARK: - UIScrollViewDelegate
extension HomeTimelineViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
- handleScrollViewDidScroll(scrollView)
- self.viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
+
+ aspectScrollViewDidScroll(scrollView)
+ viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
}
}
@@ -281,32 +290,26 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer {
extension HomeTimelineViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
- return 200
- // TODO:
- // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
- // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
- //
- // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else {
- // return 200
- // }
- // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
- //
- // return ceil(frame.height)
+ aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
- handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
+ aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
}
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
- handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
+ aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
+ }
+
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, didSelectRowAt: indexPath)
}
}
// MARK: - UITableViewDataSourcePrefetching
extension HomeTimelineViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
- handleTableView(tableView, prefetchRowsAt: indexPaths)
+ aspectTableView(tableView, prefetchRowsAt: indexPaths)
}
}
@@ -317,7 +320,6 @@ extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControl
}
}
-
// MARK: - TimelineMiddleLoaderTableViewCellDelegate
extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate {
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) {
diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift
index 5f16a18e..6f5e66c0 100644
--- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift
+++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift
@@ -29,7 +29,8 @@ extension HomeTimelineViewModel {
managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
statusTableViewCellDelegate: statusTableViewCellDelegate,
- timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
+ timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,
+ threadReplyLoaderTableViewCellDelegate: nil
)
// var snapshot = NSDiffableDataSourceSnapshot()
@@ -88,6 +89,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
for (i, timelineIndex) in timelineIndexes.enumerated() {
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute()
+ attribute.isSeparatorLineHidden = false
// append new item into snapshot
newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute))
@@ -96,6 +98,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
switch (isLast, timelineIndex.hasMore) {
case (false, true):
newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID))
+ attribute.isSeparatorLineHidden = true
case (true, true):
shouldAddBottomLoader = true
default:
diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift
index 9512ea78..f5d8c41c 100644
--- a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift
+++ b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift
@@ -16,14 +16,14 @@ final class WelcomeIllustrationView: UIView {
let leftHillImageView = UIImageView()
let centerHillImageView = UIImageView()
- private let cloudBaseImage = Asset.Welcome.Illustration.cloudBase.image
- private let elephantThreeOnGrassWithTreeTwoImage = Asset.Welcome.Illustration.elephantThreeOnGrassWithTreeTwo.image
- private let elephantThreeOnGrassWithTreeThreeImage = Asset.Welcome.Illustration.elephantThreeOnGrassWithTreeThree.image
- private let elephantThreeOnGrassImage = Asset.Welcome.Illustration.elephantThreeOnGrass.image
+ private let cloudBaseImage = Asset.Scene.Welcome.Illustration.cloudBase.image
+ private let elephantThreeOnGrassWithTreeTwoImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrassWithTreeTwo.image
+ private let elephantThreeOnGrassWithTreeThreeImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrassWithTreeThree.image
+ private let elephantThreeOnGrassImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrass.image
// layout outside
let elephantOnAirplaneWithContrailImageView: UIImageView = {
- let imageView = UIImageView(image: Asset.Welcome.Illustration.elephantOnAirplaneWithContrail.image)
+ let imageView = UIImageView(image: Asset.Scene.Welcome.Illustration.elephantOnAirplaneWithContrail.image)
imageView.contentMode = .scaleAspectFill
return imageView
}()
@@ -43,7 +43,7 @@ final class WelcomeIllustrationView: UIView {
extension WelcomeIllustrationView {
private func _init() {
- backgroundColor = Asset.Welcome.Illustration.backgroundCyan.color
+ backgroundColor = Asset.Scene.Welcome.Illustration.backgroundCyan.color
let topPaddingView = UIView()
diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift
index c647d04c..de89cd45 100644
--- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift
+++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift
@@ -17,7 +17,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency {
var welcomeIllustrationViewBottomAnchorLayoutConstraint: NSLayoutConstraint?
private(set) lazy var logoImageView: UIImageView = {
- let image = view.traitCollection.userInterfaceIdiom == .phone ? Asset.Welcome.mastodonLogo.image : Asset.Welcome.mastodonLogoBlackLarge.image
+ let image = view.traitCollection.userInterfaceIdiom == .phone ? Asset.Scene.Welcome.mastodonLogo.image : Asset.Scene.Welcome.mastodonLogoBlackLarge.image
let imageView = UIImageView(image: image)
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
diff --git a/Mastodon/Scene/Profile/CachedProfileViewModel.swift b/Mastodon/Scene/Profile/CachedProfileViewModel.swift
index 0e5823d0..083724be 100644
--- a/Mastodon/Scene/Profile/CachedProfileViewModel.swift
+++ b/Mastodon/Scene/Profile/CachedProfileViewModel.swift
@@ -10,8 +10,8 @@ import CoreDataStack
final class CachedProfileViewModel: ProfileViewModel {
- convenience init(context: AppContext, mastodonUser: MastodonUser) {
- self.init(context: context, optionalMastodonUser: mastodonUser)
+ init(context: AppContext, mastodonUser: MastodonUser) {
+ super.init(context: context, optionalMastodonUser: mastodonUser)
}
}
diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift
index 2dadc854..68adc1e3 100644
--- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift
+++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift
@@ -18,14 +18,14 @@ extension FavoriteViewController: StatusProvider {
return Future { promise in promise(.success(nil)) }
}
- func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future {
+ func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
- guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
+ guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift
index a175ae34..1e10a632 100644
--- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift
+++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift
@@ -45,7 +45,7 @@ extension FavoriteViewController {
override func viewDidLoad() {
super.viewDidLoad()
- view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
navigationItem.titleView = titleView
titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil)
@@ -114,6 +114,10 @@ extension FavoriteViewController: UITableViewDelegate {
aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, didSelectRowAt: indexPath)
+ }
+
}
// MARK: - UITableViewDataSourcePrefetching
diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift
index e64df2c9..85928e85 100644
--- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift
+++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift
@@ -25,7 +25,8 @@ extension FavoriteViewModel {
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
statusTableViewCellDelegate: statusTableViewCellDelegate,
- timelineMiddleLoaderTableViewCellDelegate: nil
+ timelineMiddleLoaderTableViewCellDelegate: nil,
+ threadReplyLoaderTableViewCellDelegate: nil
)
// set empty section to make update animation top-to-bottom style
diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift
index 2fba55e6..09d99c51 100644
--- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift
+++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift
@@ -100,7 +100,7 @@ final class ProfileHeaderView: UIView {
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
- label.textColor = Asset.Profile.Banner.usernameGray.color
+ label.textColor = Asset.Scene.Profile.Banner.usernameGray.color
label.text = "@alice"
label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0)
return label
@@ -131,7 +131,7 @@ final class ProfileHeaderView: UIView {
textEditorView.scrollView.isScrollEnabled = false
textEditorView.isScrollEnabled = false
textEditorView.font = .preferredFont(forTextStyle: .body)
- textEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color
+ textEditorView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color
textEditorView.layer.masksToBounds = true
textEditorView.layer.cornerCurve = .continuous
textEditorView.layer.cornerRadius = 10
@@ -356,9 +356,9 @@ extension ProfileHeaderView {
bioTextEditorView.backgroundColor = .clear
animator.addAnimations {
self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor
- self.nameTextFieldBackgroundView.backgroundColor = Asset.Profile.Banner.nameEditBackgroundGray.color
+ self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color
self.editAvatarBackgroundView.alpha = 1
- self.bioTextEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color
+ self.bioTextEditorView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color
}
}
diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift
index d4b57ffe..c7456038 100644
--- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift
+++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift
@@ -29,6 +29,8 @@ final class ProfileRelationshipActionButton: RoundedEdgesButton {
extension ProfileRelationshipActionButton {
private func _init() {
+ titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
+
actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(actvityIndicatorView)
NSLayoutConstraint.activate([
diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift
index 671f7c15..8fc915a0 100644
--- a/Mastodon/Scene/Profile/ProfileViewController.swift
+++ b/Mastodon/Scene/Profile/ProfileViewController.swift
@@ -625,6 +625,11 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate {
func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController postTimelineViewController: ScrollViewContainer, atIndex index: Int) {
os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index)
+ // update segemented control
+ if index < profileHeaderViewController.pageSegmentedControl.numberOfSegments {
+ profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = index
+ }
+
// save content offset
overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y
diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift
index c480e6fc..153f5099 100644
--- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift
+++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift
@@ -12,8 +12,8 @@ import MastodonSDK
final class RemoteProfileViewModel: ProfileViewModel {
- convenience init(context: AppContext, userID: Mastodon.Entity.Account.ID) {
- self.init(context: context, optionalMastodonUser: nil)
+ init(context: AppContext, userID: Mastodon.Entity.Account.ID) {
+ super.init(context: context, optionalMastodonUser: nil)
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
@@ -47,8 +47,6 @@ final class RemoteProfileViewModel: ProfileViewModel {
self.mastodonUser.value = mastodonUser
}
.store(in: &disposeBag)
-
}
-
}
diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift
index 1ea16440..4fc85781 100644
--- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift
+++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift
@@ -18,14 +18,14 @@ extension UserTimelineViewController: StatusProvider {
return Future { promise in promise(.success(nil)) }
}
- func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future {
+ func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
- guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
+ guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift
index e8e71ccf..2ec350b0 100644
--- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift
+++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift
@@ -45,7 +45,7 @@ extension UserTimelineViewController {
override func viewDidLoad() {
super.viewDidLoad()
- view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
@@ -124,6 +124,10 @@ extension UserTimelineViewController: UITableViewDelegate {
aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
}
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, didSelectRowAt: indexPath)
+ }
+
}
// MARK: - UITableViewDataSourcePrefetching
diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift
index 1a09e1b3..8e6f1314 100644
--- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift
+++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift
@@ -25,7 +25,8 @@ extension UserTimelineViewModel {
managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
statusTableViewCellDelegate: statusTableViewCellDelegate,
- timelineMiddleLoaderTableViewCellDelegate: nil
+ timelineMiddleLoaderTableViewCellDelegate: nil,
+ threadReplyLoaderTableViewCellDelegate: nil
)
// set empty section to make update animation top-to-bottom style
diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift
index a92b8f37..04fc526a 100644
--- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift
+++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift
@@ -19,14 +19,14 @@ extension PublicTimelineViewController: StatusProvider {
return Future { promise in promise(.success(nil)) }
}
- func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future {
+ func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future {
return Future { promise in
guard let diffableDataSource = self.viewModel.diffableDataSource else {
assertionFailure()
promise(.success(nil))
return
}
- guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell),
+ guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
promise(.success(nil))
return
diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift
index ce0e8b19..3ca407ca 100644
--- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift
+++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift
@@ -28,7 +28,8 @@ extension PublicTimelineViewModel {
managedObjectContext: fetchedResultsController.managedObjectContext,
timestampUpdatePublisher: timestampUpdatePublisher,
statusTableViewCellDelegate: statusTableViewCellDelegate,
- timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate
+ timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,
+ threadReplyLoaderTableViewCellDelegate: nil
)
items.value = []
stateMachine.enter(PublicTimelineViewModel.State.Loading.self)
diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift
index 5dcc47e0..704e425a 100644
--- a/Mastodon/Scene/Search/SearchViewController.swift
+++ b/Mastodon/Scene/Search/SearchViewController.swift
@@ -82,7 +82,7 @@ final class SearchViewController: UIViewController, NeedsDependency {
// searching
let searchingTableView: UITableView = {
let tableView = UITableView()
- tableView.backgroundColor = Asset.Colors.Background.searchResult.color
+ tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .singleLine
tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView.swift b/Mastodon/Scene/Share/View/Content/PollOptionView.swift
index eafeb55c..7125b691 100644
--- a/Mastodon/Scene/Share/View/Content/PollOptionView.swift
+++ b/Mastodon/Scene/Share/View/Content/PollOptionView.swift
@@ -27,7 +27,7 @@ final class PollOptionView: UIView {
let checkmarkBackgroundView: UIView = {
let view = UIView()
- view.backgroundColor = .systemBackground
+ view.backgroundColor = Asset.Colors.Background.tertiarySystemBackground.color
return view
}()
@@ -81,6 +81,7 @@ final class PollOptionView: UIView {
extension PollOptionView {
private func _init() {
+ // default color in the timeline
roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false
diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift
index fc0fda09..9365179e 100644
--- a/Mastodon/Scene/Share/View/Content/StatusView.swift
+++ b/Mastodon/Scene/Share/View/Content/StatusView.swift
@@ -29,7 +29,7 @@ final class StatusView: UIView {
static let avatarToLabelSpacing: CGFloat = 5
static let contentWarningBlurRadius: CGFloat = 12
- static let boostIconImage: UIImage = {
+ static let reblogIconImage: UIImage = {
let font = UIFont.systemFont(ofSize: 13, weight: .medium)
let configuration = UIImage.SymbolConfiguration(font: font)
let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color)
@@ -61,7 +61,7 @@ final class StatusView: UIView {
let headerIconLabel: UILabel = {
let label = UILabel()
- label.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage)
+ label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
return label
}()
@@ -181,7 +181,7 @@ final class StatusView: UIView {
// do not use visual effect view due to we blur text only without background
let contentWarningBlurContentImageView: UIImageView = {
let imageView = UIImageView()
- imageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
+ imageView.backgroundColor = Asset.Colors.Background.systemBackground.color
imageView.layer.masksToBounds = false
return imageView
}()
diff --git a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift
new file mode 100644
index 00000000..16d1b04a
--- /dev/null
+++ b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift
@@ -0,0 +1,89 @@
+//
+// ThreadMetaView.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import UIKit
+
+final class ThreadMetaView: UIView {
+
+ let dateLabel: UILabel = {
+ let label = UILabel()
+ label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
+ label.text = "Date"
+ return label
+ }()
+
+ let reblogButton: UIButton = {
+ let button = UIButton()
+ button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold))
+ button.setTitle("0 reblog", for: .normal)
+ button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal)
+ button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.5), for: .highlighted)
+ return button
+ }()
+
+ let favoriteButton: UIButton = {
+ let button = UIButton()
+ button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold))
+ button.setTitle("0 favorite", for: .normal)
+ button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal)
+ button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.5), for: .highlighted)
+ return button
+ }()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ _init()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ _init()
+ }
+
+}
+
+extension ThreadMetaView {
+ private func _init() {
+ let stackView = UIStackView()
+ stackView.axis = .horizontal
+ stackView.spacing = 20
+
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(stackView)
+ NSLayoutConstraint.activate([
+ stackView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
+ stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 12),
+ ])
+
+ stackView.addArrangedSubview(dateLabel)
+ stackView.addArrangedSubview(reblogButton)
+ stackView.addArrangedSubview(favoriteButton)
+
+ dateLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ reblogButton.setContentHuggingPriority(.required - 2, for: .horizontal)
+ favoriteButton.setContentHuggingPriority(.required - 1, for: .horizontal)
+ }
+}
+
+#if canImport(SwiftUI) && DEBUG
+import SwiftUI
+
+struct ThreadMetaView_Previews: PreviewProvider {
+
+ static var previews: some View {
+ UIViewPreview(width: 375) {
+ ThreadMetaView()
+ }
+ .previewLayout(.fixed(width: 375, height: 100))
+ }
+
+}
+
+#endif
+
diff --git a/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift b/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift
new file mode 100644
index 00000000..e801d175
--- /dev/null
+++ b/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift
@@ -0,0 +1,76 @@
+//
+// AdaptiveUserInterfaceStyleBarButtonItem.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-13.
+//
+
+import UIKit
+
+final class AdaptiveUserInterfaceStyleBarButtonItem: UIBarButtonItem {
+
+ let button = AdaptiveCustomButton()
+
+ init(lightImage: UIImage, darkImage: UIImage) {
+ super.init()
+ button.setImage(light: lightImage, dark: darkImage)
+ self.customView = button
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ }
+
+}
+
+extension AdaptiveUserInterfaceStyleBarButtonItem {
+ class AdaptiveCustomButton: UIButton {
+
+ var lightImage: UIImage?
+ var darkImage: UIImage?
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ _init()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ _init()
+ }
+
+ private func _init() {
+ adjustsImageWhenHighlighted = false
+ }
+
+ override var isHighlighted: Bool {
+ didSet {
+ alpha = isHighlighted ? 0.6 : 1
+ }
+ }
+
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+ resetImage()
+ }
+
+ func setImage(light: UIImage, dark: UIImage) {
+ lightImage = light
+ darkImage = dark
+ resetImage()
+ }
+
+ private func resetImage() {
+ switch traitCollection.userInterfaceStyle {
+ case .light:
+ setImage(lightImage, for: .normal)
+ case .dark,
+ .unspecified:
+ setImage(darkImage, for: .normal)
+ @unknown default:
+ assertionFailure()
+ }
+ }
+
+ }
+}
diff --git a/Mastodon/Scene/Share/View/Decoration/SawToothView.swift b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift
index ef1c89cc..8f41abbb 100644
--- a/Mastodon/Scene/Share/View/Decoration/SawToothView.swift
+++ b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift
@@ -22,7 +22,7 @@ final class SawToothView: UIView {
}
func _init() {
- backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
}
override func draw(_ rect: CGRect) {
@@ -37,7 +37,7 @@ final class SawToothView: UIView {
}
bezierPath.addLine(to: CGPoint(x: 0, y: bottomY))
bezierPath.close()
- Asset.Colors.Background.secondaryGroupedSystemBackground.color.setFill()
+ Asset.Colors.Background.systemBackground.color.setFill()
bezierPath.fill()
bezierPath.lineWidth = 0
bezierPath.stroke()
diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
index b600924a..afa044b6 100644
--- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
+++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift
@@ -32,6 +32,7 @@ protocol StatusTableViewCellDelegate: class {
func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController)
+ func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, replyButtonDidPressed sender: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton)
@@ -56,14 +57,25 @@ final class StatusTableViewCell: UITableViewCell {
var observations = Set()
let statusView = StatusView()
-
+ let threadMetaStackView = UIStackView()
+ let threadMetaView = ThreadMetaView()
+ let separatorLine = UIView.separatorLine
+
+ var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint!
+ var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint!
+
+ var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
+ var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint!
+
override func prepareForReuse() {
super.prepareForReuse()
+ selectionStyle = .default
statusView.isStatusTextSensitive = false
statusView.cleanUpContentWarning()
statusView.pollTableView.dataSource = nil
statusView.playerContainerView.reset()
statusView.playerContainerView.isHidden = true
+ threadMetaView.isHidden = true
disposeBag.removeAll()
observations.removeAll()
}
@@ -90,9 +102,8 @@ final class StatusTableViewCell: UITableViewCell {
extension StatusTableViewCell {
private func _init() {
- selectionStyle = .none
- backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
- statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
+ backgroundColor = Asset.Colors.Background.systemBackground.color
+ statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color
statusView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(statusView)
@@ -102,24 +113,74 @@ extension StatusTableViewCell {
contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor),
])
- let bottomPaddingView = UIView()
- bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
- contentView.addSubview(bottomPaddingView)
+ threadMetaStackView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(threadMetaStackView)
NSLayoutConstraint.activate([
- bottomPaddingView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10),
- bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
- bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
- bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
- bottomPaddingView.heightAnchor.constraint(equalToConstant: StatusTableViewCell.bottomPaddingHeight).priority(.defaultHigh),
+ threadMetaStackView.topAnchor.constraint(equalTo: statusView.bottomAnchor),
+ threadMetaStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
+ threadMetaStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
+ threadMetaStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
- bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
-
+ threadMetaStackView.addArrangedSubview(threadMetaView)
+
+ separatorLine.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(separatorLine)
+ separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
+ separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
+ separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor)
+ separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor)
+ NSLayoutConstraint.activate([
+ separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
+ separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)),
+ ])
+ resetSeparatorLineLayout()
+
statusView.delegate = self
statusView.pollTableView.delegate = self
statusView.statusMosaicImageViewContainer.delegate = self
statusView.actionToolbarContainer.delegate = self
+
+ // default hidden
+ threadMetaView.isHidden = true
}
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+
+ resetSeparatorLineLayout()
+ }
+
+}
+
+extension StatusTableViewCell {
+ private func resetSeparatorLineLayout() {
+ separatorLineToEdgeLeadingLayoutConstraint.isActive = false
+ separatorLineToEdgeTrailingLayoutConstraint.isActive = false
+ separatorLineToMarginLeadingLayoutConstraint.isActive = false
+ separatorLineToMarginTrailingLayoutConstraint.isActive = false
+
+ if traitCollection.userInterfaceIdiom == .phone {
+ // to edge
+ NSLayoutConstraint.activate([
+ separatorLineToEdgeLeadingLayoutConstraint,
+ separatorLineToEdgeTrailingLayoutConstraint,
+ ])
+ } else {
+ if traitCollection.horizontalSizeClass == .compact {
+ // to edge
+ NSLayoutConstraint.activate([
+ separatorLineToEdgeLeadingLayoutConstraint,
+ separatorLineToEdgeTrailingLayoutConstraint,
+ ])
+ } else {
+ // to margin
+ NSLayoutConstraint.activate([
+ separatorLineToMarginLeadingLayoutConstraint,
+ separatorLineToMarginTrailingLayoutConstraint,
+ ])
+ }
+ }
+ }
}
// MARK: - UITableViewDelegate
@@ -242,19 +303,21 @@ extension StatusTableViewCell: MosaicImageViewContainerDelegate {
// MARK: - ActionToolbarContainerDelegate
extension StatusTableViewCell: ActionToolbarContainerDelegate {
+
func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) {
-
+ delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, replyButtonDidPressed: sender)
}
+
func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) {
delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, reblogButtonDidPressed: sender)
}
+
func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) {
delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender)
}
- func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, bookmarkButtonDidPressed sender: UIButton) {
-
- }
+
func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) {
}
+
}
diff --git a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift
new file mode 100644
index 00000000..10ad0c5c
--- /dev/null
+++ b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift
@@ -0,0 +1,124 @@
+//
+// ThreadReplyLoaderTableViewCell.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-13.
+//
+
+import os.log
+import UIKit
+import Combine
+
+protocol ThreadReplyLoaderTableViewCellDelegate: class {
+ func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton)
+}
+
+final class ThreadReplyLoaderTableViewCell: UITableViewCell {
+
+ static let cellHeight: CGFloat = 44
+
+ weak var delegate: ThreadReplyLoaderTableViewCellDelegate?
+
+ let loadMoreButton: UIButton = {
+ let button = HighlightDimmableButton()
+ button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont
+ button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
+ button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal)
+ button.setTitle(L10n.Common.Controls.Timeline.Loader.showMoreReplies, for: .normal)
+ return button
+ }()
+
+ let separatorLine = UIView.separatorLine
+
+ var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint!
+ var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint!
+
+ var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint!
+ var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint!
+
+ override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
+ super.init(style: style, reuseIdentifier: reuseIdentifier)
+ _init()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ _init()
+ }
+
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+
+ resetSeparatorLineLayout()
+ }
+
+}
+
+extension ThreadReplyLoaderTableViewCell {
+
+ func _init() {
+ selectionStyle = .none
+ backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+
+ loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(loadMoreButton)
+ NSLayoutConstraint.activate([
+ loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor),
+ loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor),
+ contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor),
+ loadMoreButton.heightAnchor.constraint(equalToConstant: ThreadReplyLoaderTableViewCell.cellHeight).priority(.required - 1),
+ ])
+
+ separatorLine.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(separatorLine)
+ separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor)
+ separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
+ separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor)
+ separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor)
+ NSLayoutConstraint.activate([
+ separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
+ separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)),
+ ])
+ resetSeparatorLineLayout()
+
+ loadMoreButton.addTarget(self, action: #selector(ThreadReplyLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside)
+ }
+
+ private func resetSeparatorLineLayout() {
+ separatorLineToEdgeLeadingLayoutConstraint.isActive = false
+ separatorLineToEdgeTrailingLayoutConstraint.isActive = false
+ separatorLineToMarginLeadingLayoutConstraint.isActive = false
+ separatorLineToMarginTrailingLayoutConstraint.isActive = false
+
+ if traitCollection.userInterfaceIdiom == .phone {
+ // to edge
+ NSLayoutConstraint.activate([
+ separatorLineToEdgeLeadingLayoutConstraint,
+ separatorLineToEdgeTrailingLayoutConstraint,
+ ])
+ } else {
+ if traitCollection.horizontalSizeClass == .compact {
+ // to edge
+ NSLayoutConstraint.activate([
+ separatorLineToEdgeLeadingLayoutConstraint,
+ separatorLineToEdgeTrailingLayoutConstraint,
+ ])
+ } else {
+ // to margin
+ NSLayoutConstraint.activate([
+ separatorLineToMarginLeadingLayoutConstraint,
+ separatorLineToMarginTrailingLayoutConstraint,
+ ])
+ }
+ }
+ }
+
+}
+
+extension ThreadReplyLoaderTableViewCell {
+ @objc private func loadMoreButtonDidPressed(_ sender: UIButton) {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ delegate?.threadReplyLoaderTableViewCell(self, loadMoreButtonDidPressed: sender)
+ }
+}
diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift
index 38bf7ef7..da7420e4 100644
--- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift
+++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift
@@ -10,9 +10,9 @@ import Combine
class TimelineLoaderTableViewCell: UITableViewCell {
- static let buttonHeight: CGFloat = 62
- static let cellHeight: CGFloat = TimelineLoaderTableViewCell.buttonHeight + 17
- static let extraTopPadding: CGFloat = 10
+ static let buttonHeight: CGFloat = 44
+ static let buttonMargin: CGFloat = 12
+ static let cellHeight: CGFloat = buttonHeight + 2 * buttonMargin
static let labelFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium))
var disposeBag = Set()
@@ -22,7 +22,7 @@ class TimelineLoaderTableViewCell: UITableViewCell {
let loadMoreButton: UIButton = {
let button = HighlightDimmableButton()
button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont
- button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
+ button.backgroundColor = Asset.Colors.Background.systemBackground.color
button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal)
button.setTitle(L10n.Common.Controls.Timeline.Loader.loadMissingPosts, for: .normal)
button.setTitle("", for: .disabled)
@@ -73,15 +73,15 @@ class TimelineLoaderTableViewCell: UITableViewCell {
func _init() {
selectionStyle = .none
- backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
+ backgroundColor = .clear
loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(loadMoreButton)
NSLayoutConstraint.activate([
- loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 7),
+ loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: TimelineLoaderTableViewCell.buttonMargin),
loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor),
- contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: 14),
+ contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: TimelineLoaderTableViewCell.buttonMargin),
loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.buttonHeight).priority(.required - 1),
])
diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift
index 75c06a33..7438f5bf 100644
--- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift
+++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift
@@ -18,11 +18,8 @@ protocol TimelineMiddleLoaderTableViewCellDelegate: class {
final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell {
weak var delegate: TimelineMiddleLoaderTableViewCellDelegate?
- let sawToothView: SawToothView = {
- let sawToothView = SawToothView()
- sawToothView.translatesAutoresizingMaskIntoConstraints = false
- return sawToothView
- }()
+ let topSawToothView = SawToothView()
+ let bottomSawToothView = SawToothView()
override func _init() {
super._init()
@@ -34,12 +31,23 @@ final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell {
loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4)
loadMoreButton.addTarget(self, action: #selector(TimelineMiddleLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside)
- contentView.addSubview(sawToothView)
+ topSawToothView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(topSawToothView)
NSLayoutConstraint.activate([
- sawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
- sawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
- sawToothView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
- sawToothView.heightAnchor.constraint(equalToConstant: 3),
+ topSawToothView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ topSawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ topSawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ topSawToothView.heightAnchor.constraint(equalToConstant: 3),
+ ])
+ topSawToothView.transform = CGAffineTransform(scaleX: 1, y: -1) // upside down
+
+ bottomSawToothView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.addSubview(bottomSawToothView)
+ NSLayoutConstraint.activate([
+ bottomSawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ bottomSawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ bottomSawToothView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
+ bottomSawToothView.heightAnchor.constraint(equalToConstant: 3),
])
}
}
diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift
new file mode 100644
index 00000000..4accee1d
--- /dev/null
+++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift
@@ -0,0 +1,36 @@
+//
+// TimelineTopLoaderTableViewCell.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import UIKit
+import Combine
+
+final class TimelineTopLoaderTableViewCell: TimelineLoaderTableViewCell {
+ override func _init() {
+ super._init()
+
+ activityIndicatorView.isHidden = false
+
+ startAnimating()
+ }
+}
+
+#if canImport(SwiftUI) && DEBUG
+import SwiftUI
+
+struct TimelineTopLoaderTableViewCell_Previews: PreviewProvider {
+
+ static var previews: some View {
+ UIViewPreview(width: 375) {
+ TimelineTopLoaderTableViewCell()
+ }
+ .previewLayout(.fixed(width: 375, height: 100))
+ }
+
+}
+
+#endif
+
diff --git a/Mastodon/Scene/Thread/CachedThreadViewModel.swift b/Mastodon/Scene/Thread/CachedThreadViewModel.swift
new file mode 100644
index 00000000..d4866b0b
--- /dev/null
+++ b/Mastodon/Scene/Thread/CachedThreadViewModel.swift
@@ -0,0 +1,15 @@
+//
+// CachedThreadViewModel.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import Foundation
+import CoreDataStack
+
+final class CachedThreadViewModel: ThreadViewModel {
+ init(context: AppContext, status: Status) {
+ super.init(context: context, optionalStatus: status)
+ }
+}
diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift
new file mode 100644
index 00000000..e79c355c
--- /dev/null
+++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift
@@ -0,0 +1,50 @@
+//
+// RemoteThreadViewModel.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import os.log
+import UIKit
+import CoreDataStack
+import MastodonSDK
+
+final class RemoteThreadViewModel: ThreadViewModel {
+
+ init(context: AppContext, statusID: Mastodon.Entity.Status.ID) {
+ super.init(context: context, optionalStatus: nil)
+
+ guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
+ return
+ }
+ let domain = activeMastodonAuthenticationBox.domain
+ context.apiService.status(
+ domain: domain,
+ statusID: statusID,
+ authorizationBox: activeMastodonAuthenticationBox
+ )
+ .retry(3)
+ .sink { completion in
+ switch completion {
+ case .failure(let error):
+ // TODO: handle error
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote status %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, statusID, error.localizedDescription)
+ case .finished:
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote status %s fetched", ((#file as NSString).lastPathComponent), #line, #function, statusID)
+ }
+ } receiveValue: { [weak self] response in
+ guard let self = self else { return }
+ let managedObjectContext = context.managedObjectContext
+ let request = Status.sortedFetchRequest
+ request.fetchLimit = 1
+ request.predicate = Status.predicate(domain: domain, id: response.value.id)
+ guard let status = managedObjectContext.safeFetch(request).first else {
+ assertionFailure()
+ return
+ }
+ self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute())
+ }
+ .store(in: &disposeBag)
+ }
+}
diff --git a/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift b/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift
new file mode 100644
index 00000000..05cc6e4b
--- /dev/null
+++ b/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift
@@ -0,0 +1,88 @@
+//
+// ThreadViewController+StatusProvider.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import UIKit
+import Combine
+import CoreData
+import CoreDataStack
+
+// MARK: - StatusProvider
+extension ThreadViewController: StatusProvider {
+
+ func status() -> Future {
+ return Future { promise in promise(.success(nil)) }
+ }
+
+ func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future {
+ return Future { promise in
+ guard let diffableDataSource = self.viewModel.diffableDataSource else {
+ assertionFailure()
+ promise(.success(nil))
+ return
+ }
+ guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
+ let item = diffableDataSource.itemIdentifier(for: indexPath) else {
+ promise(.success(nil))
+ return
+ }
+
+ switch item {
+ case .root(let statusObjectID, _),
+ .reply(let statusObjectID, _),
+ .leaf(let statusObjectID, _):
+ let managedObjectContext = self.viewModel.context.managedObjectContext
+ managedObjectContext.perform {
+ let status = managedObjectContext.object(with: statusObjectID) as? Status
+ promise(.success(status))
+ }
+ default:
+ promise(.success(nil))
+ }
+ }
+ }
+
+ func status(for cell: UICollectionViewCell) -> Future {
+ return Future { promise in promise(.success(nil)) }
+ }
+
+ var managedObjectContext: NSManagedObjectContext {
+ return viewModel.context.managedObjectContext
+ }
+
+ var tableViewDiffableDataSource: UITableViewDiffableDataSource? {
+ return viewModel.diffableDataSource
+ }
+
+ func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? {
+ guard let diffableDataSource = self.viewModel.diffableDataSource else {
+ assertionFailure()
+ return nil
+ }
+
+ guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }),
+ let item = diffableDataSource.itemIdentifier(for: indexPath) else {
+ return nil
+ }
+
+ return item
+ }
+
+ func items(indexPaths: [IndexPath]) -> [Item] {
+ guard let diffableDataSource = self.viewModel.diffableDataSource else {
+ assertionFailure()
+ return []
+ }
+
+ var items: [Item] = []
+ for indexPath in indexPaths {
+ guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue }
+ items.append(item)
+ }
+ return items
+ }
+
+}
diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift
new file mode 100644
index 00000000..bd15b930
--- /dev/null
+++ b/Mastodon/Scene/Thread/ThreadViewController.swift
@@ -0,0 +1,211 @@
+//
+// ThreadViewController.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import os.log
+import UIKit
+import Combine
+import CoreData
+import AVKit
+
+final class ThreadViewController: UIViewController, NeedsDependency {
+
+ weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
+ weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
+
+ var disposeBag = Set()
+ var viewModel: ThreadViewModel!
+
+ let titleView = DoubleTitleLabelNavigationBarTitleView()
+
+ let replyBarButtonItem = AdaptiveUserInterfaceStyleBarButtonItem(
+ lightImage: UIImage(systemName: "arrowshape.turn.up.left")!,
+ darkImage: UIImage(systemName: "arrowshape.turn.up.left.fill")!
+ )
+
+ let tableView: UITableView = {
+ let tableView = ControlContainableTableView()
+ tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
+ tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self))
+ tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
+ tableView.register(ThreadReplyLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self))
+ tableView.rowHeight = UITableView.automaticDimension
+ tableView.separatorStyle = .none
+ tableView.backgroundColor = .clear
+
+ return tableView
+ }()
+
+ deinit {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
+ }
+
+}
+
+extension ThreadViewController {
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
+ navigationItem.title = L10n.Scene.Thread.backTitle
+ navigationItem.titleView = titleView
+ navigationItem.rightBarButtonItem = replyBarButtonItem
+ replyBarButtonItem.button.addTarget(self, action: #selector(ThreadViewController.replyBarButtonItemPressed(_:)), for: .touchUpInside)
+
+ viewModel.tableView = tableView
+ viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
+ tableView.delegate = self
+ tableView.prefetchDataSource = self
+ viewModel.setupDiffableDataSource(
+ for: tableView,
+ dependency: self,
+ statusTableViewCellDelegate: self,
+ threadReplyLoaderTableViewCellDelegate: self
+ )
+
+ tableView.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(tableView)
+ NSLayoutConstraint.activate([
+ tableView.topAnchor.constraint(equalTo: view.topAnchor),
+ tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+
+ viewModel.navigationBarTitle
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] title in
+ guard let self = self else { return }
+ self.titleView.update(title: title ?? L10n.Scene.Thread.backTitle, subtitle: nil)
+ }
+ .store(in: &disposeBag)
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+
+ aspectViewWillAppear(animated)
+ }
+
+ override func viewDidDisappear(_ animated: Bool) {
+ super.viewDidDisappear(animated)
+
+ aspectViewDidDisappear(animated)
+ }
+
+}
+
+extension ThreadViewController {
+ @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ guard let rootItem = viewModel.rootItem.value,
+ case let .root(statusObjectID, _) = rootItem else { return }
+ let composeViewModel = ComposeViewModel(context: context, composeKind: .reply(repliedToStatusObjectID: statusObjectID))
+ coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
+ }
+}
+
+// MARK: - StatusTableViewControllerAspect
+extension ThreadViewController: StatusTableViewControllerAspect { }
+
+// MARK: - TableViewCellHeightCacheableContainer
+extension ThreadViewController: TableViewCellHeightCacheableContainer {
+ var cellFrameCache: NSCache { viewModel.cellFrameCache }
+}
+
+// MARK: - UITableViewDelegate
+extension ThreadViewController: UITableViewDelegate {
+
+ func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
+ aspectTableView(tableView, estimatedHeightForRowAt: indexPath)
+ }
+
+ func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath)
+ }
+
+ func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
+ }
+
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ aspectTableView(tableView, didSelectRowAt: indexPath)
+ }
+
+ func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
+ guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
+ guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
+
+ // disable root selection
+ switch item {
+ case .root:
+ return nil
+ default:
+ return indexPath
+ }
+ }
+
+}
+
+// MARK: - UITableViewDataSourcePrefetching
+extension ThreadViewController: UITableViewDataSourcePrefetching {
+ func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
+ aspectTableView(tableView, prefetchRowsAt: indexPaths)
+ }
+}
+
+// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
+extension ThreadViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
+ func navigationBar() -> UINavigationBar? {
+ return navigationController?.navigationBar
+ }
+}
+
+// MARK: - AVPlayerViewControllerDelegate
+extension ThreadViewController: AVPlayerViewControllerDelegate {
+
+ func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
+ aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
+ }
+
+ func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
+ aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
+ }
+
+}
+
+// MARK: - statusTableViewCellDelegate
+extension ThreadViewController: StatusTableViewCellDelegate {
+ weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
+ func parent() -> UIViewController { return self }
+}
+
+// MARK: - ThreadReplyLoaderTableViewCellDelegate
+extension ThreadViewController: ThreadReplyLoaderTableViewCellDelegate {
+ func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) {
+ guard let diffableDataSource = viewModel.diffableDataSource else { return }
+ guard let indexPath = tableView.indexPath(for: cell) else { return }
+ guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
+ guard case let .leafBottomLoader(statusObjectID) = item else { return }
+
+ let nodes = viewModel.descendantNodes.value
+ nodes.forEach { node in
+ expandReply(node: node, statusObjectID: statusObjectID)
+ }
+ viewModel.descendantNodes.value = nodes
+ }
+
+ private func expandReply(node: ThreadViewModel.LeafNode, statusObjectID: NSManagedObjectID) {
+ if node.objectID == statusObjectID {
+ node.isChildrenExpanded = true
+ } else {
+ for child in node.children {
+ expandReply(node: child, statusObjectID: statusObjectID)
+ }
+ }
+ }
+}
diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift
new file mode 100644
index 00000000..323a7a54
--- /dev/null
+++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift
@@ -0,0 +1,186 @@
+//
+// ThreadViewModel+Diffable.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import UIKit
+import Combine
+import CoreData
+
+extension ThreadViewModel {
+
+ func setupDiffableDataSource(
+ for tableView: UITableView,
+ dependency: NeedsDependency,
+ statusTableViewCellDelegate: StatusTableViewCellDelegate,
+ threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate
+ ) {
+ let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common)
+ .autoconnect()
+ .share()
+ .eraseToAnyPublisher()
+
+ diffableDataSource = StatusSection.tableViewDiffableDataSource(
+ for: tableView,
+ dependency: dependency,
+ managedObjectContext: context.managedObjectContext,
+ timestampUpdatePublisher: timestampUpdatePublisher,
+ statusTableViewCellDelegate: statusTableViewCellDelegate,
+ timelineMiddleLoaderTableViewCellDelegate: nil,
+ threadReplyLoaderTableViewCellDelegate: threadReplyLoaderTableViewCellDelegate
+ )
+
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([.main])
+ if let rootNode = self.rootNode.value, rootNode.replyToID != nil {
+ snapshot.appendItems([.topLoader], toSection: .main)
+ }
+
+ diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
+
+ Publishers.CombineLatest3(
+ rootItem,
+ ancestorItems,
+ descendantItems
+ )
+ .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) // some magic to avoid jitter
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] rootItem, ancestorItems, descendantItems in
+ guard let self = self else { return }
+ guard let tableView = self.tableView,
+ let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar()
+ else { return }
+
+ guard let diffableDataSource = self.diffableDataSource else { return }
+ let oldSnapshot = diffableDataSource.snapshot()
+
+ var newSnapshot = NSDiffableDataSourceSnapshot()
+ newSnapshot.appendSections([.main])
+
+ let currentState = self.loadThreadStateMachine.currentState
+
+ // reply to
+ if self.rootNode.value?.replyToID != nil, !(currentState is LoadThreadState.NoMore) {
+ newSnapshot.appendItems([.topLoader], toSection: .main)
+ }
+ newSnapshot.appendItems(ancestorItems, toSection: .main)
+
+ // root
+ if let rootItem = rootItem {
+ switch rootItem {
+ case .root:
+ newSnapshot.appendItems([rootItem], toSection: .main)
+ default:
+ break
+ }
+ }
+
+ // leaf
+ if !(currentState is LoadThreadState.NoMore) {
+ newSnapshot.appendItems([.bottomLoader], toSection: .main)
+ }
+ newSnapshot.appendItems(descendantItems, toSection: .main)
+
+ // difference for first visiable item exclude .topLoader
+ guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else {
+ diffableDataSource.apply(newSnapshot)
+ return
+ }
+
+ // addtional margin for .topLoader
+ let oldTopMargin: CGFloat = {
+ let marginHeight = TimelineTopLoaderTableViewCell.cellHeight
+ if oldSnapshot.itemIdentifiers.contains(.topLoader) {
+ return marginHeight
+ }
+ if !ancestorItems.isEmpty {
+ return marginHeight
+ }
+
+ return .zero
+ }()
+
+ let oldRootCell: UITableViewCell? = {
+ guard let rootItem = rootItem else { return nil }
+ guard let index = oldSnapshot.indexOfItem(rootItem) else { return nil }
+ guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { return nil }
+ return cell
+ }()
+ // save height before cell reuse
+ let oldRootCellHeight = oldRootCell?.frame.height
+
+ diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
+ guard let _ = rootItem else {
+ return
+ }
+ if let oldRootCellHeight = oldRootCellHeight {
+ // set bottom inset. Make root item pin to top (with margin).
+ let bottomSpacing = tableView.safeAreaLayoutGuide.layoutFrame.height - oldRootCellHeight - oldTopMargin
+ tableView.contentInset.bottom = max(0, bottomSpacing)
+ }
+
+ // set scroll position
+ tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false)
+ let contentOffsetY: CGFloat = {
+ var offset: CGFloat = tableView.contentOffset.y - difference.offset
+ if tableView.contentInset.bottom != 0.0 && descendantItems.isEmpty {
+ // needs restore top margin if bottom inset adjusted AND no descendantItems
+ offset += oldTopMargin
+ }
+ return offset
+ }()
+ tableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: false)
+ }
+ }
+ .store(in: &disposeBag)
+ }
+
+}
+
+extension ThreadViewModel {
+ private struct Difference {
+ let item: T
+ let sourceIndexPath: IndexPath
+ let targetIndexPath: IndexPath
+ let offset: CGFloat
+ }
+
+ private func calculateReloadSnapshotDifference(
+ navigationBar: UINavigationBar,
+ tableView: UITableView,
+ oldSnapshot: NSDiffableDataSourceSnapshot,
+ newSnapshot: NSDiffableDataSourceSnapshot
+ ) -> Difference- ? {
+ guard oldSnapshot.numberOfItems != 0 else { return nil }
+ guard let visibleIndexPaths = tableView.indexPathsForVisibleRows?.sorted() else { return nil }
+
+ // find index of the first visible item exclude .topLoader
+ var _index: Int?
+ let items = oldSnapshot.itemIdentifiers(inSection: .main)
+ for (i, item) in items.enumerated() {
+ if case .topLoader = item { continue }
+ guard visibleIndexPaths.contains(where: { $0.row == i }) else { continue }
+
+ _index = i
+ break
+ }
+
+ guard let index = _index else { return nil }
+ let sourceIndexPath = IndexPath(row: index, section: 0)
+ guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil }
+
+ let item = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row]
+ guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: item) else { return nil }
+ let targetIndexPath = IndexPath(row: itemIndex, section: 0)
+
+ let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar)
+ return Difference(
+ item: item,
+ sourceIndexPath: sourceIndexPath,
+ targetIndexPath: targetIndexPath,
+ offset: offset
+ )
+ }
+}
diff --git a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift
new file mode 100644
index 00000000..5327edc5
--- /dev/null
+++ b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift
@@ -0,0 +1,127 @@
+//
+// ThreadViewModel+LoadThreadState.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import os.log
+import Foundation
+import Combine
+import GameplayKit
+import CoreDataStack
+import MastodonSDK
+
+extension ThreadViewModel {
+ class LoadThreadState: GKState {
+ weak var viewModel: ThreadViewModel?
+
+ init(viewModel: ThreadViewModel) {
+ 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, self.debugDescription, previousState.debugDescription)
+ }
+ }
+}
+
+extension ThreadViewModel.LoadThreadState {
+ class Initial: ThreadViewModel.LoadThreadState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ switch stateClass {
+ case is Loading.Type: return true
+ default: return false
+ }
+ }
+ }
+
+ class Loading: ThreadViewModel.LoadThreadState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ switch stateClass {
+ case is Fail.Type: return true
+ case is NoMore.Type: return true
+ default: return false
+ }
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ super.didEnter(from: previousState)
+
+ guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
+ guard let mastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
+ stateMachine.enter(Fail.self)
+ return
+ }
+
+ guard let rootNode = viewModel.rootNode.value else {
+ stateMachine.enter(Fail.self)
+ return
+ }
+
+ // trigger data source update
+ viewModel.rootItem.value = viewModel.rootItem.value
+
+ let domain = rootNode.domain
+ let statusID = rootNode.statusID
+ let replyToID = rootNode.replyToID
+
+ viewModel.context.apiService.statusContext(
+ domain: domain,
+ statusID: statusID,
+ mastodonAuthenticationBox: mastodonAuthenticationBox
+ )
+ .sink { completion in
+ switch completion {
+ case .failure(let error):
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch status context for %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, statusID, error.localizedDescription)
+ stateMachine.enter(Fail.self)
+ case .finished:
+ break
+ }
+ } receiveValue: { response in
+ stateMachine.enter(NoMore.self)
+
+ viewModel.ancestorNodes.value = ThreadViewModel.ReplyNode.replyToThread(
+ for: replyToID,
+ from: response.value.ancestors,
+ domain: domain,
+ managedObjectContext: viewModel.context.managedObjectContext
+ )
+ viewModel.descendantNodes.value = ThreadViewModel.LeafNode.tree(
+ for: rootNode.statusID,
+ from: response.value.descendants,
+ domain: domain,
+ managedObjectContext: viewModel.context.managedObjectContext
+ )
+ }
+ .store(in: &viewModel.disposeBag)
+ }
+
+ }
+
+ class Fail: ThreadViewModel.LoadThreadState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ switch stateClass {
+ case is Loading.Type: return true
+ default: return false
+ }
+ }
+
+ override func didEnter(from previousState: GKState?) {
+ super.didEnter(from: previousState)
+
+ guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ stateMachine.enter(Loading.self)
+ }
+ }
+ }
+
+ class NoMore: ThreadViewModel.LoadThreadState {
+ override func isValidNextState(_ stateClass: AnyClass) -> Bool {
+ return false
+ }
+ }
+
+}
diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift
new file mode 100644
index 00000000..50df678c
--- /dev/null
+++ b/Mastodon/Scene/Thread/ThreadViewModel.swift
@@ -0,0 +1,279 @@
+//
+// ThreadViewModel.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import os.log
+import UIKit
+import Combine
+import CoreData
+import CoreDataStack
+import GameplayKit
+import MastodonSDK
+
+class ThreadViewModel {
+
+ var disposeBag = Set()
+
+ // input
+ let context: AppContext
+ let rootNode: CurrentValueSubject
+ let rootItem: CurrentValueSubject
-
+ let cellFrameCache = NSCache()
+
+ weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
+ weak var tableView: UITableView?
+
+ // output
+ var diffableDataSource: UITableViewDiffableDataSource?
+ private(set) lazy var loadThreadStateMachine: GKStateMachine = {
+ let stateMachine = GKStateMachine(states: [
+ LoadThreadState.Initial(viewModel: self),
+ LoadThreadState.Loading(viewModel: self),
+ LoadThreadState.Fail(viewModel: self),
+ LoadThreadState.NoMore(viewModel: self),
+
+ ])
+ stateMachine.enter(LoadThreadState.Initial.self)
+ return stateMachine
+ }()
+ let ancestorNodes = CurrentValueSubject<[ReplyNode], Never>([])
+ let ancestorItems = CurrentValueSubject<[Item], Never>([])
+ let descendantNodes = CurrentValueSubject<[LeafNode], Never>([])
+ let descendantItems = CurrentValueSubject<[Item], Never>([])
+ let navigationBarTitle: CurrentValueSubject
+
+ init(context: AppContext, optionalStatus: Status?) {
+ self.context = context
+ self.rootNode = CurrentValueSubject(optionalStatus.flatMap { RootNode(domain: $0.domain, statusID: $0.id, replyToID: $0.inReplyToID) })
+ self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) })
+ self.navigationBarTitle = CurrentValueSubject(
+ optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) }
+ )
+
+ rootNode
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] rootNode in
+ guard let self = self else { return }
+ guard rootNode != nil else { return }
+ self.loadThreadStateMachine.enter(LoadThreadState.Loading.self)
+ }
+ .store(in: &disposeBag)
+
+ if optionalStatus == nil {
+ rootItem
+ .receive(on: DispatchQueue.main)
+ .sink { [weak self] rootItem in
+ guard let self = self else { return }
+ guard case let .root(objectID, _) = rootItem else { return }
+ self.context.managedObjectContext.perform {
+ guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else {
+ return
+ }
+ self.rootNode.value = RootNode(domain: status.domain, statusID: status.id, replyToID: status.inReplyToID)
+ self.navigationBarTitle.value = L10n.Scene.Thread.title(status.author.displayNameWithFallback)
+ }
+ }
+ .store(in: &disposeBag)
+ }
+
+ // descendantNodes
+
+ ancestorNodes
+ .receive(on: DispatchQueue.main)
+ .compactMap { [weak self] nodes -> [Item]? in
+ guard let self = self else { return nil }
+ guard !nodes.isEmpty else { return [] }
+
+ guard let diffableDataSource = self.diffableDataSource else { return nil }
+ let oldSnapshot = diffableDataSource.snapshot()
+ var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
+ for item in oldSnapshot.itemIdentifiers {
+ switch item {
+ case .reply(let objectID, let attribute):
+ oldSnapshotAttributeDict[objectID] = attribute
+ default:
+ break
+ }
+ }
+
+ var items: [Item] = []
+ for node in nodes {
+ let attribute = oldSnapshotAttributeDict[node.statusObjectID] ?? Item.StatusAttribute()
+ items.append(Item.reply(statusObjectID: node.statusObjectID, attribute: attribute))
+ }
+
+ return items.reversed()
+ }
+ .assign(to: \.value, on: ancestorItems)
+ .store(in: &disposeBag)
+
+ descendantNodes
+ .receive(on: DispatchQueue.main)
+ .compactMap { [weak self] nodes -> [Item]? in
+ guard let self = self else { return nil }
+ guard !nodes.isEmpty else { return [] }
+
+ guard let diffableDataSource = self.diffableDataSource else { return nil }
+ let oldSnapshot = diffableDataSource.snapshot()
+ var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
+ for item in oldSnapshot.itemIdentifiers {
+ switch item {
+ case .leaf(let objectID, let attribute):
+ oldSnapshotAttributeDict[objectID] = attribute
+ default:
+ break
+ }
+ }
+
+ var items: [Item] = []
+
+ func buildThread(node: LeafNode) {
+ let attribute = oldSnapshotAttributeDict[node.objectID] ?? Item.StatusAttribute()
+ items.append(Item.leaf(statusObjectID: node.objectID, attribute: attribute))
+ // only expand the first child
+ if let firstChild = node.children.first {
+ if !node.isChildrenExpanded {
+ items.append(Item.leafBottomLoader(statusObjectID: node.objectID))
+ } else {
+ buildThread(node: firstChild)
+ }
+ }
+ }
+
+ for node in nodes {
+ buildThread(node: node)
+ }
+ return items
+ }
+ .assign(to: \.value, on: descendantItems)
+ .store(in: &disposeBag)
+ }
+
+ deinit {
+ os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
+ }
+
+}
+
+extension ThreadViewModel {
+
+ struct RootNode {
+ let domain: String
+ let statusID: Mastodon.Entity.Status.ID
+ let replyToID: Mastodon.Entity.Status.ID?
+ }
+
+ class ReplyNode {
+ let statusID: Mastodon.Entity.Status.ID
+ let statusObjectID: NSManagedObjectID
+
+ init(statusID: Mastodon.Entity.Status.ID, statusObjectID: NSManagedObjectID) {
+ self.statusID = statusID
+ self.statusObjectID = statusObjectID
+ }
+
+ static func replyToThread(
+ for replyToID: Mastodon.Entity.Status.ID?,
+ from statuses: [Mastodon.Entity.Status],
+ domain: String,
+ managedObjectContext: NSManagedObjectContext
+ ) -> [ReplyNode] {
+ guard let replyToID = replyToID else {
+ return []
+ }
+
+ var nodes: [ReplyNode] = []
+ managedObjectContext.performAndWait {
+ let request = Status.sortedFetchRequest
+ request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id })
+ request.fetchLimit = statuses.count
+ let objects = managedObjectContext.safeFetch(request)
+
+ var objectDict: [Mastodon.Entity.Status.ID: Status] = [:]
+ for object in objects {
+ objectDict[object.id] = object
+ }
+ var nextID: Mastodon.Entity.Status.ID? = replyToID
+ while let _nextID = nextID {
+ guard let object = objectDict[_nextID] else { break }
+ nodes.append(ThreadViewModel.ReplyNode(statusID: _nextID, statusObjectID: object.objectID))
+ nextID = object.inReplyToID
+ }
+ }
+ return nodes.reversed()
+ }
+ }
+
+ class LeafNode {
+ let statusID: Mastodon.Entity.Status.ID
+ let objectID: NSManagedObjectID
+ let repliesCount: Int
+ let children: [LeafNode]
+
+ var isChildrenExpanded: Bool = false // default collapsed
+
+ init(
+ statusID: Mastodon.Entity.Status.ID,
+ objectID: NSManagedObjectID,
+ repliesCount: Int,
+ children: [ThreadViewModel.LeafNode]
+ ) {
+ self.statusID = statusID
+ self.objectID = objectID
+ self.repliesCount = repliesCount
+ self.children = children
+ }
+
+ static func tree(
+ for statusID: Mastodon.Entity.Status.ID,
+ from statuses: [Mastodon.Entity.Status],
+ domain: String,
+ managedObjectContext: NSManagedObjectContext
+ ) -> [LeafNode] {
+ // make an cache collection
+ var objectDict: [Mastodon.Entity.Status.ID: Status] = [:]
+
+ managedObjectContext.performAndWait {
+ let request = Status.sortedFetchRequest
+ request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id })
+ request.fetchLimit = statuses.count
+ let objects = managedObjectContext.safeFetch(request)
+
+ for object in objects {
+ objectDict[object.id] = object
+ }
+ }
+
+ var tree: [LeafNode] = []
+ let firstTierStatuses = statuses.filter { $0.inReplyToID == statusID }
+ for status in firstTierStatuses {
+ guard let node = node(of: status.id, objectDict: objectDict) else { continue }
+ tree.append(node)
+ }
+
+ return tree
+ }
+
+ static func node(
+ of statusID: Mastodon.Entity.Status.ID,
+ objectDict: [Mastodon.Entity.Status.ID: Status]
+ ) -> LeafNode? {
+ guard let object = objectDict[statusID] else { return nil }
+ let replies = (object.replyFrom ?? Set()).sorted(
+ by: { $0.createdAt > $1.createdAt } // order by date
+ )
+ let children = replies.compactMap { node(of: $0.id, objectDict: objectDict) }
+ return LeafNode(
+ statusID: statusID,
+ objectID: object.objectID,
+ repliesCount: object.repliesCount?.intValue ?? 0,
+ children: children
+ )
+ }
+ }
+
+}
+
diff --git a/Mastodon/Service/APIService/APIService+Thread.swift b/Mastodon/Service/APIService/APIService+Thread.swift
new file mode 100644
index 00000000..2633518c
--- /dev/null
+++ b/Mastodon/Service/APIService/APIService+Thread.swift
@@ -0,0 +1,57 @@
+//
+// APIService+Thread.swift
+// Mastodon
+//
+// Created by MainasuK Cirno on 2021-4-12.
+//
+
+import os.log
+import Foundation
+import Combine
+import CoreData
+import CoreDataStack
+import MastodonSDK
+
+extension APIService {
+
+ func statusContext(
+ domain: String,
+ statusID: Mastodon.Entity.Status.ID,
+ mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
+ ) -> AnyPublisher, Error> {
+ let authorization = mastodonAuthenticationBox.userAuthorization
+ guard domain == mastodonAuthenticationBox.domain else {
+ return Fail(error: APIError.implicit(.badRequest)).eraseToAnyPublisher()
+ }
+
+ return Mastodon.API.Statuses.statusContext(
+ session: session,
+ domain: domain,
+ statusID: statusID,
+ authorization: authorization
+ )
+ .flatMap { response -> AnyPublisher, Error> in
+ return APIService.Persist.persistStatus(
+ managedObjectContext: self.backgroundManagedObjectContext,
+ domain: domain,
+ query: nil,
+ response: response.map { $0.ancestors + $0.descendants },
+ persistType: .lookUp,
+ requestMastodonUserID: nil,
+ log: OSLog.api
+ )
+ .setFailureType(to: Error.self)
+ .tryMap { result -> Mastodon.Response.Content in
+ switch result {
+ case .success:
+ return response
+ case .failure(let error):
+ throw error
+ }
+ }
+ .eraseToAnyPublisher()
+ }
+ .eraseToAnyPublisher()
+ }
+
+}
diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift
index a05574b6..328fa230 100644
--- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift
+++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift
@@ -86,8 +86,8 @@ extension APIService.CoreData {
let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), votedBy: votedBy, options: options)
return object
}
- let metions = entity.mentions?.compactMap { mention -> Mention in
- Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url))
+ let metions = entity.mentions?.enumerated().compactMap { index, mention -> Mention in
+ Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url), index: index)
}
let emojis = entity.emojis?.compactMap { emoji -> Emoji in
Emoji.insert(into: managedObjectContext, property: Emoji.Property(shortcode: emoji.shortcode, url: emoji.url, staticURL: emoji.staticURL, visibleInPicker: emoji.visibleInPicker, category: emoji.category))
diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift
index 9bc699b7..f5bb4ea3 100644
--- a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift
+++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift
@@ -221,6 +221,13 @@ extension APIService.Persist {
break
}
+ // reply relationship link
+ for (_, status) in statusCache.dictionary {
+ guard let replyToID = status.inReplyToID, status.replyTo == nil else { continue }
+ guard let replyTo = statusCache.dictionary[replyToID] else { continue }
+ status.update(replyTo: replyTo)
+ }
+
// print working record tree map
#if DEBUG
DispatchQueue.global(qos: .utility).async {
diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift
index da54c934..bb5a4abf 100644
--- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift
+++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift
@@ -98,6 +98,7 @@ extension Mastodon.API.Statuses {
public let mediaIDs: [String]?
public let pollOptions: [String]?
public let pollExpiresIn: Int?
+ public let inReplyToID: Mastodon.Entity.Status.ID?
public let sensitive: Bool?
public let spoilerText: String?
public let visibility: Mastodon.Entity.Status.Visibility?
@@ -107,6 +108,7 @@ extension Mastodon.API.Statuses {
mediaIDs: [String]?,
pollOptions: [String]?,
pollExpiresIn: Int?,
+ inReplyToID: Mastodon.Entity.Status.ID?,
sensitive: Bool?,
spoilerText: String?,
visibility: Mastodon.Entity.Status.Visibility?
@@ -115,10 +117,10 @@ extension Mastodon.API.Statuses {
self.mediaIDs = mediaIDs
self.pollOptions = pollOptions
self.pollExpiresIn = pollExpiresIn
+ self.inReplyToID = inReplyToID
self.sensitive = sensitive
self.spoilerText = spoilerText
self.visibility = visibility
-
}
var contentType: String? {
@@ -136,6 +138,7 @@ extension Mastodon.API.Statuses {
data.append(Data.multipart(key: "poll[options][]", value: pollOption))
}
pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) }
+ inReplyToID.flatMap { data.append(Data.multipart(key: "in_reply_to_id", value: $0)) }
sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) }
spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) }
visibility.flatMap { data.append(Data.multipart(key: "visibility", value: $0.rawValue)) }
@@ -146,3 +149,46 @@ extension Mastodon.API.Statuses {
}
}
+
+extension Mastodon.API.Statuses {
+
+ static func statusContextEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL {
+ return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("statuses/\(statusID)/context")
+ }
+
+ /// Parent and child statuses
+ ///
+ /// View statuses above and below this status in the thread.
+ ///
+ /// - Since: 0.0.0
+ /// - Version: 3.3.0
+ /// # Last Update
+ /// 2021/4/12
+ /// # Reference
+ /// [Document](https://docs.joinmastodon.org/methods/statuses/)
+ /// - Parameters:
+ /// - session: `URLSession`
+ /// - domain: Mastodon instance domain. e.g. "example.com"
+ /// - statusID: id of status
+ /// - authorization: User token. Optional for public statuses
+ /// - Returns: `AnyPublisher` contains `Context` nested in the response
+ public static func statusContext(
+ session: URLSession,
+ domain: String,
+ statusID: Mastodon.Entity.Status.ID,
+ authorization: Mastodon.API.OAuth.Authorization?
+ ) -> AnyPublisher, Error> {
+ let request = Mastodon.API.get(
+ url: statusContextEndpointURL(domain: domain, statusID: statusID),
+ query: nil,
+ authorization: authorization
+ )
+ return session.dataTaskPublisher(for: request)
+ .tryMap { data, response in
+ let value = try Mastodon.API.decode(type: Mastodon.Entity.Context.self, from: data, response: response)
+ return Mastodon.Response.Content(value: value, response: response)
+ }
+ .eraseToAnyPublisher()
+ }
+
+}