Merge pull request #89 from tootsuite/feat/hastagTimeline
Feat/hastag timeline
This commit is contained in:
commit
502ceeabe2
|
@ -277,6 +277,9 @@
|
||||||
"follow": "Follow"
|
"follow": "Follow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"hashtag": {
|
||||||
|
"prompt": "%s people talking"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,16 @@
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; };
|
||||||
|
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; };
|
||||||
|
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; };
|
||||||
|
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; };
|
||||||
|
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */; };
|
||||||
|
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; };
|
||||||
|
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; };
|
||||||
|
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; };
|
||||||
|
0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; };
|
||||||
|
0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; };
|
||||||
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; };
|
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; };
|
||||||
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; };
|
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; };
|
||||||
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; };
|
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; };
|
||||||
|
@ -352,6 +362,16 @@
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = "<group>"; };
|
||||||
|
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = "<group>"; };
|
||||||
|
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||||
|
0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadLatestState.swift"; sourceTree = "<group>"; };
|
||||||
|
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = "<group>"; };
|
||||||
|
0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = "<group>"; };
|
||||||
|
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = "<group>"; };
|
||||||
|
0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = "<group>"; };
|
||||||
|
0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = "<group>"; };
|
||||||
0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = "<group>"; };
|
0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = "<group>"; };
|
||||||
0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; };
|
0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; };
|
||||||
0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
|
||||||
|
@ -706,6 +726,29 @@
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
0F1E2D102615C39800C38565 /* View */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */,
|
||||||
|
);
|
||||||
|
path = View;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
0F2021F5261325ED000C64BF /* HashtagTimeline */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0F1E2D102615C39800C38565 /* View */,
|
||||||
|
0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */,
|
||||||
|
0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */,
|
||||||
|
0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */,
|
||||||
|
0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */,
|
||||||
|
0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */,
|
||||||
|
0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */,
|
||||||
|
0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */,
|
||||||
|
);
|
||||||
|
path = HashtagTimeline;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
0FAA0FDD25E0B5700017CCDE /* Welcome */ = {
|
0FAA0FDD25E0B5700017CCDE /* Welcome */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1214,6 +1257,7 @@
|
||||||
DB9A488F26035963008B817C /* APIService+Media.swift */,
|
DB9A488F26035963008B817C /* APIService+Media.swift */,
|
||||||
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
|
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
|
||||||
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
|
2D34D9DA261494120081BFC0 /* APIService+Search.swift */,
|
||||||
|
0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */,
|
||||||
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
|
DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */,
|
||||||
);
|
);
|
||||||
path = APIService;
|
path = APIService;
|
||||||
|
@ -1428,6 +1472,7 @@
|
||||||
DB8AF55525C1379F002E6C99 /* Scene */ = {
|
DB8AF55525C1379F002E6C99 /* Scene */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
0F2021F5261325ED000C64BF /* HashtagTimeline */,
|
||||||
5D03938E2612D200007FE196 /* Webview */,
|
5D03938E2612D200007FE196 /* Webview */,
|
||||||
2D7631A425C1532200929FB9 /* Share */,
|
2D7631A425C1532200929FB9 /* Share */,
|
||||||
DB8AF54E25C13703002E6C99 /* MainTab */,
|
DB8AF54E25C13703002E6C99 /* MainTab */,
|
||||||
|
@ -1471,6 +1516,7 @@
|
||||||
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
|
2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */,
|
||||||
2D84350425FF858100EECE90 /* UIScrollView.swift */,
|
2D84350425FF858100EECE90 /* UIScrollView.swift */,
|
||||||
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
|
DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */,
|
||||||
|
0F20223826146553000C64BF /* Array+removeDuplicates.swift */,
|
||||||
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
|
DBCC3B2F261440A50045B23D /* UITabBarController.swift */,
|
||||||
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
|
DBCC3B35261440BA0045B23D /* UINavigationController.swift */,
|
||||||
);
|
);
|
||||||
|
@ -2052,6 +2098,7 @@
|
||||||
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
|
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
|
||||||
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */,
|
DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */,
|
||||||
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
|
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
|
||||||
|
0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */,
|
||||||
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
|
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
|
||||||
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
|
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
|
||||||
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
|
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
|
||||||
|
@ -2076,6 +2123,7 @@
|
||||||
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
|
DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */,
|
||||||
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */,
|
||||||
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */,
|
||||||
|
0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */,
|
||||||
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
|
DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */,
|
||||||
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */,
|
2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */,
|
||||||
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
|
DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */,
|
||||||
|
@ -2098,6 +2146,7 @@
|
||||||
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
|
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */,
|
||||||
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */,
|
DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */,
|
||||||
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
|
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||||
|
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
|
||||||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
|
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */,
|
||||||
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
|
DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */,
|
||||||
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
|
DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */,
|
||||||
|
@ -2135,6 +2184,7 @@
|
||||||
5D0393902612D259007FE196 /* WebViewController.swift in Sources */,
|
5D0393902612D259007FE196 /* WebViewController.swift in Sources */,
|
||||||
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
|
DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */,
|
||||||
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
|
DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */,
|
||||||
|
0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */,
|
||||||
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
|
DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */,
|
||||||
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
|
DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */,
|
||||||
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,
|
DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */,
|
||||||
|
@ -2176,6 +2226,7 @@
|
||||||
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */,
|
||||||
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */,
|
||||||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||||
|
0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */,
|
||||||
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */,
|
||||||
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */,
|
||||||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
||||||
|
@ -2206,6 +2257,7 @@
|
||||||
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
|
DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */,
|
||||||
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
|
DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */,
|
||||||
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
|
2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */,
|
||||||
|
0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */,
|
||||||
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
|
DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */,
|
||||||
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
|
5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */,
|
||||||
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
|
DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */,
|
||||||
|
@ -2213,7 +2265,9 @@
|
||||||
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
|
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */,
|
||||||
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */,
|
2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */,
|
||||||
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
|
2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */,
|
||||||
|
0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */,
|
||||||
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
|
2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */,
|
||||||
|
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */,
|
||||||
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
|
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
|
||||||
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
|
DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */,
|
||||||
2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */,
|
2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */,
|
||||||
|
@ -2222,11 +2276,13 @@
|
||||||
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
|
DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */,
|
||||||
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
|
2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */,
|
||||||
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
|
2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */,
|
||||||
|
0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */,
|
||||||
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
|
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
|
||||||
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
|
2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */,
|
||||||
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
|
2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */,
|
||||||
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
|
DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */,
|
||||||
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
|
DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */,
|
||||||
|
0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */,
|
||||||
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
|
DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */,
|
||||||
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */,
|
||||||
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */,
|
||||||
|
|
|
@ -51,6 +51,9 @@ extension SceneCoordinator {
|
||||||
// compose
|
// compose
|
||||||
case compose(viewModel: ComposeViewModel)
|
case compose(viewModel: ComposeViewModel)
|
||||||
|
|
||||||
|
// Hashtag Timeline
|
||||||
|
case hashtagTimeline(viewModel: HashtagTimelineViewModel)
|
||||||
|
|
||||||
// profile
|
// profile
|
||||||
case profile(viewModel: ProfileViewModel)
|
case profile(viewModel: ProfileViewModel)
|
||||||
|
|
||||||
|
@ -222,6 +225,10 @@ private extension SceneCoordinator {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewController = alertController
|
viewController = alertController
|
||||||
|
case .hashtagTimeline(let viewModel):
|
||||||
|
let _viewController = HashtagTimelineViewController()
|
||||||
|
_viewController.viewModel = viewModel
|
||||||
|
viewController = _viewController
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
case .publicTimeline:
|
case .publicTimeline:
|
||||||
let _viewController = PublicTimelineViewController()
|
let _viewController = PublicTimelineViewController()
|
||||||
|
|
|
@ -25,7 +25,7 @@ final class StatusFetchedResultsController: NSObject {
|
||||||
// output
|
// output
|
||||||
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
||||||
|
|
||||||
init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) {
|
init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) {
|
||||||
self.domain.value = domain ?? ""
|
self.domain.value = domain ?? ""
|
||||||
self.fetchedResultsController = {
|
self.fetchedResultsController = {
|
||||||
let fetchRequest = Status.sortedFetchRequest
|
let fetchRequest = Status.sortedFetchRequest
|
||||||
|
@ -52,10 +52,11 @@ final class StatusFetchedResultsController: NSObject {
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] domain, ids in
|
.sink { [weak self] domain, ids in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
var predicates = [Status.predicate(domain: domain ?? "", ids: ids)]
|
||||||
Status.predicate(domain: domain ?? "", ids: ids),
|
if let additionalPredicate = additionalTweetPredicate {
|
||||||
additionalTweetPredicate
|
predicates.append(additionalPredicate)
|
||||||
])
|
}
|
||||||
|
self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||||
do {
|
do {
|
||||||
try self.fetchedResultsController.performFetch()
|
try self.fetchedResultsController.performFetch()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
//
|
||||||
|
// Array+removeDuplicates.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array
|
||||||
|
extension Array where Element: Hashable {
|
||||||
|
func removingDuplicates() -> [Element] {
|
||||||
|
var addedDict = [Element: Bool]()
|
||||||
|
|
||||||
|
return filter {
|
||||||
|
addedDict.updateValue(true, forKey: $0) == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeDuplicates() {
|
||||||
|
self = self.removingDuplicates()
|
||||||
|
}
|
||||||
|
}
|
|
@ -267,6 +267,12 @@ internal enum L10n {
|
||||||
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title")
|
internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
internal enum Hashtag {
|
||||||
|
/// %@ people talking
|
||||||
|
internal static func prompt(_ p1: Any) -> String {
|
||||||
|
return L10n.tr("Localizable", "Scene.Hashtag.Prompt", String(describing: p1))
|
||||||
|
}
|
||||||
|
}
|
||||||
internal enum HomeTimeline {
|
internal enum HomeTimeline {
|
||||||
/// Home
|
/// Home
|
||||||
internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title")
|
internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title")
|
||||||
|
|
|
@ -205,3 +205,21 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - ActiveLabel didSelect ActiveEntity
|
||||||
|
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||||
|
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity) {
|
||||||
|
switch entity.type {
|
||||||
|
case .hashtag(let hashtag, let userInfo):
|
||||||
|
let hashtagTimelienViewModel = HashtagTimelineViewModel(context: context, hashTag: hashtag)
|
||||||
|
coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: self, transition: .show)
|
||||||
|
break
|
||||||
|
case .email(let content, let userInfo):
|
||||||
|
break
|
||||||
|
case .mention(let mention, let userInfo):
|
||||||
|
break
|
||||||
|
case .url(let content, let trimmed, let url, let userInfo):
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -88,6 +88,7 @@ uploaded to Mastodon.";
|
||||||
"Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@,
|
"Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@,
|
||||||
tap the link to confirm your account.";
|
tap the link to confirm your account.";
|
||||||
"Scene.ConfirmEmail.Title" = "One last thing.";
|
"Scene.ConfirmEmail.Title" = "One last thing.";
|
||||||
|
"Scene.Hashtag.Prompt" = "%@ people talking";
|
||||||
"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts";
|
"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts";
|
||||||
"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline";
|
"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline";
|
||||||
"Scene.HomeTimeline.NavigationBarState.Published" = "Published!";
|
"Scene.HomeTimeline.NavigationBarState.Published" = "Published!";
|
||||||
|
|
|
@ -56,6 +56,9 @@ final class ComposeViewModel {
|
||||||
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
|
let isPollToolbarButtonEnabled = CurrentValueSubject<Bool, Never>(true)
|
||||||
let characterCount = CurrentValueSubject<Int, Never>(0)
|
let characterCount = CurrentValueSubject<Int, Never>(0)
|
||||||
|
|
||||||
|
// In some specific scenes(hashtag scene e.g.), we need to display the compose scene with pre-inserted text(insert '#mastodon ' in #mastodon hashtag scene, e.g.), the pre-inserted text should be treated as mannually inputed by users.
|
||||||
|
var preInsertedContent: String? = nil
|
||||||
|
|
||||||
// custom emojis
|
// custom emojis
|
||||||
var customEmojiViewModelSubscription: AnyCancellable?
|
var customEmojiViewModelSubscription: AnyCancellable?
|
||||||
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
|
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
|
||||||
|
@ -71,10 +74,12 @@ final class ComposeViewModel {
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
composeKind: ComposeStatusSection.ComposeKind
|
composeKind: ComposeStatusSection.ComposeKind,
|
||||||
|
preInsertedContent: String? = nil
|
||||||
) {
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.composeKind = composeKind
|
self.composeKind = composeKind
|
||||||
|
self.preInsertedContent = preInsertedContent
|
||||||
switch composeKind {
|
switch composeKind {
|
||||||
case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost)
|
case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost)
|
||||||
case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
|
case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply)
|
||||||
|
@ -195,9 +200,16 @@ final class ComposeViewModel {
|
||||||
// bind modal dismiss state
|
// bind modal dismiss state
|
||||||
composeStatusAttribute.composeContent
|
composeStatusAttribute.composeContent
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.map { content in
|
.map { [weak self] content in
|
||||||
let content = content ?? ""
|
let content = content ?? ""
|
||||||
return content.isEmpty
|
if content.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// if preInsertedContent plus a space is equal to the content, simply dismiss the modal
|
||||||
|
if let preInsertedContent = self?.preInsertedContent {
|
||||||
|
return content == (preInsertedContent + " ")
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
.assign(to: \.value, on: shouldDismiss)
|
.assign(to: \.value, on: shouldDismiss)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
@ -304,6 +316,11 @@ final class ComposeViewModel {
|
||||||
self.isPollToolbarButtonEnabled.value = !shouldPollDisable
|
self.isPollToolbarButtonEnabled.value = !shouldPollDisable
|
||||||
})
|
})
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
if let preInsertedContent = preInsertedContent {
|
||||||
|
// add a space after the injected text
|
||||||
|
composeStatusAttribute.composeContent.send(preInsertedContent + " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewController+StatusProvider.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
// MARK: - StatusProvider
|
||||||
|
extension HashtagTimelineViewController: StatusProvider {
|
||||||
|
|
||||||
|
func status() -> Future<Status?, Never> {
|
||||||
|
return Future { promise in promise(.success(nil)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Status?, Never> {
|
||||||
|
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),
|
||||||
|
let item = diffableDataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
promise(.success(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item {
|
||||||
|
case .status(let objectID, _):
|
||||||
|
let managedObjectContext = self.viewModel.context.managedObjectContext
|
||||||
|
managedObjectContext.perform {
|
||||||
|
let status = managedObjectContext.object(with: objectID) as? Status
|
||||||
|
promise(.success(status))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
promise(.success(nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(for cell: UICollectionViewCell) -> Future<Status?, Never> {
|
||||||
|
return Future { promise in promise(.success(nil)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedObjectContext: NSManagedObjectContext {
|
||||||
|
return viewModel.context.managedObjectContext
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,317 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewController.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import AVKit
|
||||||
|
import Combine
|
||||||
|
import GameplayKit
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
class HashtagTimelineViewController: UIViewController, NeedsDependency {
|
||||||
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
var viewModel: HashtagTimelineViewModel!
|
||||||
|
|
||||||
|
let composeBarButtonItem: UIBarButtonItem = {
|
||||||
|
let barButtonItem = UIBarButtonItem()
|
||||||
|
barButtonItem.tintColor = Asset.Colors.Label.highlight.color
|
||||||
|
barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
return barButtonItem
|
||||||
|
}()
|
||||||
|
|
||||||
|
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.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
|
||||||
|
return tableView
|
||||||
|
}()
|
||||||
|
|
||||||
|
let refreshControl = UIRefreshControl()
|
||||||
|
|
||||||
|
let titleView = HashtagTimelineNavigationBarTitleView()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewController {
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
title = "#\(viewModel.hashTag)"
|
||||||
|
titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: nil)
|
||||||
|
navigationItem.titleView = titleView
|
||||||
|
|
||||||
|
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
|
|
||||||
|
navigationItem.rightBarButtonItem = composeBarButtonItem
|
||||||
|
|
||||||
|
composeBarButtonItem.target = self
|
||||||
|
composeBarButtonItem.action = #selector(HashtagTimelineViewController.composeBarButtonItemPressed(_:))
|
||||||
|
|
||||||
|
tableView.refreshControl = refreshControl
|
||||||
|
refreshControl.addTarget(self, action: #selector(HashtagTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
|
||||||
|
|
||||||
|
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.tableView = tableView
|
||||||
|
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.prefetchDataSource = self
|
||||||
|
viewModel.setupDiffableDataSource(
|
||||||
|
for: tableView,
|
||||||
|
dependency: self,
|
||||||
|
statusTableViewCellDelegate: self,
|
||||||
|
timelineMiddleLoaderTableViewCellDelegate: self
|
||||||
|
)
|
||||||
|
|
||||||
|
// bind refresh control
|
||||||
|
viewModel.isFetchingLatestTimeline
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isFetching in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if !isFetching {
|
||||||
|
UIView.animate(withDuration: 0.5) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.refreshControl.endRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
viewModel.hashtagEntity
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] tag in
|
||||||
|
self?.updatePromptTitle()
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
viewModel.fetchTag()
|
||||||
|
guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return }
|
||||||
|
|
||||||
|
refreshControl.beginRefreshing()
|
||||||
|
refreshControl.sendActions(for: .valueChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
context.videoPlaybackService.viewDidDisappear(from: self)
|
||||||
|
context.audioPlaybackService.viewDidDisappear(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
super.viewWillTransition(to: size, with: coordinator)
|
||||||
|
|
||||||
|
coordinator.animate { _ in
|
||||||
|
// do nothing
|
||||||
|
} completion: { _ in
|
||||||
|
// fix AutoLayout cell height not update after rotate issue
|
||||||
|
self.viewModel.cellFrameCache.removeAllObjects()
|
||||||
|
self.tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePromptTitle() {
|
||||||
|
var subtitle: String?
|
||||||
|
defer {
|
||||||
|
titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: subtitle)
|
||||||
|
}
|
||||||
|
guard let histories = viewModel.hashtagEntity.value?.history else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if histories.isEmpty {
|
||||||
|
// No tag history, remove the prompt title
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
let sortedHistory = histories.sorted { (h1, h2) -> Bool in
|
||||||
|
return h1.day > h2.day
|
||||||
|
}
|
||||||
|
let peopleTalkingNumber = sortedHistory
|
||||||
|
.prefix(2)
|
||||||
|
.compactMap({ Int($0.accounts) })
|
||||||
|
.reduce(0, +)
|
||||||
|
subtitle = "\(peopleTalkingNumber)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewController {
|
||||||
|
|
||||||
|
@objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashTag)")
|
||||||
|
coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshControlValueChanged(_ sender: UIRefreshControl) {
|
||||||
|
guard viewModel.loadLatestStateMachine.enter(HashtagTimelineViewModel.LoadLatestState.Loading.self) else {
|
||||||
|
sender.endRefreshing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIScrollViewDelegate
|
||||||
|
extension HashtagTimelineViewController {
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
handleScrollViewDidScroll(scrollView)
|
||||||
|
// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer {
|
||||||
|
typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell
|
||||||
|
typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading
|
||||||
|
var loadMoreConfigurableTableView: UITableView { return tableView }
|
||||||
|
var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDelegate
|
||||||
|
extension HashtagTimelineViewController: UITableViewDelegate {
|
||||||
|
|
||||||
|
// TODO:
|
||||||
|
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||||
|
// 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)
|
||||||
|
// }
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate
|
||||||
|
extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate {
|
||||||
|
func navigationBar() -> UINavigationBar? {
|
||||||
|
return navigationController?.navigationBar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - UITableViewDataSourcePrefetching
|
||||||
|
extension HashtagTimelineViewController: UITableViewDataSourcePrefetching {
|
||||||
|
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
||||||
|
handleTableView(tableView, prefetchRowsAt: indexPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TimelineMiddleLoaderTableViewCellDelegate
|
||||||
|
extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate {
|
||||||
|
func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) {
|
||||||
|
guard let upperTimelineIndexObjectID = timelineIndexobjectID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModel.loadMiddleSateMachineList
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] ids in
|
||||||
|
guard let _ = self else { return }
|
||||||
|
if let stateMachine = ids[upperTimelineIndexObjectID] {
|
||||||
|
guard let state = stateMachine.currentState else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// make success state same as loading due to snapshot updating delay
|
||||||
|
let isLoading = state is HashtagTimelineViewModel.LoadMiddleState.Loading || state is HashtagTimelineViewModel.LoadMiddleState.Success
|
||||||
|
if isLoading {
|
||||||
|
cell.startAnimating()
|
||||||
|
} else {
|
||||||
|
cell.stopAnimating()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cell.stopAnimating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cell.disposeBag)
|
||||||
|
|
||||||
|
var dict = viewModel.loadMiddleSateMachineList.value
|
||||||
|
if let _ = dict[upperTimelineIndexObjectID] {
|
||||||
|
// do nothing
|
||||||
|
} else {
|
||||||
|
let stateMachine = GKStateMachine(states: [
|
||||||
|
HashtagTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
|
||||||
|
HashtagTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
|
||||||
|
HashtagTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
|
||||||
|
HashtagTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID),
|
||||||
|
])
|
||||||
|
stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Initial.self)
|
||||||
|
dict[upperTimelineIndexObjectID] = stateMachine
|
||||||
|
viewModel.loadMiddleSateMachineList.value = dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, 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 }
|
||||||
|
|
||||||
|
switch item {
|
||||||
|
case .homeMiddleLoader(let upper):
|
||||||
|
guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Loading.self)
|
||||||
|
default:
|
||||||
|
assertionFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVPlayerViewControllerDelegate
|
||||||
|
extension HashtagTimelineViewController: AVPlayerViewControllerDelegate {
|
||||||
|
|
||||||
|
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - StatusTableViewCellDelegate
|
||||||
|
extension HashtagTimelineViewController: StatusTableViewCellDelegate {
|
||||||
|
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
|
||||||
|
func parent() -> UIViewController { return self }
|
||||||
|
}
|
|
@ -0,0 +1,124 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel+Diffable.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
func setupDiffableDataSource(
|
||||||
|
for tableView: UITableView,
|
||||||
|
dependency: NeedsDependency,
|
||||||
|
statusTableViewCellDelegate: StatusTableViewCellDelegate,
|
||||||
|
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate
|
||||||
|
) {
|
||||||
|
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: timelineMiddleLoaderTableViewCellDelegate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compare old & new snapshots and generate new items
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
func generateStatusItems(newObjectIDs: [NSManagedObjectID]) {
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
|
||||||
|
guard let tableView = self.tableView else { return }
|
||||||
|
guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return }
|
||||||
|
|
||||||
|
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||||
|
|
||||||
|
let parentManagedObjectContext = fetchedResultsController.fetchedResultsController.managedObjectContext
|
||||||
|
let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||||
|
managedObjectContext.parent = parentManagedObjectContext
|
||||||
|
|
||||||
|
let oldSnapshot = diffableDataSource.snapshot()
|
||||||
|
// let snapshot = snapshot as NSDiffableDataSourceSnapshot<Int, NSManagedObjectID>
|
||||||
|
|
||||||
|
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
|
||||||
|
for item in oldSnapshot.itemIdentifiers {
|
||||||
|
guard case let .status(objectID, attribute) = item else { continue }
|
||||||
|
oldSnapshotAttributeDict[objectID] = attribute
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusItemList: [Item] = newObjectIDs.map {
|
||||||
|
let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute()
|
||||||
|
return Item.status(objectID: $0, attribute: attribute)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newSnapshot = NSDiffableDataSourceSnapshot<StatusSection, Item>()
|
||||||
|
newSnapshot.appendSections([.main])
|
||||||
|
|
||||||
|
// Check if there is a `needLoadMiddleIndex`
|
||||||
|
if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) {
|
||||||
|
// If yes, insert a `middleLoader` at the index
|
||||||
|
var newItems = statusItemList
|
||||||
|
newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: newObjectIDs[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1))
|
||||||
|
newSnapshot.appendItems(newItems, toSection: .main)
|
||||||
|
} else {
|
||||||
|
newSnapshot.appendItems(statusItemList, toSection: .main)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) {
|
||||||
|
newSnapshot.appendItems([.bottomLoader], toSection: .main)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else {
|
||||||
|
diffableDataSource.apply(newSnapshot)
|
||||||
|
self.isFetchingLatestTimeline.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
diffableDataSource.apply(newSnapshot, animatingDifferences: false) {
|
||||||
|
tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false)
|
||||||
|
tableView.contentOffset.y = tableView.contentOffset.y - difference.offset
|
||||||
|
self.isFetchingLatestTimeline.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Difference<T> {
|
||||||
|
let targetIndexPath: IndexPath
|
||||||
|
let offset: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateReloadSnapshotDifference<T: Hashable>(
|
||||||
|
navigationBar: UINavigationBar,
|
||||||
|
tableView: UITableView,
|
||||||
|
oldSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>,
|
||||||
|
newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, T>
|
||||||
|
) -> Difference<T>? {
|
||||||
|
guard oldSnapshot.numberOfItems != 0 else { return nil }
|
||||||
|
guard let item = oldSnapshot.itemIdentifiers.first as? Item, case Item.status = item else { return nil }
|
||||||
|
|
||||||
|
let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first!
|
||||||
|
|
||||||
|
guard let oldItemBeginIndexInNewSnapshot = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: oldItemAtBeginning) else { return nil }
|
||||||
|
|
||||||
|
if oldItemBeginIndexInNewSnapshot > 0 {
|
||||||
|
let targetIndexPath = IndexPath(row: oldItemBeginIndexInNewSnapshot, section: 0)
|
||||||
|
let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: IndexPath(row: 0, section: 0), navigationBar: navigationBar)
|
||||||
|
return Difference(
|
||||||
|
targetIndexPath: targetIndexPath,
|
||||||
|
offset: offset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,104 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel+LoadLatestState.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import GameplayKit
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
class LoadLatestState: GKState {
|
||||||
|
weak var viewModel: HashtagTimelineViewModel?
|
||||||
|
|
||||||
|
init(viewModel: HashtagTimelineViewModel) {
|
||||||
|
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)
|
||||||
|
viewModel?.loadLatestStateMachinePublisher.send(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
class Initial: HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Loading: HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Fail.self || stateClass == Idle.self
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
// sign out when loading will enter here
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: only set large count when using Wi-Fi
|
||||||
|
viewModel.context.apiService.hashtagTimeline(
|
||||||
|
domain: activeMastodonAuthenticationBox.domain,
|
||||||
|
hashtag: viewModel.hashTag,
|
||||||
|
authorizationBox: activeMastodonAuthenticationBox)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
// TODO: handle error
|
||||||
|
viewModel.isFetchingLatestTimeline.value = false
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statues failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
stateMachine.enter(Idle.self)
|
||||||
|
|
||||||
|
} receiveValue: { response in
|
||||||
|
let newStatusIDList = response.value.map { $0.id }
|
||||||
|
|
||||||
|
// When response data:
|
||||||
|
// 1. is not empty
|
||||||
|
// 2. last status are not recorded
|
||||||
|
// Then we may have middle data to load
|
||||||
|
var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value
|
||||||
|
if !oldStatusIDs.isEmpty, let lastNewStatusID = newStatusIDList.last,
|
||||||
|
!oldStatusIDs.contains(lastNewStatusID) {
|
||||||
|
viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1)
|
||||||
|
} else {
|
||||||
|
viewModel.needLoadMiddleIndex = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
oldStatusIDs.insert(contentsOf: newStatusIDList, at: 0)
|
||||||
|
let newIDs = oldStatusIDs.removingDuplicates()
|
||||||
|
|
||||||
|
viewModel.fetchedResultsController.statusIDs.value = newIDs
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fail: HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self || stateClass == Idle.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Idle: HashtagTimelineViewModel.LoadLatestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel+LoadMiddleState.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import GameplayKit
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
class LoadMiddleState: GKState {
|
||||||
|
weak var viewModel: HashtagTimelineViewModel?
|
||||||
|
let upperStatusObjectID: NSManagedObjectID
|
||||||
|
|
||||||
|
init(viewModel: HashtagTimelineViewModel, upperStatusObjectID: NSManagedObjectID) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.upperStatusObjectID = upperStatusObjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
var dict = viewModel.loadMiddleSateMachineList.value
|
||||||
|
dict[upperStatusObjectID] = stateMachine
|
||||||
|
viewModel.loadMiddleSateMachineList.value = dict // trigger value change
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
|
||||||
|
class Initial: HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Loading: HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
// guard let viewModel = viewModel else { return false }
|
||||||
|
return stateClass == Success.self || stateClass == Fail.self
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else {
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let statusIDs = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in
|
||||||
|
status.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: only set large count when using Wi-Fi
|
||||||
|
let maxID = upperStatusObject.id
|
||||||
|
viewModel.context.apiService.hashtagTimeline(
|
||||||
|
domain: activeMastodonAuthenticationBox.domain,
|
||||||
|
maxID: maxID,
|
||||||
|
hashtag: viewModel.hashTag,
|
||||||
|
authorizationBox: activeMastodonAuthenticationBox)
|
||||||
|
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { completion in
|
||||||
|
// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
// TODO: handle error
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
case .finished:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { response in
|
||||||
|
stateMachine.enter(Success.self)
|
||||||
|
|
||||||
|
let newStatusIDList = response.value.map { $0.id }
|
||||||
|
|
||||||
|
var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value
|
||||||
|
if let indexToInsert = oldStatusIDs.firstIndex(of: maxID) {
|
||||||
|
// When response data:
|
||||||
|
// 1. is not empty
|
||||||
|
// 2. last status are not recorded
|
||||||
|
// Then we may have middle data to load
|
||||||
|
if let lastNewStatusID = newStatusIDList.last,
|
||||||
|
!oldStatusIDs.contains(lastNewStatusID) {
|
||||||
|
viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count
|
||||||
|
} else {
|
||||||
|
viewModel.needLoadMiddleIndex = nil
|
||||||
|
}
|
||||||
|
oldStatusIDs.insert(contentsOf: newStatusIDList, at: indexToInsert + 1)
|
||||||
|
oldStatusIDs.removeDuplicates()
|
||||||
|
} else {
|
||||||
|
// Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index
|
||||||
|
// Then there is no need to set a `loadMiddleState` cell
|
||||||
|
viewModel.needLoadMiddleIndex = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.fetchedResultsController.statusIDs.value = oldStatusIDs
|
||||||
|
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fail: HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
// guard let viewModel = viewModel else { return false }
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Success: HashtagTimelineViewModel.LoadMiddleState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
// guard let viewModel = viewModel else { return false }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel+LoadOldestState.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import GameplayKit
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel {
|
||||||
|
class LoadOldestState: GKState {
|
||||||
|
weak var viewModel: HashtagTimelineViewModel?
|
||||||
|
|
||||||
|
init(viewModel: HashtagTimelineViewModel) {
|
||||||
|
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)
|
||||||
|
viewModel?.loadOldestStateMachinePublisher.send(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
class Initial: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
guard let viewModel = viewModel else { return false }
|
||||||
|
guard !(viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false }
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Loading: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
assertionFailure()
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let last = viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects?.last else {
|
||||||
|
stateMachine.enter(Idle.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: only set large count when using Wi-Fi
|
||||||
|
let maxID = last.id
|
||||||
|
viewModel.context.apiService.hashtagTimeline(
|
||||||
|
domain: activeMastodonAuthenticationBox.domain,
|
||||||
|
maxID: maxID,
|
||||||
|
hashtag: viewModel.hashTag,
|
||||||
|
authorizationBox: activeMastodonAuthenticationBox)
|
||||||
|
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { completion in
|
||||||
|
// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
|
||||||
|
switch completion {
|
||||||
|
case .failure(let error):
|
||||||
|
os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||||
|
case .finished:
|
||||||
|
// handle isFetchingLatestTimeline in fetch controller delegate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} receiveValue: { response in
|
||||||
|
let statuses = response.value
|
||||||
|
// enter no more state when no new statuses
|
||||||
|
if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) {
|
||||||
|
stateMachine.enter(NoMore.self)
|
||||||
|
} else {
|
||||||
|
stateMachine.enter(Idle.self)
|
||||||
|
}
|
||||||
|
var newStatusIDs = viewModel.fetchedResultsController.statusIDs.value
|
||||||
|
let fetchedStatusIDList = statuses.map { $0.id }
|
||||||
|
newStatusIDs.append(contentsOf: fetchedStatusIDList)
|
||||||
|
viewModel.fetchedResultsController.statusIDs.value = newStatusIDs
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.disposeBag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fail: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self || stateClass == Idle.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Idle: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
return stateClass == Loading.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoMore: HashtagTimelineViewModel.LoadOldestState {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
// reset state if needs
|
||||||
|
return stateClass == Idle.self
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
guard let viewModel = viewModel else { return }
|
||||||
|
guard let diffableDataSource = viewModel.diffableDataSource else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var snapshot = diffableDataSource.snapshot()
|
||||||
|
snapshot.deleteItems([.bottomLoader])
|
||||||
|
diffableDataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,105 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineViewModel.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import GameplayKit
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
final class HashtagTimelineViewModel: NSObject {
|
||||||
|
|
||||||
|
let hashTag: String
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
var needLoadMiddleIndex: Int? = nil
|
||||||
|
|
||||||
|
// input
|
||||||
|
let context: AppContext
|
||||||
|
let fetchedResultsController: StatusFetchedResultsController
|
||||||
|
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
let timelinePredicate = CurrentValueSubject<NSPredicate?, Never>(nil)
|
||||||
|
let hashtagEntity = CurrentValueSubject<Mastodon.Entity.Tag?, Never>(nil)
|
||||||
|
|
||||||
|
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
|
||||||
|
weak var tableView: UITableView?
|
||||||
|
|
||||||
|
// output
|
||||||
|
// top loader
|
||||||
|
private(set) lazy var loadLatestStateMachine: GKStateMachine = {
|
||||||
|
// exclude timeline middle fetcher state
|
||||||
|
let stateMachine = GKStateMachine(states: [
|
||||||
|
LoadLatestState.Initial(viewModel: self),
|
||||||
|
LoadLatestState.Loading(viewModel: self),
|
||||||
|
LoadLatestState.Fail(viewModel: self),
|
||||||
|
LoadLatestState.Idle(viewModel: self),
|
||||||
|
])
|
||||||
|
stateMachine.enter(LoadLatestState.Initial.self)
|
||||||
|
return stateMachine
|
||||||
|
}()
|
||||||
|
lazy var loadLatestStateMachinePublisher = CurrentValueSubject<LoadLatestState?, Never>(nil)
|
||||||
|
// bottom loader
|
||||||
|
private(set) lazy var loadoldestStateMachine: GKStateMachine = {
|
||||||
|
// exclude timeline middle fetcher state
|
||||||
|
let stateMachine = GKStateMachine(states: [
|
||||||
|
LoadOldestState.Initial(viewModel: self),
|
||||||
|
LoadOldestState.Loading(viewModel: self),
|
||||||
|
LoadOldestState.Fail(viewModel: self),
|
||||||
|
LoadOldestState.Idle(viewModel: self),
|
||||||
|
LoadOldestState.NoMore(viewModel: self),
|
||||||
|
])
|
||||||
|
stateMachine.enter(LoadOldestState.Initial.self)
|
||||||
|
return stateMachine
|
||||||
|
}()
|
||||||
|
lazy var loadOldestStateMachinePublisher = CurrentValueSubject<LoadOldestState?, Never>(nil)
|
||||||
|
// middle loader
|
||||||
|
let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine
|
||||||
|
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
|
||||||
|
var cellFrameCache = NSCache<NSNumber, NSValue>()
|
||||||
|
|
||||||
|
|
||||||
|
init(context: AppContext, hashTag: String) {
|
||||||
|
self.context = context
|
||||||
|
self.hashTag = hashTag
|
||||||
|
let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value
|
||||||
|
self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil)
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
fetchedResultsController.objectIDs
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] objectIds in
|
||||||
|
self?.generateStatusItems(newObjectIDs: objectIds)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTag() {
|
||||||
|
guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let query = Mastodon.API.Search.Query(q: hashTag, type: .hashtags)
|
||||||
|
context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||||
|
.sink { _ in
|
||||||
|
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
let matchedTag = response.value.hashtags.first { tag -> Bool in
|
||||||
|
return tag.name == self?.hashTag
|
||||||
|
}
|
||||||
|
self?.hashtagEntity.send(matchedTag)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
//
|
||||||
|
// HashtagTimelineTitleView.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/4/1.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class HashtagTimelineNavigationBarTitleView: UIView {
|
||||||
|
|
||||||
|
let containerView = UIStackView()
|
||||||
|
|
||||||
|
let titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
|
label.textAlignment = .center
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
let subtitleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .systemFont(ofSize: 12)
|
||||||
|
label.textColor = Asset.Colors.Label.secondary.color
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.isHidden = true
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashtagTimelineNavigationBarTitleView {
|
||||||
|
private func _init() {
|
||||||
|
containerView.axis = .vertical
|
||||||
|
containerView.alignment = .center
|
||||||
|
containerView.distribution = .fill
|
||||||
|
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(containerView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
containerView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
containerView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
containerView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
containerView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
containerView.addArrangedSubview(titleLabel)
|
||||||
|
containerView.addArrangedSubview(subtitleLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTitle(hashtag: String, peopleNumber: String?) {
|
||||||
|
titleLabel.text = "#\(hashtag)"
|
||||||
|
if let peopleNumebr = peopleNumber {
|
||||||
|
subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr)
|
||||||
|
subtitleLabel.isHidden = false
|
||||||
|
} else {
|
||||||
|
subtitleLabel.text = nil
|
||||||
|
subtitleLabel.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -222,6 +222,6 @@ extension WelcomeViewController: OnboardingViewControllerAppearance { }
|
||||||
extension WelcomeViewController: UIAdaptivePresentationControllerDelegate {
|
extension WelcomeViewController: UIAdaptivePresentationControllerDelegate {
|
||||||
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
|
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
|
||||||
// make underneath view controller alive to fix layout issue due to view life cycle
|
// make underneath view controller alive to fix layout issue due to view life cycle
|
||||||
return .overFullScreen
|
return .fullScreen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -258,5 +258,12 @@ extension UserTimelineViewModel.State {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
|
||||||
|
viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ protocol StatusViewDelegate: class {
|
||||||
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
|
func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
|
||||||
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||||
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
||||||
|
func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class StatusView: UIView {
|
final class StatusView: UIView {
|
||||||
|
@ -403,6 +404,7 @@ extension StatusView {
|
||||||
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
|
statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false
|
||||||
|
|
||||||
playerContainerView.delegate = self
|
playerContainerView.delegate = self
|
||||||
|
activeTextLabel.delegate = self
|
||||||
|
|
||||||
headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:)))
|
headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:)))
|
||||||
headerInfoLabel.isUserInteractionEnabled = true
|
headerInfoLabel.isUserInteractionEnabled = true
|
||||||
|
@ -491,6 +493,13 @@ extension StatusView: AvatarConfigurableView {
|
||||||
var configurableVerifiedBadgeImageView: UIImageView? { nil }
|
var configurableVerifiedBadgeImageView: UIImageView? { nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - ActiveLabelDelegate
|
||||||
|
extension StatusView: ActiveLabelDelegate {
|
||||||
|
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
|
||||||
|
delegate?.statusView(self, didSelectActiveEntity: activeLabel, entity: entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if canImport(SwiftUI) && DEBUG
|
#if canImport(SwiftUI) && DEBUG
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import AVKit
|
||||||
import Combine
|
import Combine
|
||||||
import CoreData
|
import CoreData
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
|
import ActiveLabel
|
||||||
|
|
||||||
protocol StatusTableViewCellDelegate: class {
|
protocol StatusTableViewCellDelegate: class {
|
||||||
var context: AppContext! { get }
|
var context: AppContext! { get }
|
||||||
|
@ -31,6 +32,8 @@ protocol StatusTableViewCellDelegate: class {
|
||||||
|
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
||||||
func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath)
|
func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath)
|
||||||
|
|
||||||
|
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StatusTableViewCellDelegate {
|
extension StatusTableViewCellDelegate {
|
||||||
|
@ -216,6 +219,10 @@ extension StatusTableViewCell: StatusViewDelegate {
|
||||||
delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button)
|
delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity) {
|
||||||
|
delegate?.statusTableViewCell(self, statusView: statusView, didSelectActiveEntity: entity)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MosaicImageViewDelegate
|
// MARK: - MosaicImageViewDelegate
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
//
|
||||||
|
// APIService+HashtagTimeline.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/3/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import CommonOSLog
|
||||||
|
import DateToolsSwift
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension APIService {
|
||||||
|
|
||||||
|
func hashtagTimeline(
|
||||||
|
domain: String,
|
||||||
|
sinceID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
maxID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
limit: Int = onceRequestStatusMaxCount,
|
||||||
|
local: Bool? = nil,
|
||||||
|
hashtag: String,
|
||||||
|
authorizationBox: AuthenticationService.MastodonAuthenticationBox
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||||
|
let authorization = authorizationBox.userAuthorization
|
||||||
|
let requestMastodonUserID = authorizationBox.userID
|
||||||
|
let query = Mastodon.API.Timeline.HashtagTimelineQuery(
|
||||||
|
maxID: maxID,
|
||||||
|
sinceID: sinceID,
|
||||||
|
minID: nil, // prefer sinceID
|
||||||
|
limit: limit,
|
||||||
|
local: local,
|
||||||
|
onlyMedia: false
|
||||||
|
)
|
||||||
|
|
||||||
|
return Mastodon.API.Timeline.hashtag(
|
||||||
|
session: session,
|
||||||
|
domain: domain,
|
||||||
|
query: query,
|
||||||
|
hashtag: hashtag,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
|
||||||
|
return APIService.Persist.persistStatus(
|
||||||
|
managedObjectContext: self.backgroundManagedObjectContext,
|
||||||
|
domain: domain,
|
||||||
|
query: query,
|
||||||
|
response: response,
|
||||||
|
persistType: .lookUp,
|
||||||
|
requestMastodonUserID: requestMastodonUserID,
|
||||||
|
log: OSLog.api
|
||||||
|
)
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
return response
|
||||||
|
case .failure(let error):
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -121,7 +121,7 @@ extension Mastodon.API.Favorites {
|
||||||
case destroy
|
case destroy
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ListQuery: GetQuery,TimelineQueryType {
|
public struct ListQuery: GetQuery, PagedQueryType {
|
||||||
|
|
||||||
public var limit: Int?
|
public var limit: Int?
|
||||||
public var minID: String?
|
public var minID: String?
|
||||||
|
|
|
@ -0,0 +1,125 @@
|
||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by BradGao on 2021/4/1.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
extension Mastodon.API.Notifications {
|
||||||
|
static func notificationsEndpointURL(domain: String) -> URL {
|
||||||
|
Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications")
|
||||||
|
}
|
||||||
|
static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL {
|
||||||
|
notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all notifications
|
||||||
|
///
|
||||||
|
/// - Since: 0.0.0
|
||||||
|
/// - Version: 3.1.0
|
||||||
|
/// # Last Update
|
||||||
|
/// 2021/4/1
|
||||||
|
/// # Reference
|
||||||
|
/// [Document](https://docs.joinmastodon.org/methods/notifications/)
|
||||||
|
/// - Parameters:
|
||||||
|
/// - session: `URLSession`
|
||||||
|
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||||
|
/// - query: `GetAllNotificationsQuery` with query parameters
|
||||||
|
/// - authorization: User token
|
||||||
|
/// - Returns: `AnyPublisher` contains `Token` nested in the response
|
||||||
|
public static func getAll(
|
||||||
|
session: URLSession,
|
||||||
|
domain: String,
|
||||||
|
query: GetAllNotificationsQuery,
|
||||||
|
authorization: Mastodon.API.OAuth.Authorization?
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Notification]>, Error> {
|
||||||
|
let request = Mastodon.API.get(
|
||||||
|
url: notificationsEndpointURL(domain: domain),
|
||||||
|
query: query,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
return session.dataTaskPublisher(for: request)
|
||||||
|
.tryMap { data, response in
|
||||||
|
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Notification].self, from: data, response: response)
|
||||||
|
return Mastodon.Response.Content(value: value, response: response)
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a single notification
|
||||||
|
///
|
||||||
|
/// - Since: 0.0.0
|
||||||
|
/// - Version: 3.1.0
|
||||||
|
/// # Last Update
|
||||||
|
/// 2021/4/1
|
||||||
|
/// # Reference
|
||||||
|
/// [Document](https://docs.joinmastodon.org/methods/notifications/)
|
||||||
|
/// - Parameters:
|
||||||
|
/// - session: `URLSession`
|
||||||
|
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||||
|
/// - notificationID: ID of the notification.
|
||||||
|
/// - authorization: User token
|
||||||
|
/// - Returns: `AnyPublisher` contains `Token` nested in the response
|
||||||
|
public static func get(
|
||||||
|
session: URLSession,
|
||||||
|
domain: String,
|
||||||
|
notificationID: String,
|
||||||
|
authorization: Mastodon.API.OAuth.Authorization?
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Notification>, Error> {
|
||||||
|
let request = Mastodon.API.get(
|
||||||
|
url: getNotificationEndpointURL(domain: domain, notificationID: notificationID),
|
||||||
|
query: nil,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
return session.dataTaskPublisher(for: request)
|
||||||
|
.tryMap { data, response in
|
||||||
|
let value = try Mastodon.API.decode(type: Mastodon.Entity.Notification.self, from: data, response: response)
|
||||||
|
return Mastodon.Response.Content(value: value, response: response)
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct GetAllNotificationsQuery: Codable, PagedQueryType, GetQuery {
|
||||||
|
public let maxID: Mastodon.Entity.Status.ID?
|
||||||
|
public let sinceID: Mastodon.Entity.Status.ID?
|
||||||
|
public let minID: Mastodon.Entity.Status.ID?
|
||||||
|
public let limit: Int?
|
||||||
|
public let excludeTypes: [String]?
|
||||||
|
public let accountID: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
maxID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
sinceID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
minID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
limit: Int? = nil,
|
||||||
|
excludeTypes: [String]? = nil,
|
||||||
|
accountID: String? = nil
|
||||||
|
) {
|
||||||
|
self.maxID = maxID
|
||||||
|
self.sinceID = sinceID
|
||||||
|
self.minID = minID
|
||||||
|
self.limit = limit
|
||||||
|
self.excludeTypes = excludeTypes
|
||||||
|
self.accountID = accountID
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryItems: [URLQueryItem]? {
|
||||||
|
var items: [URLQueryItem] = []
|
||||||
|
maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) }
|
||||||
|
sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) }
|
||||||
|
minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) }
|
||||||
|
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
|
||||||
|
if let excludeTypes = excludeTypes {
|
||||||
|
excludeTypes.forEach {
|
||||||
|
items.append(URLQueryItem(name: "exclude_types[]", value: $0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) }
|
||||||
|
guard !items.isEmpty else { return nil }
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -50,8 +50,21 @@ extension Mastodon.API.Search {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Mastodon.API.Search {
|
extension Mastodon.API.Search {
|
||||||
|
public enum SearchType: String, Codable {
|
||||||
|
case ccounts, hashtags, statuses
|
||||||
|
}
|
||||||
|
|
||||||
public struct Query: Codable, GetQuery {
|
public struct Query: Codable, GetQuery {
|
||||||
public init(accountID: Mastodon.Entity.Account.ID?, maxID: Mastodon.Entity.Status.ID?, minID: Mastodon.Entity.Status.ID?, type: String?, excludeUnreviewed: Bool?, q: String, resolve: Bool?, limit: Int?, offset: Int?, following: Bool?) {
|
public init(q: String,
|
||||||
|
type: SearchType? = nil,
|
||||||
|
accountID: Mastodon.Entity.Account.ID? = nil,
|
||||||
|
maxID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
minID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
excludeUnreviewed: Bool? = nil,
|
||||||
|
resolve: Bool? = nil,
|
||||||
|
limit: Int? = nil,
|
||||||
|
offset: Int? = nil,
|
||||||
|
following: Bool? = nil) {
|
||||||
self.accountID = accountID
|
self.accountID = accountID
|
||||||
self.maxID = maxID
|
self.maxID = maxID
|
||||||
self.minID = minID
|
self.minID = minID
|
||||||
|
@ -67,7 +80,7 @@ extension Mastodon.API.Search {
|
||||||
public let accountID: Mastodon.Entity.Account.ID?
|
public let accountID: Mastodon.Entity.Account.ID?
|
||||||
public let maxID: Mastodon.Entity.Status.ID?
|
public let maxID: Mastodon.Entity.Status.ID?
|
||||||
public let minID: Mastodon.Entity.Status.ID?
|
public let minID: Mastodon.Entity.Status.ID?
|
||||||
public let type: String?
|
public let type: SearchType?
|
||||||
public let excludeUnreviewed: Bool? // Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags.
|
public let excludeUnreviewed: Bool? // Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags.
|
||||||
public let q: String
|
public let q: String
|
||||||
public let resolve: Bool? // Attempt WebFinger lookup. Defaults to false.
|
public let resolve: Bool? // Attempt WebFinger lookup. Defaults to false.
|
||||||
|
@ -80,7 +93,7 @@ extension Mastodon.API.Search {
|
||||||
accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) }
|
accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) }
|
||||||
maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) }
|
maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) }
|
||||||
minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) }
|
minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) }
|
||||||
type.flatMap { items.append(URLQueryItem(name: "type", value: $0)) }
|
type.flatMap { items.append(URLQueryItem(name: "type", value: $0.rawValue)) }
|
||||||
excludeUnreviewed.flatMap { items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) }
|
excludeUnreviewed.flatMap { items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) }
|
||||||
items.append(URLQueryItem(name: "q", value: q))
|
items.append(URLQueryItem(name: "q", value: q))
|
||||||
resolve.flatMap { items.append(URLQueryItem(name: "resolve", value: $0.queryItemValue)) }
|
resolve.flatMap { items.append(URLQueryItem(name: "resolve", value: $0.queryItemValue)) }
|
||||||
|
|
|
@ -16,6 +16,10 @@ extension Mastodon.API.Timeline {
|
||||||
static func homeTimelineEndpointURL(domain: String) -> URL {
|
static func homeTimelineEndpointURL(domain: String) -> URL {
|
||||||
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/home")
|
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/home")
|
||||||
}
|
}
|
||||||
|
static func hashtagTimelineEndpointURL(domain: String, hashtag: String) -> URL {
|
||||||
|
return Mastodon.API.endpointURL(domain: domain)
|
||||||
|
.appendingPathComponent("timelines/tag/\(hashtag)")
|
||||||
|
}
|
||||||
|
|
||||||
/// View public timeline statuses
|
/// View public timeline statuses
|
||||||
///
|
///
|
||||||
|
@ -81,16 +85,50 @@ extension Mastodon.API.Timeline {
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// View public statuses containing the given hashtag.
|
||||||
|
///
|
||||||
|
/// - Since: 0.0.0
|
||||||
|
/// - Version: 3.3.0
|
||||||
|
/// # Last Update
|
||||||
|
/// 2021/3/29
|
||||||
|
/// # Reference
|
||||||
|
/// [Document](https://https://docs.joinmastodon.org/methods/timelines/)
|
||||||
|
/// - Parameters:
|
||||||
|
/// - session: `URLSession`
|
||||||
|
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||||
|
/// - query: `HashtagTimelineQuery` with query parameters
|
||||||
|
/// - hashtag: Content of a #hashtag, not including # symbol.
|
||||||
|
/// - authorization: User token, auth is required if public preview is disabled
|
||||||
|
/// - Returns: `AnyPublisher` contains `Token` nested in the response
|
||||||
|
public static func hashtag(
|
||||||
|
session: URLSession,
|
||||||
|
domain: String,
|
||||||
|
query: HashtagTimelineQuery,
|
||||||
|
hashtag: String,
|
||||||
|
authorization: Mastodon.API.OAuth.Authorization?
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||||
|
let request = Mastodon.API.get(
|
||||||
|
url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag),
|
||||||
|
query: query,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
return session.dataTaskPublisher(for: request)
|
||||||
|
.tryMap { data, response in
|
||||||
|
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response)
|
||||||
|
return Mastodon.Response.Content(value: value, response: response)
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol TimelineQueryType {
|
public protocol PagedQueryType {
|
||||||
var maxID: Mastodon.Entity.Status.ID? { get }
|
var maxID: Mastodon.Entity.Status.ID? { get }
|
||||||
var sinceID: Mastodon.Entity.Status.ID? { get }
|
var sinceID: Mastodon.Entity.Status.ID? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Mastodon.API.Timeline {
|
extension Mastodon.API.Timeline {
|
||||||
|
|
||||||
public typealias TimelineQuery = TimelineQueryType
|
public typealias TimelineQuery = PagedQueryType
|
||||||
|
|
||||||
public struct PublicTimelineQuery: Codable, TimelineQuery, GetQuery {
|
public struct PublicTimelineQuery: Codable, TimelineQuery, GetQuery {
|
||||||
|
|
||||||
|
@ -167,4 +205,41 @@ extension Mastodon.API.Timeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct HashtagTimelineQuery: Codable, TimelineQuery, GetQuery {
|
||||||
|
public let maxID: Mastodon.Entity.Status.ID?
|
||||||
|
public let sinceID: Mastodon.Entity.Status.ID?
|
||||||
|
public let minID: Mastodon.Entity.Status.ID?
|
||||||
|
public let limit: Int?
|
||||||
|
public let local: Bool?
|
||||||
|
public let onlyMedia: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
maxID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
sinceID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
minID: Mastodon.Entity.Status.ID? = nil,
|
||||||
|
limit: Int? = nil,
|
||||||
|
local: Bool? = nil,
|
||||||
|
onlyMedia: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.maxID = maxID
|
||||||
|
self.sinceID = sinceID
|
||||||
|
self.minID = minID
|
||||||
|
self.limit = limit
|
||||||
|
self.local = local
|
||||||
|
self.onlyMedia = onlyMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryItems: [URLQueryItem]? {
|
||||||
|
var items: [URLQueryItem] = []
|
||||||
|
maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) }
|
||||||
|
sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) }
|
||||||
|
minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) }
|
||||||
|
limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) }
|
||||||
|
local.flatMap { items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) }
|
||||||
|
onlyMedia.flatMap { items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) }
|
||||||
|
guard !items.isEmpty else { return nil }
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -114,6 +114,7 @@ extension Mastodon.API {
|
||||||
public enum Search { }
|
public enum Search { }
|
||||||
public enum Trends { }
|
public enum Trends { }
|
||||||
public enum Suggestions { }
|
public enum Suggestions { }
|
||||||
|
public enum Notifications { }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Mastodon.API {
|
extension Mastodon.API {
|
||||||
|
|
|
@ -8,9 +8,9 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
extension Mastodon.Entity {
|
extension Mastodon.Entity {
|
||||||
public struct SearchResult: Codable {
|
public struct SearchResult: Codable {
|
||||||
let accounts: [Mastodon.Entity.Account]
|
public let accounts: [Mastodon.Entity.Account]
|
||||||
let statuses: [Mastodon.Entity.Status]
|
public let statuses: [Mastodon.Entity.Status]
|
||||||
let hashtags: [Mastodon.Entity.Tag]
|
public let hashtags: [Mastodon.Entity.Tag]
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue