Merge pull request #474 from protolimit/feature/add-bookmarks
Add bookmarking and bookmarks view
This commit is contained in:
commit
28267fe6d8
|
@ -107,6 +107,13 @@
|
||||||
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; };
|
5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; };
|
||||||
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; };
|
5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; };
|
||||||
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
|
5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; };
|
||||||
|
6213AF5828939C4800BCADB6 /* APIService+Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213AF5728939C4700BCADB6 /* APIService+Bookmark.swift */; };
|
||||||
|
6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213AF5928939C8400BCADB6 /* BookmarkViewModel.swift */; };
|
||||||
|
6213AF5C28939C8A00BCADB6 /* BookmarkViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213AF5B28939C8A00BCADB6 /* BookmarkViewModel+State.swift */; };
|
||||||
|
6213AF5E2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213AF5D2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift */; };
|
||||||
|
62FD27D12893707600B205C5 /* BookmarkViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D02893707600B205C5 /* BookmarkViewController.swift */; };
|
||||||
|
62FD27D32893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D22893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift */; };
|
||||||
|
62FD27D52893708A00B205C5 /* BookmarkViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */; };
|
||||||
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
|
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
|
||||||
DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; };
|
DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; };
|
||||||
DB0009A726AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; };
|
DB0009A726AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; };
|
||||||
|
@ -824,6 +831,13 @@
|
||||||
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = "<group>"; };
|
5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = "<group>"; };
|
||||||
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = "<group>"; };
|
5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = "<group>"; };
|
||||||
6130CBE4B26E3C976ACC1688 /* Pods-ShareActionExtension.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.asdk - debug.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.asdk - debug.xcconfig"; sourceTree = "<group>"; };
|
6130CBE4B26E3C976ACC1688 /* Pods-ShareActionExtension.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareActionExtension.asdk - debug.xcconfig"; path = "Target Support Files/Pods-ShareActionExtension/Pods-ShareActionExtension.asdk - debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
6213AF5728939C4700BCADB6 /* APIService+Bookmark.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Bookmark.swift"; sourceTree = "<group>"; };
|
||||||
|
6213AF5928939C8400BCADB6 /* BookmarkViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
6213AF5B28939C8A00BCADB6 /* BookmarkViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BookmarkViewModel+State.swift"; sourceTree = "<group>"; };
|
||||||
|
6213AF5D2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Bookmark.swift"; sourceTree = "<group>"; };
|
||||||
|
62FD27D02893707600B205C5 /* BookmarkViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkViewController.swift; sourceTree = "<group>"; };
|
||||||
|
62FD27D22893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookmarkViewController+DataSourceProvider.swift"; sourceTree = "<group>"; };
|
||||||
|
62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookmarkViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||||
63EF9E6E5B575CD2A8B0475D /* Pods-AppShared.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.profile.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.profile.xcconfig"; sourceTree = "<group>"; };
|
63EF9E6E5B575CD2A8B0475D /* Pods-AppShared.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.profile.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
728DE51ADA27C395C6E1BAB5 /* Pods-Mastodon-MastodonUITests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.profile.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.profile.xcconfig"; sourceTree = "<group>"; };
|
728DE51ADA27C395C6E1BAB5 /* Pods-Mastodon-MastodonUITests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.profile.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; };
|
75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
@ -1934,6 +1948,18 @@
|
||||||
path = Webview;
|
path = Webview;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
62047EBE28874C8F00A3BA5D /* Bookmark */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
62FD27D02893707600B205C5 /* BookmarkViewController.swift */,
|
||||||
|
62FD27D22893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift */,
|
||||||
|
6213AF5928939C8400BCADB6 /* BookmarkViewModel.swift */,
|
||||||
|
62FD27D42893708A00B205C5 /* BookmarkViewModel+Diffable.swift */,
|
||||||
|
6213AF5B28939C8A00BCADB6 /* BookmarkViewModel+State.swift */,
|
||||||
|
);
|
||||||
|
path = Bookmark;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
DB01409B25C40BB600F9F3CF /* Onboarding */ = {
|
DB01409B25C40BB600F9F3CF /* Onboarding */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -2332,6 +2358,7 @@
|
||||||
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
|
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */,
|
||||||
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */,
|
5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */,
|
||||||
DB9D7C20269824B80054B3DF /* APIService+Filter.swift */,
|
DB9D7C20269824B80054B3DF /* APIService+Filter.swift */,
|
||||||
|
6213AF5728939C4700BCADB6 /* APIService+Bookmark.swift */,
|
||||||
);
|
);
|
||||||
path = APIService;
|
path = APIService;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2678,6 +2705,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */,
|
DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */,
|
||||||
|
6213AF5D2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift */,
|
||||||
DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.swift */,
|
DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.swift */,
|
||||||
DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */,
|
DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */,
|
||||||
DB8F7075279E954700E1225B /* DataSourceFacade+Follow.swift */,
|
DB8F7075279E954700E1225B /* DataSourceFacade+Follow.swift */,
|
||||||
|
@ -3024,6 +3052,7 @@
|
||||||
DB9D6C0825E4F5A60051B173 /* Profile */ = {
|
DB9D6C0825E4F5A60051B173 /* Profile */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
62047EBE28874C8F00A3BA5D /* Bookmark */,
|
||||||
DBB525462611ED57002F1F29 /* Header */,
|
DBB525462611ED57002F1F29 /* Header */,
|
||||||
DBB525262611EBDA002F1F29 /* Paging */,
|
DBB525262611EBDA002F1F29 /* Paging */,
|
||||||
DBB5253B2611ECF5002F1F29 /* Timeline */,
|
DBB5253B2611ECF5002F1F29 /* Timeline */,
|
||||||
|
@ -3953,6 +3982,7 @@
|
||||||
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */,
|
DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */,
|
||||||
DB6746E7278ED633008A6B94 /* MastodonAuthenticationBox.swift in Sources */,
|
DB6746E7278ED633008A6B94 /* MastodonAuthenticationBox.swift in Sources */,
|
||||||
DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */,
|
DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */,
|
||||||
|
62FD27D32893707B00B205C5 /* BookmarkViewController+DataSourceProvider.swift in Sources */,
|
||||||
DB1D843426579931000346B3 /* TableViewControllerNavigateable.swift in Sources */,
|
DB1D843426579931000346B3 /* TableViewControllerNavigateable.swift in Sources */,
|
||||||
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
|
0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */,
|
||||||
DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */,
|
DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */,
|
||||||
|
@ -3997,6 +4027,7 @@
|
||||||
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
|
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
|
||||||
DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */,
|
DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */,
|
||||||
DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */,
|
DBF1D24E269DAF5D00C1C08A /* SearchDetailViewController.swift in Sources */,
|
||||||
|
62FD27D52893708A00B205C5 /* BookmarkViewModel+Diffable.swift in Sources */,
|
||||||
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
|
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
|
||||||
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */,
|
DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */,
|
||||||
DB336F36278D77A40031E64B /* PollOption+Property.swift in Sources */,
|
DB336F36278D77A40031E64B /* PollOption+Property.swift in Sources */,
|
||||||
|
@ -4020,6 +4051,7 @@
|
||||||
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
|
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
|
||||||
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
|
DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */,
|
||||||
DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */,
|
DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */,
|
||||||
|
62FD27D12893707600B205C5 /* BookmarkViewController.swift in Sources */,
|
||||||
DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */,
|
DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */,
|
||||||
DBD5B1F627BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */,
|
DBD5B1F627BCD3D200BD6B38 /* SuggestionAccountTableViewCell+ViewModel.swift in Sources */,
|
||||||
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
|
5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */,
|
||||||
|
@ -4169,6 +4201,7 @@
|
||||||
DB63F75C279956D000455B82 /* Persistence+Tag.swift in Sources */,
|
DB63F75C279956D000455B82 /* Persistence+Tag.swift in Sources */,
|
||||||
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */,
|
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */,
|
||||||
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */,
|
DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */,
|
||||||
|
6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */,
|
||||||
5B24BBDB262DB14800A9381B /* ReportStatusViewModel+Diffable.swift in Sources */,
|
5B24BBDB262DB14800A9381B /* ReportStatusViewModel+Diffable.swift in Sources */,
|
||||||
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */,
|
DB4F0968269ED8AD00D62E92 /* SearchHistoryTableHeaderView.swift in Sources */,
|
||||||
0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */,
|
0FB3D2FE25E4CB6400AAD544 /* OnboardingHeadlineTableViewCell.swift in Sources */,
|
||||||
|
@ -4288,6 +4321,7 @@
|
||||||
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */,
|
DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */,
|
||||||
DB336F38278D7AAF0031E64B /* Poll+Property.swift in Sources */,
|
DB336F38278D7AAF0031E64B /* Poll+Property.swift in Sources */,
|
||||||
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
|
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
|
||||||
|
6213AF5C28939C8A00BCADB6 /* BookmarkViewModel+State.swift in Sources */,
|
||||||
DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */,
|
DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */,
|
||||||
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
|
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
|
||||||
DBA465932696B495002B41DB /* APIService+WebFinger.swift in Sources */,
|
DBA465932696B495002B41DB /* APIService+WebFinger.swift in Sources */,
|
||||||
|
@ -4297,6 +4331,7 @@
|
||||||
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
|
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
|
||||||
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
|
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
|
||||||
DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */,
|
DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */,
|
||||||
|
6213AF5E2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift in Sources */,
|
||||||
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */,
|
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */,
|
||||||
DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */,
|
DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */,
|
||||||
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */,
|
||||||
|
@ -4445,6 +4480,7 @@
|
||||||
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
|
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
|
||||||
DBEFCD82282A2AB100C0ABEA /* ReportServerRulesView.swift in Sources */,
|
DBEFCD82282A2AB100C0ABEA /* ReportServerRulesView.swift in Sources */,
|
||||||
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
|
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
|
||||||
|
6213AF5828939C4800BCADB6 /* APIService+Bookmark.swift in Sources */,
|
||||||
DB023D2A27A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift in Sources */,
|
DB023D2A27A0FE5C005AC798 /* DataSourceProvider+NotificationTableViewCellDelegate.swift in Sources */,
|
||||||
DB98EB6027B10E150082E365 /* ReportCommentTableViewCell.swift in Sources */,
|
DB98EB6027B10E150082E365 /* ReportCommentTableViewCell.swift in Sources */,
|
||||||
DB0FCB962797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift in Sources */,
|
DB0FCB962797E6C2006C02E2 /* SearchResultViewController+DataSourceProvider.swift in Sources */,
|
||||||
|
|
|
@ -181,6 +181,7 @@ extension SceneCoordinator {
|
||||||
case familiarFollowers(viewModel: FamiliarFollowersViewModel)
|
case familiarFollowers(viewModel: FamiliarFollowersViewModel)
|
||||||
case rebloggedBy(viewModel: UserListViewModel)
|
case rebloggedBy(viewModel: UserListViewModel)
|
||||||
case favoritedBy(viewModel: UserListViewModel)
|
case favoritedBy(viewModel: UserListViewModel)
|
||||||
|
case bookmark(viewModel: BookmarkViewModel)
|
||||||
|
|
||||||
// setting
|
// setting
|
||||||
case settings(viewModel: SettingsViewModel)
|
case settings(viewModel: SettingsViewModel)
|
||||||
|
@ -437,6 +438,10 @@ private extension SceneCoordinator {
|
||||||
let _viewController = ProfileViewController()
|
let _viewController = ProfileViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
viewController = _viewController
|
viewController = _viewController
|
||||||
|
case .bookmark(let viewModel):
|
||||||
|
let _viewController = BookmarkViewController()
|
||||||
|
_viewController.viewModel = viewModel
|
||||||
|
viewController = _viewController
|
||||||
case .favorite(let viewModel):
|
case .favorite(let viewModel):
|
||||||
let _viewController = FavoriteViewController()
|
let _viewController = FavoriteViewController()
|
||||||
_viewController.viewModel = viewModel
|
_viewController.viewModel = viewModel
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
//
|
||||||
|
// DataSourceFacade+Bookmark.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by ProtoLimit on 2022/07/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
|
extension DataSourceFacade {
|
||||||
|
static func responseToStatusBookmarkAction(
|
||||||
|
provider: DataSourceProvider,
|
||||||
|
status: ManagedObjectRecord<Status>,
|
||||||
|
authenticationBox: MastodonAuthenticationBox
|
||||||
|
) async throws {
|
||||||
|
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
|
||||||
|
await selectionFeedbackGenerator.selectionChanged()
|
||||||
|
|
||||||
|
_ = try await provider.context.apiService.bookmark(
|
||||||
|
record: status,
|
||||||
|
authenticationBox: authenticationBox
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -125,6 +125,12 @@ extension DataSourceFacade {
|
||||||
status: status,
|
status: status,
|
||||||
authenticationBox: authenticationBox
|
authenticationBox: authenticationBox
|
||||||
)
|
)
|
||||||
|
case .bookmark:
|
||||||
|
try await DataSourceFacade.responseToStatusBookmarkAction(
|
||||||
|
provider: provider,
|
||||||
|
status: status,
|
||||||
|
authenticationBox: authenticationBox
|
||||||
|
)
|
||||||
case .share:
|
case .share:
|
||||||
try await DataSourceFacade.responseToStatusShareAction(
|
try await DataSourceFacade.responseToStatusShareAction(
|
||||||
provider: provider,
|
provider: provider,
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
//
|
||||||
|
// BookmarkViewController+DataSourceProvider.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by ProtoLimit on 2022-07-19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension BookmarkViewController: DataSourceProvider {
|
||||||
|
func item(from source: DataSourceItem.Source) async -> DataSourceItem? {
|
||||||
|
var _indexPath = source.indexPath
|
||||||
|
if _indexPath == nil, let cell = source.tableViewCell {
|
||||||
|
_indexPath = await self.indexPath(for: cell)
|
||||||
|
}
|
||||||
|
guard let indexPath = _indexPath else { return nil }
|
||||||
|
|
||||||
|
guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item {
|
||||||
|
case .status(let record):
|
||||||
|
return .status(record: record)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func indexPath(for cell: UITableViewCell) async -> IndexPath? {
|
||||||
|
return tableView.indexPath(for: cell)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,151 @@
|
||||||
|
//
|
||||||
|
// BookmarkViewController.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by ProtoLimit on 2022-07-19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import UIKit
|
||||||
|
import AVKit
|
||||||
|
import Combine
|
||||||
|
import GameplayKit
|
||||||
|
import MastodonAsset
|
||||||
|
import MastodonLocalization
|
||||||
|
|
||||||
|
final class BookmarkViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
|
||||||
|
|
||||||
|
let logger = Logger(subsystem: "BookmarkViewController", category: "ViewController")
|
||||||
|
|
||||||
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
var viewModel: BookmarkViewModel!
|
||||||
|
|
||||||
|
let mediaPreviewTransitionController = MediaPreviewTransitionController()
|
||||||
|
|
||||||
|
let titleView = DoubleTitleLabelNavigationBarTitleView()
|
||||||
|
|
||||||
|
lazy var tableView: UITableView = {
|
||||||
|
let tableView = UITableView()
|
||||||
|
tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self))
|
||||||
|
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
return tableView
|
||||||
|
}()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BookmarkViewController {
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor
|
||||||
|
ThemeService.shared.currentTheme
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] theme in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.view.backgroundColor = theme.secondarySystemBackgroundColor
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
navigationItem.titleView = titleView
|
||||||
|
titleView.update(title: L10n.Scene.Bookmark.title, subtitle: nil)
|
||||||
|
|
||||||
|
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),
|
||||||
|
])
|
||||||
|
|
||||||
|
tableView.delegate = self
|
||||||
|
viewModel.setupDiffableDataSource(
|
||||||
|
tableView: tableView,
|
||||||
|
statusTableViewCellDelegate: self
|
||||||
|
)
|
||||||
|
|
||||||
|
// setup batch fetch
|
||||||
|
viewModel.listBatchFetchViewModel.setup(scrollView: tableView)
|
||||||
|
viewModel.listBatchFetchViewModel.shouldFetch
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.viewModel.stateMachine.enter(BookmarkViewModel.State.Loading.self)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
tableView.deselectRow(with: transitionCoordinator, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
|
||||||
|
// aspectViewDidDisappear(animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDelegate
|
||||||
|
extension BookmarkViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
|
||||||
|
// sourcery:inline:BookmarkViewController.AutoGenerateTableViewDelegate
|
||||||
|
|
||||||
|
// Generated using Sourcery
|
||||||
|
// DO NOT EDIT
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
aspectTableView(tableView, didSelectRowAt: indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||||
|
return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
||||||
|
return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
|
aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// sourcery:end
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - StatusTableViewCellDelegate
|
||||||
|
extension BookmarkViewController: StatusTableViewCellDelegate { }
|
||||||
|
|
||||||
|
extension BookmarkViewController {
|
||||||
|
override var keyCommands: [UIKeyCommand]? {
|
||||||
|
return navigationKeyCommands + statusNavigationKeyCommands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - StatusTableViewControllerNavigateable
|
||||||
|
extension BookmarkViewController: StatusTableViewControllerNavigateable {
|
||||||
|
@objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
||||||
|
navigateKeyCommandHandler(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) {
|
||||||
|
statusKeyCommandHandler(sender)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
//
|
||||||
|
// BookmarkViewModel+Diffable.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by ProtoLimit on 2022-07-19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension BookmarkViewModel {
|
||||||
|
|
||||||
|
func setupDiffableDataSource(
|
||||||
|
tableView: UITableView,
|
||||||
|
statusTableViewCellDelegate: StatusTableViewCellDelegate
|
||||||
|
) {
|
||||||
|
diffableDataSource = StatusSection.diffableDataSource(
|
||||||
|
tableView: tableView,
|
||||||
|
context: context,
|
||||||
|
configuration: StatusSection.Configuration(
|
||||||
|
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||||
|
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||||
|
filterContext: .none,
|
||||||
|
activeFilters: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// set empty section to make update animation top-to-bottom style
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
diffableDataSource?.apply(snapshot)
|
||||||
|
|
||||||
|
stateMachine.enter(State.Reloading.self)
|
||||||
|
|
||||||
|
statusFetchedResultsController.$records
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] records in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let diffableDataSource = self.diffableDataSource else { return }
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<StatusSection, StatusItem>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
|
||||||
|
let items = records.map { StatusItem.status(record: $0) }
|
||||||
|
snapshot.appendItems(items, toSection: .main)
|
||||||
|
|
||||||
|
if let currentState = self.stateMachine.currentState {
|
||||||
|
switch currentState {
|
||||||
|
case is State.Reloading,
|
||||||
|
is State.Loading,
|
||||||
|
is State.Idle,
|
||||||
|
is State.Fail:
|
||||||
|
snapshot.appendItems([.bottomLoader], toSection: .main)
|
||||||
|
case is State.NoMore:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
assertionFailure()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diffableDataSource.applySnapshot(snapshot, animated: false)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,191 @@
|
||||||
|
//
|
||||||
|
// BookmarkViewModel+State.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by ProtoLimit on 2022-07-19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import GameplayKit
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
|
extension BookmarkViewModel {
|
||||||
|
class State: GKState, NamingState {
|
||||||
|
|
||||||
|
let logger = Logger(subsystem: "BookmarkViewModel.State", category: "StateMachine")
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
String(describing: Self.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
weak var viewModel: BookmarkViewModel?
|
||||||
|
|
||||||
|
init(viewModel: BookmarkViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
let previousState = previousState as? BookmarkViewModel.State
|
||||||
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func enter(state: State.Type) {
|
||||||
|
stateMachine?.enter(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BookmarkViewModel.State {
|
||||||
|
class Initial: BookmarkViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
guard let viewModel = viewModel else { return false }
|
||||||
|
switch stateClass {
|
||||||
|
case is Reloading.Type:
|
||||||
|
return viewModel.activeMastodonAuthenticationBox.value != nil
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Reloading: BookmarkViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Loading.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
|
||||||
|
// reset
|
||||||
|
viewModel.statusFetchedResultsController.statusIDs.value = []
|
||||||
|
|
||||||
|
stateMachine.enter(Loading.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fail: BookmarkViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Loading.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let _ = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
stateMachine.enter(Loading.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Idle: BookmarkViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Reloading.Type, is Loading.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Loading: BookmarkViewModel.State {
|
||||||
|
|
||||||
|
// prefer use `maxID` token in response header
|
||||||
|
var maxID: String?
|
||||||
|
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Fail.Type:
|
||||||
|
return true
|
||||||
|
case is Idle.Type:
|
||||||
|
return true
|
||||||
|
case is NoMore.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didEnter(from previousState: GKState?) {
|
||||||
|
super.didEnter(from: previousState)
|
||||||
|
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
||||||
|
|
||||||
|
guard let authenticationBox = viewModel.activeMastodonAuthenticationBox.value else {
|
||||||
|
stateMachine.enter(Fail.self)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if previousState is Reloading {
|
||||||
|
maxID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let response = try await viewModel.context.apiService.bookmarkedStatuses(
|
||||||
|
maxID: maxID,
|
||||||
|
authenticationBox: authenticationBox
|
||||||
|
)
|
||||||
|
|
||||||
|
var hasNewStatusesAppend = false
|
||||||
|
var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value
|
||||||
|
for status in response.value {
|
||||||
|
guard !statusIDs.contains(status.id) else { continue }
|
||||||
|
statusIDs.append(status.id)
|
||||||
|
hasNewStatusesAppend = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.maxID = response.link?.maxID
|
||||||
|
|
||||||
|
let hasNextPage: Bool = {
|
||||||
|
guard let link = response.link else { return true } // assert has more when link invalid
|
||||||
|
return link.maxID != nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
if hasNewStatusesAppend && hasNextPage {
|
||||||
|
await enter(state: Idle.self)
|
||||||
|
} else {
|
||||||
|
await enter(state: NoMore.self)
|
||||||
|
}
|
||||||
|
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
|
||||||
|
} catch {
|
||||||
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user bookmarks fail: \(error.localizedDescription)")
|
||||||
|
await enter(state: Fail.self)
|
||||||
|
}
|
||||||
|
} // end Task
|
||||||
|
} // end func
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoMore: BookmarkViewModel.State {
|
||||||
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
|
switch stateClass {
|
||||||
|
case is Reloading.Type:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
//
|
||||||
|
// BookmarkViewModel.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by ProtoLimit on 2022-07-19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import GameplayKit
|
||||||
|
|
||||||
|
final class BookmarkViewModel {
|
||||||
|
|
||||||
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// input
|
||||||
|
let context: AppContext
|
||||||
|
let activeMastodonAuthenticationBox: CurrentValueSubject<MastodonAuthenticationBox?, Never>
|
||||||
|
let statusFetchedResultsController: StatusFetchedResultsController
|
||||||
|
let listBatchFetchViewModel = ListBatchFetchViewModel()
|
||||||
|
|
||||||
|
// output
|
||||||
|
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
|
||||||
|
private(set) lazy var stateMachine: GKStateMachine = {
|
||||||
|
let stateMachine = GKStateMachine(states: [
|
||||||
|
State.Initial(viewModel: self),
|
||||||
|
State.Reloading(viewModel: self),
|
||||||
|
State.Fail(viewModel: self),
|
||||||
|
State.Idle(viewModel: self),
|
||||||
|
State.Loading(viewModel: self),
|
||||||
|
State.NoMore(viewModel: self),
|
||||||
|
])
|
||||||
|
stateMachine.enter(State.Initial.self)
|
||||||
|
return stateMachine
|
||||||
|
}()
|
||||||
|
|
||||||
|
init(context: AppContext) {
|
||||||
|
self.context = context
|
||||||
|
self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value)
|
||||||
|
self.statusFetchedResultsController = StatusFetchedResultsController(
|
||||||
|
managedObjectContext: context.managedObjectContext,
|
||||||
|
domain: nil,
|
||||||
|
additionalTweetPredicate: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
context.authenticationService.activeMastodonAuthenticationBox
|
||||||
|
.assign(to: \.value, on: activeMastodonAuthenticationBox)
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
activeMastodonAuthenticationBox
|
||||||
|
.map { $0?.domain }
|
||||||
|
.assign(to: \.value, on: statusFetchedResultsController.domain)
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -75,6 +75,17 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi
|
||||||
return barButtonItem
|
return barButtonItem
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private(set) lazy var bookmarkBarButtonItem: UIBarButtonItem = {
|
||||||
|
let barButtonItem = UIBarButtonItem(
|
||||||
|
image: Asset.ObjectsAndTools.bookmark.image.withRenderingMode(.alwaysTemplate),
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(ProfileViewController.bookmarkBarButtonItemPressed(_:))
|
||||||
|
)
|
||||||
|
barButtonItem.tintColor = .white
|
||||||
|
return barButtonItem
|
||||||
|
}()
|
||||||
|
|
||||||
private(set) lazy var replyBarButtonItem: UIBarButtonItem = {
|
private(set) lazy var replyBarButtonItem: UIBarButtonItem = {
|
||||||
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:)))
|
let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:)))
|
||||||
barButtonItem.tintColor = .white
|
barButtonItem.tintColor = .white
|
||||||
|
@ -224,6 +235,7 @@ extension ProfileViewController {
|
||||||
items.append(self.settingBarButtonItem)
|
items.append(self.settingBarButtonItem)
|
||||||
items.append(self.shareBarButtonItem)
|
items.append(self.shareBarButtonItem)
|
||||||
items.append(self.favoriteBarButtonItem)
|
items.append(self.favoriteBarButtonItem)
|
||||||
|
items.append(self.bookmarkBarButtonItem)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -504,6 +516,12 @@ extension ProfileViewController {
|
||||||
coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show)
|
coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func bookmarkBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
|
let bookmarkViewModel = BookmarkViewModel(context: context)
|
||||||
|
coordinator.present(scene: .bookmark(viewModel: bookmarkViewModel), from: self, transition: .show)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
@objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
|
||||||
|
|
|
@ -395,6 +395,19 @@ extension StatusView {
|
||||||
}
|
}
|
||||||
.assign(to: \.isFavorite, on: viewModel)
|
.assign(to: \.isFavorite, on: viewModel)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
Publishers.CombineLatest(
|
||||||
|
viewModel.$userIdentifier,
|
||||||
|
status.publisher(for: \.bookmarkedBy)
|
||||||
|
)
|
||||||
|
.map { userIdentifier, bookmarkedBy in
|
||||||
|
guard let userIdentifier = userIdentifier else { return false }
|
||||||
|
return bookmarkedBy.contains(where: {
|
||||||
|
$0.id == userIdentifier.userID && $0.domain == userIdentifier.domain
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.assign(to: \.isBookmark, on: viewModel)
|
||||||
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureFilter(status: Status) {
|
private func configureFilter(status: Status) {
|
||||||
|
|
|
@ -0,0 +1,142 @@
|
||||||
|
//
|
||||||
|
// APIService+Bookmark.swift
|
||||||
|
// Mastodon
|
||||||
|
//
|
||||||
|
// Created by ProtoLimit on 2022/07/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import MastodonSDK
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
import CommonOSLog
|
||||||
|
|
||||||
|
extension APIService {
|
||||||
|
|
||||||
|
private struct MastodonBookmarkContext {
|
||||||
|
let statusID: Status.ID
|
||||||
|
let isBookmarked: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func bookmark(
|
||||||
|
record: ManagedObjectRecord<Status>,
|
||||||
|
authenticationBox: MastodonAuthenticationBox
|
||||||
|
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Status> {
|
||||||
|
let logger = Logger(subsystem: "APIService", category: "Bookmark")
|
||||||
|
|
||||||
|
let managedObjectContext = backgroundManagedObjectContext
|
||||||
|
|
||||||
|
// update bookmark state and retrieve bookmark context
|
||||||
|
let bookmarkContext: MastodonBookmarkContext = try await managedObjectContext.performChanges {
|
||||||
|
guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext),
|
||||||
|
let _status = record.object(in: managedObjectContext)
|
||||||
|
else {
|
||||||
|
throw APIError.implicit(.badRequest)
|
||||||
|
}
|
||||||
|
let me = authentication.user
|
||||||
|
let status = _status.reblog ?? _status
|
||||||
|
let isBookmarked = status.bookmarkedBy.contains(me)
|
||||||
|
status.update(bookmarked: !isBookmarked, by: me)
|
||||||
|
let context = MastodonBookmarkContext(
|
||||||
|
statusID: status.id,
|
||||||
|
isBookmarked: isBookmarked
|
||||||
|
)
|
||||||
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update status bookmark: \(!isBookmarked)")
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// request bookmark or undo bookmark
|
||||||
|
let result: Result<Mastodon.Response.Content<Mastodon.Entity.Status>, Error>
|
||||||
|
do {
|
||||||
|
let response = try await Mastodon.API.Bookmarks.bookmarks(
|
||||||
|
domain: authenticationBox.domain,
|
||||||
|
statusID: bookmarkContext.statusID,
|
||||||
|
session: session,
|
||||||
|
authorization: authenticationBox.userAuthorization,
|
||||||
|
bookmarkKind: bookmarkContext.isBookmarked ? .destroy : .create
|
||||||
|
).singleOutput()
|
||||||
|
result = .success(response)
|
||||||
|
} catch {
|
||||||
|
result = .failure(error)
|
||||||
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update bookmark failure: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// update bookmark state
|
||||||
|
try await managedObjectContext.performChanges {
|
||||||
|
guard let authentication = authenticationBox.authenticationRecord.object(in: managedObjectContext),
|
||||||
|
let _status = record.object(in: managedObjectContext)
|
||||||
|
else { return }
|
||||||
|
let me = authentication.user
|
||||||
|
let status = _status.reblog ?? _status
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
_ = Persistence.Status.createOrMerge(
|
||||||
|
in: managedObjectContext,
|
||||||
|
context: Persistence.Status.PersistContext(
|
||||||
|
domain: authenticationBox.domain,
|
||||||
|
entity: response.value,
|
||||||
|
me: me,
|
||||||
|
statusCache: nil,
|
||||||
|
userCache: nil,
|
||||||
|
networkDate: response.networkDate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update status bookmark: \(response.value.bookmarked.debugDescription)")
|
||||||
|
case .failure:
|
||||||
|
// rollback
|
||||||
|
status.update(bookmarked: bookmarkContext.isBookmarked, by: me)
|
||||||
|
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): rollback status bookmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = try result.get()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIService {
|
||||||
|
func bookmarkedStatuses(
|
||||||
|
limit: Int = onceRequestStatusMaxCount,
|
||||||
|
maxID: String? = nil,
|
||||||
|
authenticationBox: MastodonAuthenticationBox
|
||||||
|
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> {
|
||||||
|
let query = Mastodon.API.Bookmarks.BookmarkStatusesQuery(limit: limit, minID: nil, maxID: maxID)
|
||||||
|
|
||||||
|
let response = try await Mastodon.API.Bookmarks.bookmarkedStatus(
|
||||||
|
domain: authenticationBox.domain,
|
||||||
|
session: session,
|
||||||
|
authorization: authenticationBox.userAuthorization,
|
||||||
|
query: query
|
||||||
|
).singleOutput()
|
||||||
|
|
||||||
|
let managedObjectContext = self.backgroundManagedObjectContext
|
||||||
|
try await managedObjectContext.performChanges {
|
||||||
|
guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for entity in response.value {
|
||||||
|
let result = Persistence.Status.createOrMerge(
|
||||||
|
in: managedObjectContext,
|
||||||
|
context: Persistence.Status.PersistContext(
|
||||||
|
domain: authenticationBox.domain,
|
||||||
|
entity: entity,
|
||||||
|
me: me,
|
||||||
|
statusCache: nil,
|
||||||
|
userCache: nil,
|
||||||
|
networkDate: response.networkDate
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result.status.update(bookmarked: true, by: me)
|
||||||
|
result.status.reblog?.update(bookmarked: true, by: me)
|
||||||
|
} // end for … in
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} // end func
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "bookmark-solid.pdf",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true
|
||||||
|
}
|
||||||
|
}
|
BIN
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/bookmark-solid.pdf
vendored
Executable file
BIN
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.fill.imageset/bookmark-solid.pdf
vendored
Executable file
Binary file not shown.
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "bookmark-regular.pdf",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true
|
||||||
|
}
|
||||||
|
}
|
BIN
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/bookmark-regular.pdf
vendored
Executable file
BIN
MastodonSDK/Sources/MastodonAsset/Assets.xcassets/ObjectsAndTools/bookmark.imageset/bookmark-regular.pdf
vendored
Executable file
Binary file not shown.
|
@ -117,6 +117,8 @@ public enum Asset {
|
||||||
public static let bellBadge = ImageAsset(name: "ObjectsAndTools/bell.badge")
|
public static let bellBadge = ImageAsset(name: "ObjectsAndTools/bell.badge")
|
||||||
public static let bellFill = ImageAsset(name: "ObjectsAndTools/bell.fill")
|
public static let bellFill = ImageAsset(name: "ObjectsAndTools/bell.fill")
|
||||||
public static let bell = ImageAsset(name: "ObjectsAndTools/bell")
|
public static let bell = ImageAsset(name: "ObjectsAndTools/bell")
|
||||||
|
public static let bookmarkFill = ImageAsset(name: "ObjectsAndTools/bookmark.fill")
|
||||||
|
public static let bookmark = ImageAsset(name: "ObjectsAndTools/bookmark")
|
||||||
public static let gear = ImageAsset(name: "ObjectsAndTools/gear")
|
public static let gear = ImageAsset(name: "ObjectsAndTools/gear")
|
||||||
public static let houseFill = ImageAsset(name: "ObjectsAndTools/house.fill")
|
public static let houseFill = ImageAsset(name: "ObjectsAndTools/house.fill")
|
||||||
public static let house = ImageAsset(name: "ObjectsAndTools/house")
|
public static let house = ImageAsset(name: "ObjectsAndTools/house")
|
||||||
|
|
|
@ -287,6 +287,8 @@ public enum L10n {
|
||||||
return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1))
|
return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1))
|
||||||
}
|
}
|
||||||
public enum Actions {
|
public enum Actions {
|
||||||
|
/// Bookmark
|
||||||
|
public static let bookmark = L10n.tr("Localizable", "Common.Controls.Status.Actions.Bookmark")
|
||||||
/// Favorite
|
/// Favorite
|
||||||
public static let favorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Favorite")
|
public static let favorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Favorite")
|
||||||
/// Hide
|
/// Hide
|
||||||
|
@ -305,6 +307,8 @@ public enum L10n {
|
||||||
public static let showVideoPlayer = L10n.tr("Localizable", "Common.Controls.Status.Actions.ShowVideoPlayer")
|
public static let showVideoPlayer = L10n.tr("Localizable", "Common.Controls.Status.Actions.ShowVideoPlayer")
|
||||||
/// Tap then hold to show menu
|
/// Tap then hold to show menu
|
||||||
public static let tapThenHoldToShowMenu = L10n.tr("Localizable", "Common.Controls.Status.Actions.TapThenHoldToShowMenu")
|
public static let tapThenHoldToShowMenu = L10n.tr("Localizable", "Common.Controls.Status.Actions.TapThenHoldToShowMenu")
|
||||||
|
/// Unbookmark
|
||||||
|
public static let unbookmark = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unbookmark")
|
||||||
/// Unfavorite
|
/// Unfavorite
|
||||||
public static let unfavorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unfavorite")
|
public static let unfavorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unfavorite")
|
||||||
/// Undo reblog
|
/// Undo reblog
|
||||||
|
@ -403,6 +407,10 @@ public enum L10n {
|
||||||
return L10n.tr("Localizable", "Scene.AccountList.TabBarHint", String(describing: p1))
|
return L10n.tr("Localizable", "Scene.AccountList.TabBarHint", String(describing: p1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public enum Bookmark {
|
||||||
|
/// Your Bookmarks
|
||||||
|
public static let title = L10n.tr("Localizable", "Scene.Bookmark.Title")
|
||||||
|
}
|
||||||
public enum Compose {
|
public enum Compose {
|
||||||
/// Publish
|
/// Publish
|
||||||
public static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction")
|
public static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction")
|
||||||
|
|
|
@ -104,6 +104,8 @@ Please check your internet connection.";
|
||||||
"Common.Controls.Status.Actions.TapThenHoldToShowMenu" = "Tap then hold to show menu";
|
"Common.Controls.Status.Actions.TapThenHoldToShowMenu" = "Tap then hold to show menu";
|
||||||
"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite";
|
"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite";
|
||||||
"Common.Controls.Status.Actions.Unreblog" = "Undo reblog";
|
"Common.Controls.Status.Actions.Unreblog" = "Undo reblog";
|
||||||
|
"Common.Controls.Status.Actions.Bookmark" = "Bookmark";
|
||||||
|
"Common.Controls.Status.Actions.Unbookmark" = "Unbookmark";
|
||||||
"Common.Controls.Status.ContentWarning" = "Content Warning";
|
"Common.Controls.Status.ContentWarning" = "Content Warning";
|
||||||
"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
|
"Common.Controls.Status.MediaContentWarning" = "Tap anywhere to reveal";
|
||||||
"Common.Controls.Status.Poll.Closed" = "Closed";
|
"Common.Controls.Status.Poll.Closed" = "Closed";
|
||||||
|
@ -149,6 +151,7 @@ Your profile looks like this to them.";
|
||||||
"Scene.AccountList.AddAccount" = "Add Account";
|
"Scene.AccountList.AddAccount" = "Add Account";
|
||||||
"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher";
|
"Scene.AccountList.DismissAccountSwitcher" = "Dismiss Account Switcher";
|
||||||
"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher";
|
"Scene.AccountList.TabBarHint" = "Current selected profile: %@. Double tap then hold to show account switcher";
|
||||||
|
"Scene.Bookmark.Title" = "Your Bookmarks";
|
||||||
"Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment";
|
"Scene.Compose.Accessibility.AppendAttachment" = "Add Attachment";
|
||||||
"Scene.Compose.Accessibility.AppendPoll" = "Add Poll";
|
"Scene.Compose.Accessibility.AppendPoll" = "Add Poll";
|
||||||
"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker";
|
"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom Emoji Picker";
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
//
|
||||||
|
// Mastodon+API+Bookmarks.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by ProtoLimit on 2022/07/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Mastodon.API.Bookmarks {
|
||||||
|
|
||||||
|
static func bookmarksStatusesEndpointURL(domain: String) -> URL {
|
||||||
|
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("bookmarks")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bookmarked statuses
|
||||||
|
///
|
||||||
|
/// Using this endpoint to view the bookmarked list for user
|
||||||
|
///
|
||||||
|
/// - Since: 3.1.0
|
||||||
|
/// - Version: 3.3.0
|
||||||
|
/// # Last Update
|
||||||
|
/// 2022/7/28
|
||||||
|
/// # Reference
|
||||||
|
/// [Document](https://docs.joinmastodon.org/methods/accounts/bookmarks/)
|
||||||
|
/// - Parameters:
|
||||||
|
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||||
|
/// - session: `URLSession`
|
||||||
|
/// - authorization: User token
|
||||||
|
/// - Returns: `AnyPublisher` contains `Server` nested in the response
|
||||||
|
public static func bookmarkedStatus(
|
||||||
|
domain: String,
|
||||||
|
session: URLSession,
|
||||||
|
authorization: Mastodon.API.OAuth.Authorization,
|
||||||
|
query: Mastodon.API.Bookmarks.BookmarkStatusesQuery
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
|
||||||
|
let url = bookmarksStatusesEndpointURL(domain: domain)
|
||||||
|
let request = Mastodon.API.get(url: url, 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 struct BookmarkStatusesQuery: GetQuery, PagedQueryType {
|
||||||
|
|
||||||
|
public var limit: Int?
|
||||||
|
public var minID: String?
|
||||||
|
public var maxID: String?
|
||||||
|
public var sinceID: Mastodon.Entity.Status.ID?
|
||||||
|
|
||||||
|
public init(limit: Int? = nil, minID: String? = nil, maxID: String? = nil, sinceID: String? = nil) {
|
||||||
|
self.limit = limit
|
||||||
|
self.minID = minID
|
||||||
|
self.maxID = maxID
|
||||||
|
self.sinceID = sinceID
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryItems: [URLQueryItem]? {
|
||||||
|
var items: [URLQueryItem] = []
|
||||||
|
if let limit = self.limit {
|
||||||
|
items.append(URLQueryItem(name: "limit", value: String(limit)))
|
||||||
|
}
|
||||||
|
if let minID = self.minID {
|
||||||
|
items.append(URLQueryItem(name: "min_id", value: minID))
|
||||||
|
}
|
||||||
|
if let maxID = self.maxID {
|
||||||
|
items.append(URLQueryItem(name: "max_id", value: maxID))
|
||||||
|
}
|
||||||
|
if let sinceID = self.sinceID {
|
||||||
|
items.append(URLQueryItem(name: "since_id", value: sinceID))
|
||||||
|
}
|
||||||
|
guard !items.isEmpty else { return nil }
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Mastodon.API.Bookmarks {
|
||||||
|
|
||||||
|
static func bookmarkActionEndpointURL(domain: String, statusID: String, bookmarkKind: BookmarkKind) -> URL {
|
||||||
|
var actionString: String
|
||||||
|
switch bookmarkKind {
|
||||||
|
case .create:
|
||||||
|
actionString = "/bookmark"
|
||||||
|
case .destroy:
|
||||||
|
actionString = "/unbookmark"
|
||||||
|
}
|
||||||
|
let pathComponent = "statuses/" + statusID + actionString
|
||||||
|
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bookmark / Undo Bookmark
|
||||||
|
///
|
||||||
|
/// Add a status to your bookmarks list / Remove a status from your bookmarks list
|
||||||
|
///
|
||||||
|
/// - Since: 3.1.0
|
||||||
|
/// - Version: 3.3.0
|
||||||
|
/// # Last Update
|
||||||
|
/// 2022/7/28
|
||||||
|
/// # Reference
|
||||||
|
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
|
||||||
|
/// - Parameters:
|
||||||
|
/// - domain: Mastodon instance domain. e.g. "example.com"
|
||||||
|
/// - statusID: Mastodon status id
|
||||||
|
/// - session: `URLSession`
|
||||||
|
/// - authorization: User token
|
||||||
|
/// - Returns: `AnyPublisher` contains `Server` nested in the response
|
||||||
|
public static func bookmarks(
|
||||||
|
domain: String,
|
||||||
|
statusID: String,
|
||||||
|
session: URLSession,
|
||||||
|
authorization: Mastodon.API.OAuth.Authorization,
|
||||||
|
bookmarkKind: BookmarkKind
|
||||||
|
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
|
||||||
|
let url: URL = bookmarkActionEndpointURL(domain: domain, statusID: statusID, bookmarkKind: bookmarkKind)
|
||||||
|
var request = Mastodon.API.post(url: url, query: nil, authorization: authorization)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
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 enum BookmarkKind {
|
||||||
|
case create
|
||||||
|
case destroy
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -102,6 +102,7 @@ extension Mastodon.API {
|
||||||
public enum V2 { }
|
public enum V2 { }
|
||||||
public enum Account { }
|
public enum Account { }
|
||||||
public enum App { }
|
public enum App { }
|
||||||
|
public enum Bookmarks { }
|
||||||
public enum CustomEmojis { }
|
public enum CustomEmojis { }
|
||||||
public enum Favorites { }
|
public enum Favorites { }
|
||||||
public enum Instance { }
|
public enum Instance { }
|
||||||
|
|
|
@ -84,6 +84,7 @@ extension StatusView {
|
||||||
@Published public var isReblog: Bool = false
|
@Published public var isReblog: Bool = false
|
||||||
@Published public var isReblogEnabled: Bool = true
|
@Published public var isReblogEnabled: Bool = true
|
||||||
@Published public var isFavorite: Bool = false
|
@Published public var isFavorite: Bool = false
|
||||||
|
@Published public var isBookmark: Bool = false
|
||||||
|
|
||||||
@Published public var replyCount: Int = 0
|
@Published public var replyCount: Int = 0
|
||||||
@Published public var reblogCount: Int = 0
|
@Published public var reblogCount: Int = 0
|
||||||
|
@ -510,6 +511,13 @@ extension StatusView.ViewModel {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
$isBookmark
|
||||||
|
.sink { isHighlighted in
|
||||||
|
statusView.actionToolbarContainer.configureBookmark(
|
||||||
|
isHighlighted: isHighlighted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bindMetric(statusView: StatusView) {
|
private func bindMetric(statusView: StatusView) {
|
||||||
|
|
|
@ -22,11 +22,14 @@ public final class ActionToolbarContainer: UIView {
|
||||||
static let reblogImage = Asset.Arrow.repeat.image.withRenderingMode(.alwaysTemplate)
|
static let reblogImage = Asset.Arrow.repeat.image.withRenderingMode(.alwaysTemplate)
|
||||||
static let starImage = Asset.ObjectsAndTools.star.image.withRenderingMode(.alwaysTemplate)
|
static let starImage = Asset.ObjectsAndTools.star.image.withRenderingMode(.alwaysTemplate)
|
||||||
static let starFillImage = Asset.ObjectsAndTools.starFill.image.withRenderingMode(.alwaysTemplate)
|
static let starFillImage = Asset.ObjectsAndTools.starFill.image.withRenderingMode(.alwaysTemplate)
|
||||||
|
static let bookmarkImage = Asset.ObjectsAndTools.bookmark.image.withRenderingMode(.alwaysTemplate)
|
||||||
|
static let bookmarkFillImage = Asset.ObjectsAndTools.bookmarkFill.image.withRenderingMode(.alwaysTemplate)
|
||||||
static let shareImage = Asset.Communication.share.image.withRenderingMode(.alwaysTemplate)
|
static let shareImage = Asset.Communication.share.image.withRenderingMode(.alwaysTemplate)
|
||||||
|
|
||||||
public let replyButton = HighlightDimmableButton()
|
public let replyButton = HighlightDimmableButton()
|
||||||
public let reblogButton = HighlightDimmableButton()
|
public let reblogButton = HighlightDimmableButton()
|
||||||
public let favoriteButton = HighlightDimmableButton()
|
public let favoriteButton = HighlightDimmableButton()
|
||||||
|
public let bookmarkButton = HighlightDimmableButton()
|
||||||
public let shareButton = HighlightDimmableButton()
|
public let shareButton = HighlightDimmableButton()
|
||||||
|
|
||||||
public weak var delegate: ActionToolbarContainerDelegate?
|
public weak var delegate: ActionToolbarContainerDelegate?
|
||||||
|
@ -61,6 +64,7 @@ extension ActionToolbarContainer {
|
||||||
replyButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside)
|
replyButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside)
|
||||||
reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside)
|
reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside)
|
||||||
favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside)
|
favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside)
|
||||||
|
bookmarkButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside)
|
||||||
shareButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside)
|
shareButton.addTarget(self, action: #selector(ActionToolbarContainer.buttonDidPressed(_:)), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,7 +79,7 @@ extension ActionToolbarContainer {
|
||||||
subview.removeFromSuperview()
|
subview.removeFromSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
let buttons = [replyButton, reblogButton, favoriteButton, shareButton]
|
let buttons = [replyButton, reblogButton, favoriteButton, bookmarkButton, shareButton]
|
||||||
buttons.forEach { button in
|
buttons.forEach { button in
|
||||||
button.tintColor = Asset.Colors.Button.actionToolbar.color
|
button.tintColor = Asset.Colors.Button.actionToolbar.color
|
||||||
button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular)
|
button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular)
|
||||||
|
@ -90,6 +94,7 @@ extension ActionToolbarContainer {
|
||||||
replyButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reply
|
replyButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reply
|
||||||
reblogButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reblog // needs update to follow state
|
reblogButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reblog // needs update to follow state
|
||||||
favoriteButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.favorite // needs update to follow state
|
favoriteButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.favorite // needs update to follow state
|
||||||
|
bookmarkButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.bookmark // needs update to follow state
|
||||||
shareButton.accessibilityLabel = L10n.Common.Controls.Actions.share
|
shareButton.accessibilityLabel = L10n.Common.Controls.Actions.share
|
||||||
|
|
||||||
switch style {
|
switch style {
|
||||||
|
@ -100,6 +105,7 @@ extension ActionToolbarContainer {
|
||||||
replyButton.setImage(ActionToolbarContainer.replyImage, for: .normal)
|
replyButton.setImage(ActionToolbarContainer.replyImage, for: .normal)
|
||||||
reblogButton.setImage(ActionToolbarContainer.reblogImage, for: .normal)
|
reblogButton.setImage(ActionToolbarContainer.reblogImage, for: .normal)
|
||||||
favoriteButton.setImage(ActionToolbarContainer.starImage, for: .normal)
|
favoriteButton.setImage(ActionToolbarContainer.starImage, for: .normal)
|
||||||
|
bookmarkButton.setImage(ActionToolbarContainer.bookmarkImage, for: .normal)
|
||||||
shareButton.setImage(ActionToolbarContainer.shareImage, for: .normal)
|
shareButton.setImage(ActionToolbarContainer.shareImage, for: .normal)
|
||||||
|
|
||||||
container.axis = .horizontal
|
container.axis = .horizontal
|
||||||
|
@ -108,18 +114,22 @@ extension ActionToolbarContainer {
|
||||||
replyButton.translatesAutoresizingMaskIntoConstraints = false
|
replyButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
reblogButton.translatesAutoresizingMaskIntoConstraints = false
|
reblogButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
favoriteButton.translatesAutoresizingMaskIntoConstraints = false
|
favoriteButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
shareButton.translatesAutoresizingMaskIntoConstraints = false
|
shareButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
container.addArrangedSubview(replyButton)
|
container.addArrangedSubview(replyButton)
|
||||||
container.addArrangedSubview(reblogButton)
|
container.addArrangedSubview(reblogButton)
|
||||||
container.addArrangedSubview(favoriteButton)
|
container.addArrangedSubview(favoriteButton)
|
||||||
|
container.addArrangedSubview(bookmarkButton)
|
||||||
container.addArrangedSubview(shareButton)
|
container.addArrangedSubview(shareButton)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
replyButton.heightAnchor.constraint(equalToConstant: 36).priority(.defaultHigh),
|
replyButton.heightAnchor.constraint(equalToConstant: 36).priority(.defaultHigh),
|
||||||
replyButton.heightAnchor.constraint(equalTo: reblogButton.heightAnchor).priority(.defaultHigh),
|
replyButton.heightAnchor.constraint(equalTo: reblogButton.heightAnchor).priority(.defaultHigh),
|
||||||
replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh),
|
replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh),
|
||||||
|
replyButton.heightAnchor.constraint(equalTo: bookmarkButton.heightAnchor).priority(.defaultHigh),
|
||||||
replyButton.heightAnchor.constraint(equalTo: shareButton.heightAnchor).priority(.defaultHigh),
|
replyButton.heightAnchor.constraint(equalTo: shareButton.heightAnchor).priority(.defaultHigh),
|
||||||
replyButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor).priority(.defaultHigh),
|
replyButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor).priority(.defaultHigh),
|
||||||
replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh),
|
replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh),
|
||||||
|
replyButton.widthAnchor.constraint(equalTo: bookmarkButton.widthAnchor).priority(.defaultHigh),
|
||||||
])
|
])
|
||||||
shareButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
shareButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||||
shareButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
shareButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||||
|
@ -131,6 +141,7 @@ extension ActionToolbarContainer {
|
||||||
replyButton.setImage(ActionToolbarContainer.replyImage, for: .normal)
|
replyButton.setImage(ActionToolbarContainer.replyImage, for: .normal)
|
||||||
reblogButton.setImage(ActionToolbarContainer.reblogImage, for: .normal)
|
reblogButton.setImage(ActionToolbarContainer.reblogImage, for: .normal)
|
||||||
favoriteButton.setImage(ActionToolbarContainer.starImage, for: .normal)
|
favoriteButton.setImage(ActionToolbarContainer.starImage, for: .normal)
|
||||||
|
bookmarkButton.setImage(ActionToolbarContainer.bookmarkImage, for: .normal)
|
||||||
|
|
||||||
container.axis = .horizontal
|
container.axis = .horizontal
|
||||||
container.spacing = 8
|
container.spacing = 8
|
||||||
|
@ -139,6 +150,7 @@ extension ActionToolbarContainer {
|
||||||
container.addArrangedSubview(replyButton)
|
container.addArrangedSubview(replyButton)
|
||||||
container.addArrangedSubview(reblogButton)
|
container.addArrangedSubview(reblogButton)
|
||||||
container.addArrangedSubview(favoriteButton)
|
container.addArrangedSubview(favoriteButton)
|
||||||
|
container.addArrangedSubview(bookmarkButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,6 +167,7 @@ extension ActionToolbarContainer {
|
||||||
case reply
|
case reply
|
||||||
case reblog
|
case reblog
|
||||||
case like
|
case like
|
||||||
|
case bookmark
|
||||||
case share
|
case share
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,6 +197,11 @@ extension ActionToolbarContainer {
|
||||||
favoriteButton.setTitleColor(tintColor, for: .highlighted)
|
favoriteButton.setTitleColor(tintColor, for: .highlighted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isBookmarkButtonHighlightStateDidChange(to isHighlight: Bool) {
|
||||||
|
let tintColor = isHighlight ? Asset.Colors.brand.color : Asset.Colors.Button.actionToolbar.color
|
||||||
|
bookmarkButton.tintColor = tintColor
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ActionToolbarContainer {
|
extension ActionToolbarContainer {
|
||||||
|
@ -196,6 +214,7 @@ extension ActionToolbarContainer {
|
||||||
case replyButton: _action = .reply
|
case replyButton: _action = .reply
|
||||||
case reblogButton: _action = .reblog
|
case reblogButton: _action = .reblog
|
||||||
case favoriteButton: _action = .like
|
case favoriteButton: _action = .like
|
||||||
|
case bookmarkButton: _action = .bookmark
|
||||||
case shareButton: _action = .share
|
case shareButton: _action = .share
|
||||||
default: _action = nil
|
default: _action = nil
|
||||||
}
|
}
|
||||||
|
@ -256,6 +275,20 @@ extension ActionToolbarContainer {
|
||||||
favoriteButton.accessibilityLabel = L10n.Plural.Count.favorite(count)
|
favoriteButton.accessibilityLabel = L10n.Plural.Count.favorite(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func configureBookmark(isHighlighted: Bool) {
|
||||||
|
let image = isHighlighted ? ActionToolbarContainer.bookmarkFillImage : ActionToolbarContainer.bookmarkImage
|
||||||
|
bookmarkButton.setImage(image, for: .normal)
|
||||||
|
let tintColor = isHighlighted ? Asset.Colors.brand.color : Asset.Colors.Button.actionToolbar.color
|
||||||
|
bookmarkButton.tintColor = tintColor
|
||||||
|
|
||||||
|
if isHighlighted {
|
||||||
|
bookmarkButton.accessibilityTraits.insert(.selected)
|
||||||
|
} else {
|
||||||
|
bookmarkButton.accessibilityTraits.remove(.selected)
|
||||||
|
}
|
||||||
|
bookmarkButton.accessibilityLabel = isHighlighted ? L10n.Common.Controls.Status.Actions.unbookmark : L10n.Common.Controls.Status.Actions.bookmark
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ActionToolbarContainer {
|
extension ActionToolbarContainer {
|
||||||
|
@ -267,7 +300,7 @@ extension ActionToolbarContainer {
|
||||||
|
|
||||||
extension ActionToolbarContainer {
|
extension ActionToolbarContainer {
|
||||||
public override var accessibilityElements: [Any]? {
|
public override var accessibilityElements: [Any]? {
|
||||||
get { [replyButton, reblogButton, favoriteButton, shareButton] }
|
get { [replyButton, reblogButton, favoriteButton, bookmarkButton, shareButton] }
|
||||||
set { }
|
set { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue