diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index bc7a20d5d..e52f2d8e7 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -131,6 +131,7 @@ + @@ -226,7 +227,7 @@ - + diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift index 9559ea5d5..864ca4948 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 33f1abc2d..80757ff07 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..." @@ -333,6 +336,17 @@ "reblog": "rebloged your post", "poll": "Your poll has ended", "mention": "mentioned you" + }, + "thread": { + "back_title": "Post", + "title": "Post from %s", + "reblog": { + "single": "%s reblog", + "multiple": "%s reblogs" + }, + "favorite": { + "single": "%s favorite", + "multiple": "%s favorites" } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 19506bb99..427ffa0db 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -155,6 +155,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 */; }; @@ -229,7 +231,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 */; }; @@ -274,6 +276,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 */; }; @@ -317,6 +328,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 */; }; @@ -546,6 +558,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 = ""; }; @@ -626,7 +640,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 = ""; }; @@ -673,6 +687,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 = ""; }; @@ -715,6 +738,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 = ""; }; @@ -887,6 +911,7 @@ DB87D44A2609C11900D12C0D /* PollOptionView.swift */, DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */, + DBB9759B262462E1004620BD /* ThreadMetaView.swift */, ); path = Content; sourceTree = ""; @@ -1102,9 +1127,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; @@ -1342,6 +1369,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 */, 2D61254C262547C200299647 /* APIService+Notification.swift */, @@ -1463,7 +1491,7 @@ children = ( DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */, DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, - DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, + DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */, @@ -1588,6 +1616,7 @@ DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, DB789A1025F9F29B0071ACA0 /* Compose */, + DB938EEB2623F52600E5B6C1 /* Thread */, ); path = Scene; sourceTree = ""; @@ -1630,6 +1659,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 = ( @@ -1741,6 +1784,7 @@ children = ( DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, + DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */, ); path = Control; sourceTree = ""; @@ -2241,6 +2285,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 */, @@ -2249,6 +2294,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 */, 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, @@ -2263,6 +2309,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 */, @@ -2284,6 +2331,7 @@ 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 */, 2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */, @@ -2291,7 +2339,7 @@ 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 */, @@ -2309,6 +2357,7 @@ 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.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 */, @@ -2379,8 +2428,10 @@ 2D61254D262547C200299647 /* APIService+Notification.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 */, @@ -2398,11 +2449,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 */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, @@ -2452,6 +2505,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 */, @@ -2473,7 +2527,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 6ec23cf5d..fd1ce69a1 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 36d42745e..8874a69e8 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 9f82f6ca5..da3455201 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 56aa32798..0e7c574b4 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/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 0b63bb241..e7c96139e 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -343,7 +343,7 @@ extension NotificationSection { ) { 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 diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index fe720e0f0..36d4853a8 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 252f1289a..cced4abee 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 8a0630f03..cf1f84e97 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 072a3d4d4..35766c0bc 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 40eda8019..0e0869f00 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -97,26 +97,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 21a5053b8..81ac0d1f8 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 @@ -601,6 +611,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 e828602e4..5f652b32c 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 f8c99c13f..25322e216 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 32915baf9..cd6cbf589 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 e16343ee6..8e27a2207 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 abdc27902..0e26614c5 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 ecd8291ff..f96998ea6 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/pure.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/pure.colorset/Contents.json index 3338422aa..82edd034b 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/pure.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/pure.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/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json index 6bce2b697..bd6f07f25 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 55f84c267..23d03492f 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 d8f32572f..9fa2b261b 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 d47050048..5da572b1d 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 000000000..df869a35c --- /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 000000000..77c6c2d32 --- /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 000000000..61f471d6d --- /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/Scene/Compose/background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json new file mode 100644 index 000000000..82edd034b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "30", + "green" : "28", + "red" : "28" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 000000000..4ef70f635 --- /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 000000000..6e965652d --- /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 000000000..6e965652d --- /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 000000000..6e965652d --- /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 aa43ec64d..cdee03595 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"; @@ -193,5 +196,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 0163a54cd..95e9b4f1a 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 bc087c990..141a944fd 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 f1fe6b541..2b71e55f3 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 2c321f51f..39a12f954 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 3e82cd51a..b463f13ac 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 4d5a39be1..6581e1fb1 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 c3e903812..fd3f5bce0 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 f52c38a17..ef744d0b3 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 efe408265..99288a5e0 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 23068b7bc..191ad374d 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 c9bf87410..ea1a03aa9 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 26f32a33c..ed7b3a844 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 0c43af79e..401e4fc14 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 9e1915301..aea931a62 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 1f3dea81a..53909b2df 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 5f16a18eb..6f5e66c0e 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 9512ea78b..f5d8c41c8 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 c647d04ca..de89cd457 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 0e5823d09..083724be1 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 2dadc8545..68adc1e3e 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 a175ae348..1e10a6322 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 e64df2c99..85928e852 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 2fba55e66..09d99c51e 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 d4b57ffe4..c74560386 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 671f7c155..8fc915a0a 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 c480e6fc9..153f50998 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 1ea164406..4fc857812 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 e8e71ccf4..2ec350b07 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 1a09e1b35..8e6f1314f 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 a92b8f37e..04fc526a0 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 ce0e8b19d..3ca407caa 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/Share/View/Content/PollOptionView.swift b/Mastodon/Scene/Share/View/Content/PollOptionView.swift index eafeb55cf..7125b691c 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 fc0fda099..9365179ef 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 000000000..16d1b04a6 --- /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 000000000..e801d1756 --- /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 ef1c89cc0..8f41abbb3 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 b600924a6..afa044b67 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 000000000..10ad0c5c8 --- /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 38bf7ef78..da7420e43 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 75c06a339..7438f5bfd 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 000000000..4accee1de --- /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 000000000..d4866b0bd --- /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 000000000..e79c355cf --- /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 000000000..05cc6e4b2 --- /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 000000000..bd15b930e --- /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 000000000..323a7a545 --- /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 000000000..5327edc5c --- /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 000000000..50df678c6 --- /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 000000000..2633518ca --- /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 a05574b6b..328fa2305 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 9bc699b71..f5bb4ea3d 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 da54c9344..bb5a4abfc 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() + } + +}