Compare commits
5 Commits
e0d97cd2a8
...
4dc108f782
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 4dc108f782 | |
Shadowfacts | 795146cde4 | |
Shadowfacts | 975be17d13 | |
Shadowfacts | 32be76ebee | |
Shadowfacts | d13b517128 |
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum Timeline: Equatable {
|
public enum Timeline: Equatable, Hashable {
|
||||||
case home
|
case home
|
||||||
case `public`(local: Bool)
|
case `public`(local: Bool)
|
||||||
case tag(hashtag: String)
|
case tag(hashtag: String)
|
||||||
|
|
|
@ -52,7 +52,7 @@
|
||||||
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
|
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
|
||||||
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; };
|
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; };
|
||||||
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */; };
|
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */; };
|
||||||
D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759829384D4D00C0B37F /* FiltersView.swift */; };
|
D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */; };
|
||||||
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759A29384F9C00C0B37F /* FilterMO.swift */; };
|
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759A29384F9C00C0B37F /* FilterMO.swift */; };
|
||||||
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759C2938574B00C0B37F /* FilterRow.swift */; };
|
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759C2938574B00C0B37F /* FilterRow.swift */; };
|
||||||
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */; };
|
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */; };
|
||||||
|
@ -186,6 +186,10 @@
|
||||||
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
|
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
|
||||||
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; };
|
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; };
|
||||||
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DE828D962C2006341DA /* TimelineLikeController.swift */; };
|
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DE828D962C2006341DA /* TimelineLikeController.swift */; };
|
||||||
|
D68A76DA29511CA6001DA1B3 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */; };
|
||||||
|
D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E229524D2A001DA1B3 /* ListMO.swift */; };
|
||||||
|
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */; };
|
||||||
|
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */; };
|
||||||
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
|
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
|
||||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
|
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
|
||||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
|
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
|
||||||
|
@ -424,7 +428,7 @@
|
||||||
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
|
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
|
||||||
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
|
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
|
||||||
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = "<group>"; };
|
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = "<group>"; };
|
||||||
D61F759829384D4D00C0B37F /* FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = "<group>"; };
|
D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeTimelinesView.swift; sourceTree = "<group>"; };
|
||||||
D61F759A29384F9C00C0B37F /* FilterMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMO.swift; sourceTree = "<group>"; };
|
D61F759A29384F9C00C0B37F /* FilterMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMO.swift; sourceTree = "<group>"; };
|
||||||
D61F759C2938574B00C0B37F /* FilterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterRow.swift; sourceTree = "<group>"; };
|
D61F759C2938574B00C0B37F /* FilterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterRow.swift; sourceTree = "<group>"; };
|
||||||
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparator.swift; sourceTree = "<group>"; };
|
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparator.swift; sourceTree = "<group>"; };
|
||||||
|
@ -560,6 +564,10 @@
|
||||||
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
|
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
|
||||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
|
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
|
||||||
D6895DE828D962C2006341DA /* TimelineLikeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeController.swift; sourceTree = "<group>"; };
|
D6895DE828D962C2006341DA /* TimelineLikeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeController.swift; sourceTree = "<group>"; };
|
||||||
|
D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPreferences.swift; sourceTree = "<group>"; };
|
||||||
|
D68A76E229524D2A001DA1B3 /* ListMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListMO.swift; sourceTree = "<group>"; };
|
||||||
|
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelinesView.swift; sourceTree = "<group>"; };
|
||||||
|
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddHashtagPinnedTimelineView.swift; sourceTree = "<group>"; };
|
||||||
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
|
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
|
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
|
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -798,14 +806,16 @@
|
||||||
path = "Instance Cell";
|
path = "Instance Cell";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
D61F759729384D4200C0B37F /* Filters */ = {
|
D61F759729384D4200C0B37F /* Customize Timelines */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D61F759829384D4D00C0B37F /* FiltersView.swift */,
|
D61F759829384D4D00C0B37F /* CustomizeTimelinesView.swift */,
|
||||||
D61F759C2938574B00C0B37F /* FilterRow.swift */,
|
D61F759C2938574B00C0B37F /* FilterRow.swift */,
|
||||||
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */,
|
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */,
|
||||||
|
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */,
|
||||||
|
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */,
|
||||||
);
|
);
|
||||||
path = Filters;
|
path = "Customize Timelines";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
D623A53B2635F4E20095BD04 /* Poll */ = {
|
D623A53B2635F4E20095BD04 /* Poll */ = {
|
||||||
|
@ -895,6 +905,8 @@
|
||||||
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
|
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
|
||||||
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
|
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
|
||||||
D6D706A62948D4D0000827ED /* TimlineState.swift */,
|
D6D706A62948D4D0000827ED /* TimlineState.swift */,
|
||||||
|
D68A76E229524D2A001DA1B3 /* ListMO.swift */,
|
||||||
|
D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */,
|
||||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||||
);
|
);
|
||||||
|
@ -924,7 +936,7 @@
|
||||||
D6F2E960249E772F005846BB /* Crash Reporter */,
|
D6F2E960249E772F005846BB /* Crash Reporter */,
|
||||||
D627943C23A5635D00D38C68 /* Explore */,
|
D627943C23A5635D00D38C68 /* Explore */,
|
||||||
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
|
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
|
||||||
D61F759729384D4200C0B37F /* Filters */,
|
D61F759729384D4200C0B37F /* Customize Timelines */,
|
||||||
D641C788213DD86D004B4513 /* Large Image */,
|
D641C788213DD86D004B4513 /* Large Image */,
|
||||||
D627944B23A9A02400D38C68 /* Lists */,
|
D627944B23A9A02400D38C68 /* Lists */,
|
||||||
D641C782213DD7F0004B4513 /* Main */,
|
D641C782213DD7F0004B4513 /* Main */,
|
||||||
|
@ -1871,6 +1883,7 @@
|
||||||
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
|
||||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
|
||||||
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
|
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
|
||||||
|
D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */,
|
||||||
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
|
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
|
||||||
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */,
|
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */,
|
||||||
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
|
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
|
||||||
|
@ -1979,6 +1992,7 @@
|
||||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
||||||
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */,
|
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */,
|
||||||
|
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
|
||||||
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
|
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
|
||||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
||||||
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
||||||
|
@ -2006,7 +2020,7 @@
|
||||||
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
|
||||||
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
|
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
|
||||||
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
|
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
|
||||||
D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */,
|
D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */,
|
||||||
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
|
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
|
||||||
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
|
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
|
||||||
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
|
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
|
||||||
|
@ -2040,6 +2054,7 @@
|
||||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
|
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
|
||||||
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
|
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
|
||||||
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
|
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
|
||||||
|
D68A76DA29511CA6001DA1B3 /* AccountPreferences.swift in Sources */,
|
||||||
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
|
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
|
||||||
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
|
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
|
||||||
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
|
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
|
||||||
|
@ -2073,6 +2088,7 @@
|
||||||
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
|
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
|
||||||
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
||||||
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
||||||
|
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
|
||||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||||
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
||||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
||||||
|
|
|
@ -40,6 +40,7 @@ class MastodonController: ObservableObject {
|
||||||
|
|
||||||
let instanceURL: URL
|
let instanceURL: URL
|
||||||
var accountInfo: LocalData.UserAccountInfo?
|
var accountInfo: LocalData.UserAccountInfo?
|
||||||
|
var accountPreferences: AccountPreferences!
|
||||||
|
|
||||||
let client: Client!
|
let client: Client!
|
||||||
|
|
||||||
|
@ -154,6 +155,13 @@ class MastodonController: ObservableObject {
|
||||||
// are available when Filterers are constructed
|
// are available when Filterers are constructed
|
||||||
loadCachedFilters()
|
loadCachedFilters()
|
||||||
|
|
||||||
|
if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first {
|
||||||
|
accountPreferences = existing
|
||||||
|
} else {
|
||||||
|
accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext)
|
||||||
|
persistentContainer.save(context: persistentContainer.viewContext)
|
||||||
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
async let ownAccount = try getOwnAccount()
|
async let ownAccount = try getOwnAccount()
|
||||||
|
@ -317,6 +325,17 @@ class MastodonController: ObservableObject {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||||
}
|
}
|
||||||
|
let context = self.persistentContainer.backgroundContext
|
||||||
|
context.perform {
|
||||||
|
for list in lists {
|
||||||
|
if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first {
|
||||||
|
existing.updateFrom(apiList: list)
|
||||||
|
} else {
|
||||||
|
_ = ListMO(apiList: list, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.persistentContainer.save(context: context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
//
|
||||||
|
// AccountPreferences.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/19/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@objc(AccountPreferences)
|
||||||
|
public final class AccountPreferences: NSManagedObject {
|
||||||
|
|
||||||
|
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<AccountPreferences> {
|
||||||
|
let req = NSFetchRequest<AccountPreferences>(entityName: "AccountPreferences")
|
||||||
|
req.predicate = NSPredicate(format: "accountID = %@", account.id)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var accountID: String
|
||||||
|
@NSManaged var pinnedTimelinesData: Data?
|
||||||
|
|
||||||
|
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: [])
|
||||||
|
var pinnedTimelines: [Timeline]
|
||||||
|
|
||||||
|
static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
|
||||||
|
let prefs = AccountPreferences(context: context)
|
||||||
|
prefs.accountID = account.id
|
||||||
|
prefs.pinnedTimelines = [.home, .public(local: true), .public(local: false)]
|
||||||
|
return prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
//
|
||||||
|
// ListMO.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/20/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@objc(ListMO)
|
||||||
|
public final class ListMO: NSManagedObject {
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<ListMO> {
|
||||||
|
return NSFetchRequest(entityName: "List")
|
||||||
|
}
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest(id: String) -> NSFetchRequest<ListMO> {
|
||||||
|
let req = NSFetchRequest<ListMO>(entityName: "List")
|
||||||
|
req.predicate = NSPredicate(format: "id = %@", id)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var id: String
|
||||||
|
@NSManaged public var title: String
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ListMO {
|
||||||
|
convenience init(apiList list: List, context: NSManagedObjectContext) {
|
||||||
|
self.init(context: context)
|
||||||
|
self.updateFrom(apiList: list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFrom(apiList list: List) {
|
||||||
|
self.id = list.id
|
||||||
|
self.title = list.title
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,10 +12,11 @@ import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
import OSLog
|
import OSLog
|
||||||
import Sentry
|
import Sentry
|
||||||
|
import CloudKit
|
||||||
|
|
||||||
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
|
fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentStore")
|
||||||
|
|
||||||
class MastodonCachePersistentStore: NSPersistentContainer {
|
class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
|
|
||||||
private static let managedObjectModel: NSManagedObjectModel = {
|
private static let managedObjectModel: NSManagedObjectModel = {
|
||||||
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
|
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
|
||||||
|
@ -38,6 +39,9 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
return context
|
return context
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private var remoteChangeHandlerQueue = DispatchQueue(label: "PersistentStore remote changes")
|
||||||
|
private var lastRemoteChangeToken: NSPersistentHistoryToken?
|
||||||
|
|
||||||
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
|
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
|
||||||
// would need to audit existing uses to make sure everything happens on the main thread
|
// would need to audit existing uses to make sure everything happens on the main thread
|
||||||
// and when updating things on the background context would need to switch to main, refetch, and then publish
|
// and when updating things on the background context would need to switch to main, refetch, and then publish
|
||||||
|
@ -46,6 +50,9 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
let relationshipSubject = PassthroughSubject<String, Never>()
|
let relationshipSubject = PassthroughSubject<String, Never>()
|
||||||
|
|
||||||
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
|
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
|
||||||
|
let group = DispatchGroup()
|
||||||
|
var instancesToMigrate: [URL]? = nil
|
||||||
|
var hashtagsToMigrate: [Hashtag]? = nil
|
||||||
if transient {
|
if transient {
|
||||||
super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
||||||
|
|
||||||
|
@ -55,6 +62,28 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
} else {
|
} else {
|
||||||
super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
||||||
|
|
||||||
|
var localStoreLocation = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
localStoreLocation.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
|
||||||
|
let localStoreDescription = NSPersistentStoreDescription(url: localStoreLocation)
|
||||||
|
localStoreDescription.configuration = "Local"
|
||||||
|
localStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||||
|
localStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||||
|
|
||||||
|
var cloudStoreLocation = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
cloudStoreLocation.appendPathComponent("cloud.sqlite", isDirectory: false)
|
||||||
|
let cloudStoreDescription = NSPersistentStoreDescription(url: cloudStoreLocation)
|
||||||
|
cloudStoreDescription.configuration = "Cloud"
|
||||||
|
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.space.vaccor.Tusker")
|
||||||
|
options.databaseScope = .private
|
||||||
|
cloudStoreDescription.cloudKitContainerOptions = options
|
||||||
|
cloudStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||||
|
cloudStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||||
|
|
||||||
|
persistentStoreDescriptions = [
|
||||||
|
cloudStoreDescription,
|
||||||
|
localStoreDescription,
|
||||||
|
]
|
||||||
|
|
||||||
// workaround for migrating from using id in name to persistenceKey
|
// workaround for migrating from using id in name to persistenceKey
|
||||||
// can be removed after a sufficient time has passed
|
// can be removed after a sufficient time has passed
|
||||||
if accountInfo!.id.contains("/") {
|
if accountInfo!.id.contains("/") {
|
||||||
|
@ -82,19 +111,70 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrate saved data from local store to cloud store
|
||||||
|
// this can be removed pre-app store release
|
||||||
|
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
|
||||||
|
if FileManager.default.fileExists(atPath: defaultPath.path) {
|
||||||
|
group.enter()
|
||||||
|
let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
|
||||||
|
defaultDesc.configuration = "Default"
|
||||||
|
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
||||||
|
defaultPSC.persistentStoreDescriptions = [defaultDesc]
|
||||||
|
defaultPSC.loadPersistentStores { _, error in
|
||||||
|
guard error == nil else {
|
||||||
|
group.leave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaultPSC.performBackgroundTask { context in
|
||||||
|
if let instances = try? context.fetch(SavedInstance.fetchRequestWithoutAccountForMigrating()) {
|
||||||
|
instancesToMigrate = instances.map(\.url)
|
||||||
|
instances.forEach(context.delete(_:))
|
||||||
|
}
|
||||||
|
if let hashtags = try? context.fetch(SavedHashtag.fetchRequestWithoutAccountForMigrating()) {
|
||||||
|
hashtagsToMigrate = hashtags.map { Hashtag(name: $0.name, url: $0.url) }
|
||||||
|
hashtags.forEach(context.delete(_:))
|
||||||
|
}
|
||||||
|
if context.hasChanges {
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
group.wait()
|
||||||
loadPersistentStores { (description, error) in
|
loadPersistentStores { (description, error) in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
logger.error("Unable to load persistent store: \(String(describing: error), privacy: .public)")
|
logger.error("Unable to load persistent store: \(String(describing: error), privacy: .public)")
|
||||||
fatalError("Unable to load persistent store")
|
fatalError("Unable to load persistent store")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if description.configuration == "Cloud" {
|
||||||
|
self.backgroundContext.perform {
|
||||||
|
instancesToMigrate?.forEach({ url in
|
||||||
|
_ = SavedInstance(url: url, account: accountInfo!, context: self.backgroundContext)
|
||||||
|
})
|
||||||
|
hashtagsToMigrate?.forEach({ hashtag in
|
||||||
|
_ = SavedHashtag(hashtag: hashtag, account: accountInfo!, context: self.backgroundContext)
|
||||||
|
})
|
||||||
|
self.save(context: self.backgroundContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changes to the Cloud CD model in development need this to be uncommented to update the CK schema
|
||||||
|
// #if DEBUG
|
||||||
|
// try! initializeCloudKitSchema(options: [])
|
||||||
|
// #endif
|
||||||
|
|
||||||
viewContext.automaticallyMergesChangesFromParent = true
|
viewContext.automaticallyMergesChangesFromParent = true
|
||||||
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(context: NSManagedObjectContext) {
|
func save(context: NSManagedObjectContext) {
|
||||||
|
@ -403,4 +483,45 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the remote change notifications only handle deletes, inserts get handled by the regular managed object did change notifications
|
||||||
|
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
||||||
|
guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remoteChangeHandlerQueue.async {
|
||||||
|
defer {
|
||||||
|
self.lastRemoteChangeToken = token
|
||||||
|
}
|
||||||
|
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
|
||||||
|
self.backgroundContext.performAndWait {
|
||||||
|
if let result = try? self.backgroundContext.execute(req) as? NSPersistentHistoryResult,
|
||||||
|
let transactions = result.result as? [NSPersistentHistoryTransaction] {
|
||||||
|
var changes: (hashtags: Bool, instances: Bool) = (false, false)
|
||||||
|
outer: for transaction in transactions {
|
||||||
|
for change in transaction.changes ?? [] {
|
||||||
|
if change.changedObjectID.entity.name == "SavedHashtag" {
|
||||||
|
changes.hashtags = true
|
||||||
|
} else if change.changedObjectID.entity.name == "SavedInstance" {
|
||||||
|
changes.instances = true
|
||||||
|
}
|
||||||
|
if changes.hashtags && changes.instances {
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changes.hashtags {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changes.instances {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,24 +14,32 @@ import WebURLFoundationExtras
|
||||||
@objc(SavedHashtag)
|
@objc(SavedHashtag)
|
||||||
public final class SavedHashtag: NSManagedObject {
|
public final class SavedHashtag: NSManagedObject {
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<SavedHashtag> {
|
@nonobjc class func fetchRequestWithoutAccountForMigrating() -> NSFetchRequest<SavedHashtag> {
|
||||||
return NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
|
return NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
|
||||||
}
|
}
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<SavedHashtag> {
|
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedHashtag> {
|
||||||
let req = NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
|
let req = NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
|
||||||
req.predicate = NSPredicate(format: "name LIKE[cd] %@", name)
|
req.predicate = NSPredicate(format: "accountID = %@", account.id)
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@nonobjc class func fetchRequest(name: String, account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedHashtag> {
|
||||||
|
let req = NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
|
||||||
|
req.predicate = NSPredicate(format: "name LIKE[cd] %@ AND accountID = %@", name, account.id)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var accountID: String
|
||||||
@NSManaged public var name: String
|
@NSManaged public var name: String
|
||||||
@NSManaged public var url: URL
|
@NSManaged public var url: URL
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SavedHashtag {
|
extension SavedHashtag {
|
||||||
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
|
convenience init(hashtag: Hashtag, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) {
|
||||||
self.init(context: context)
|
self.init(context: context)
|
||||||
|
self.accountID = account.id
|
||||||
self.name = hashtag.name
|
self.name = hashtag.name
|
||||||
self.url = URL(hashtag.url)!
|
self.url = URL(hashtag.url)!
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,23 +12,31 @@ import CoreData
|
||||||
@objc(SavedInstance)
|
@objc(SavedInstance)
|
||||||
public final class SavedInstance: NSManagedObject {
|
public final class SavedInstance: NSManagedObject {
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<SavedInstance> {
|
@nonobjc class func fetchRequestWithoutAccountForMigrating() -> NSFetchRequest<SavedInstance> {
|
||||||
return NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
|
return NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
|
||||||
}
|
}
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest(url: URL) -> NSFetchRequest<SavedInstance> {
|
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedInstance> {
|
||||||
let req = fetchRequest()
|
let req = NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
|
||||||
req.predicate = NSPredicate(format: "url = %@", url as NSURL)
|
req.predicate = NSPredicate(format: "accountID = %@", account.id)
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@nonobjc class func fetchRequest(url: URL, account: LocalData.UserAccountInfo) -> NSFetchRequest<SavedInstance> {
|
||||||
|
let req = NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
|
||||||
|
req.predicate = NSPredicate(format: "url = %@ AND accountID = %@", url as NSURL, account.id)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var accountID: String
|
||||||
@NSManaged public var url: URL
|
@NSManaged public var url: URL
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SavedInstance {
|
extension SavedInstance {
|
||||||
convenience init(url: URL, context: NSManagedObjectContext) {
|
convenience init(url: URL, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) {
|
||||||
self.init(context: context)
|
self.init(context: context)
|
||||||
|
self.accountID = account.id
|
||||||
self.url = url
|
self.url = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
||||||
<attribute name="acct" attributeType="String"/>
|
<attribute name="acct" attributeType="String"/>
|
||||||
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
||||||
|
@ -28,6 +28,10 @@
|
||||||
</uniquenessConstraint>
|
</uniquenessConstraint>
|
||||||
</uniquenessConstraints>
|
</uniquenessConstraints>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="AccountPreferences" representedClassName="AccountPreferences" syncable="YES">
|
||||||
|
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
|
||||||
|
</entity>
|
||||||
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
|
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
|
||||||
<attribute name="action" attributeType="String" defaultValueString="warn"/>
|
<attribute name="action" attributeType="String" defaultValueString="warn"/>
|
||||||
<attribute name="context" attributeType="String"/>
|
<attribute name="context" attributeType="String"/>
|
||||||
|
@ -46,6 +50,15 @@
|
||||||
<attribute name="name" attributeType="String"/>
|
<attribute name="name" attributeType="String"/>
|
||||||
<attribute name="url" attributeType="URI"/>
|
<attribute name="url" attributeType="URI"/>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="List" representedClassName="ListMO" syncable="YES">
|
||||||
|
<attribute name="id" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="title" optional="YES" attributeType="String"/>
|
||||||
|
<uniquenessConstraints>
|
||||||
|
<uniquenessConstraint>
|
||||||
|
<constraint value="id"/>
|
||||||
|
</uniquenessConstraint>
|
||||||
|
</uniquenessConstraints>
|
||||||
|
</entity>
|
||||||
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
|
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
|
||||||
<attribute name="accountID" optional="YES" attributeType="String"/>
|
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
@ -60,21 +73,13 @@
|
||||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/>
|
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="SavedHashtag" representedClassName="SavedHashtag" syncable="YES">
|
<entity name="SavedHashtag" representedClassName="SavedHashtag" syncable="YES">
|
||||||
<attribute name="name" attributeType="String"/>
|
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="url" attributeType="URI"/>
|
<attribute name="name" optional="YES" attributeType="String"/>
|
||||||
<uniquenessConstraints>
|
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||||
<uniquenessConstraint>
|
|
||||||
<constraint value="name"/>
|
|
||||||
</uniquenessConstraint>
|
|
||||||
</uniquenessConstraints>
|
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="SavedInstance" representedClassName="SavedInstance" syncable="YES">
|
<entity name="SavedInstance" representedClassName="SavedInstance" syncable="YES">
|
||||||
<attribute name="url" attributeType="URI"/>
|
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||||
<uniquenessConstraints>
|
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||||
<uniquenessConstraint>
|
|
||||||
<constraint value="url"/>
|
|
||||||
</uniquenessConstraint>
|
|
||||||
</uniquenessConstraints>
|
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="Status" representedClassName="StatusMO" syncable="YES">
|
<entity name="Status" representedClassName="StatusMO" syncable="YES">
|
||||||
<attribute name="applicationName" optional="YES" attributeType="String"/>
|
<attribute name="applicationName" optional="YES" attributeType="String"/>
|
||||||
|
@ -117,4 +122,19 @@
|
||||||
<attribute name="timelineKind" attributeType="String" valueTransformerName="pachydermTimeline" customClassName="Tusker.TimelineContainer"/>
|
<attribute name="timelineKind" attributeType="String" valueTransformerName="pachydermTimeline" customClassName="Tusker.TimelineContainer"/>
|
||||||
<relationship name="statuses" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="Status" inverseName="timelines" inverseEntity="Status"/>
|
<relationship name="statuses" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="Status" inverseName="timelines" inverseEntity="Status"/>
|
||||||
</entity>
|
</entity>
|
||||||
|
<configuration name="Cloud" usedWithCloudKit="YES">
|
||||||
|
<memberEntity name="SavedHashtag"/>
|
||||||
|
<memberEntity name="SavedInstance"/>
|
||||||
|
<memberEntity name="AccountPreferences"/>
|
||||||
|
</configuration>
|
||||||
|
<configuration name="Local">
|
||||||
|
<memberEntity name="Account"/>
|
||||||
|
<memberEntity name="Filter"/>
|
||||||
|
<memberEntity name="FilterKeyword"/>
|
||||||
|
<memberEntity name="FollowedHashtag"/>
|
||||||
|
<memberEntity name="Relationship"/>
|
||||||
|
<memberEntity name="Status"/>
|
||||||
|
<memberEntity name="TimelineState"/>
|
||||||
|
<memberEntity name="List"/>
|
||||||
|
</configuration>
|
||||||
</model>
|
</model>
|
|
@ -25,18 +25,22 @@ extension Timeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tabBarImage: UIImage? {
|
var image: UIImage {
|
||||||
switch self {
|
switch self {
|
||||||
case .home:
|
case .home:
|
||||||
return UIImage(systemName: "house.fill")
|
return UIImage(systemName: "house.fill")!
|
||||||
case let .public(local):
|
case let .public(local):
|
||||||
if local {
|
if local {
|
||||||
return UIImage(systemName: "person.and.person.fill")
|
return UIImage(systemName: "person.and.person.fill")!
|
||||||
} else {
|
} else {
|
||||||
return UIImage(systemName: "globe")
|
return UIImage(systemName: "globe")!
|
||||||
}
|
}
|
||||||
default:
|
case .list(id: _):
|
||||||
return nil
|
return UIImage(systemName: "list.bullet")!
|
||||||
|
case .tag(hashtag: _):
|
||||||
|
return UIImage(systemName: "number")!
|
||||||
|
case .direct:
|
||||||
|
return UIImage(systemName: "enveloep.fill")!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,37 +2,6 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>SentryDSN</key>
|
|
||||||
<string>$(SENTRY_DSN)</string>
|
|
||||||
<key>OSLogPreferences</key>
|
|
||||||
<dict>
|
|
||||||
<key>space.vaccor.Tusker</key>
|
|
||||||
<dict>
|
|
||||||
<key>DEFAULT-OPTIONS</key>
|
|
||||||
<dict>
|
|
||||||
<key>TTL</key>
|
|
||||||
<dict>
|
|
||||||
<key>Fault</key>
|
|
||||||
<integer>30</integer>
|
|
||||||
<key>Error</key>
|
|
||||||
<integer>30</integer>
|
|
||||||
<key>Debug</key>
|
|
||||||
<integer>15</integer>
|
|
||||||
<key>Info</key>
|
|
||||||
<integer>30</integer>
|
|
||||||
<key>Default</key>
|
|
||||||
<integer>30</integer>
|
|
||||||
</dict>
|
|
||||||
<key>Level</key>
|
|
||||||
<dict>
|
|
||||||
<key>Persist</key>
|
|
||||||
<string>Debug</string>
|
|
||||||
<key>Enable</key>
|
|
||||||
<string>Debug</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
|
@ -87,7 +56,7 @@
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Post videos from the camera.</string>
|
<string>Post videos from the camera.</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<string>Save photos directly from other people's posts.</string>
|
<string>Save photos directly from other people's posts.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Post photos from the photo library.</string>
|
<string>Post photos from the photo library.</string>
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
|
@ -102,6 +71,37 @@
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>OSLogPreferences</key>
|
||||||
|
<dict>
|
||||||
|
<key>space.vaccor.Tusker</key>
|
||||||
|
<dict>
|
||||||
|
<key>DEFAULT-OPTIONS</key>
|
||||||
|
<dict>
|
||||||
|
<key>Level</key>
|
||||||
|
<dict>
|
||||||
|
<key>Enable</key>
|
||||||
|
<string>Debug</string>
|
||||||
|
<key>Persist</key>
|
||||||
|
<string>Debug</string>
|
||||||
|
</dict>
|
||||||
|
<key>TTL</key>
|
||||||
|
<dict>
|
||||||
|
<key>Debug</key>
|
||||||
|
<integer>15</integer>
|
||||||
|
<key>Default</key>
|
||||||
|
<integer>30</integer>
|
||||||
|
<key>Error</key>
|
||||||
|
<integer>30</integer>
|
||||||
|
<key>Fault</key>
|
||||||
|
<integer>30</integer>
|
||||||
|
<key>Info</key>
|
||||||
|
<integer>30</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>SentryDSN</key>
|
||||||
|
<string>$(SENTRY_DSN)</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
@ -140,6 +140,7 @@
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
|
|
|
@ -400,7 +400,8 @@ struct ComposeAutocompleteHashtagsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) {
|
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) {
|
||||||
let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? [])
|
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
|
||||||
|
let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [])
|
||||||
.map { Hashtag(name: $0.name, url: $0.url) }
|
.map { Hashtag(name: $0.name, url: $0.url) }
|
||||||
|
|
||||||
hashtags = (searchResults + savedTags + trendingTags)
|
hashtags = (searchResults + savedTags + trendingTags)
|
||||||
|
|
|
@ -0,0 +1,113 @@
|
||||||
|
//
|
||||||
|
// AddHashtagPinnedTimelineView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/20/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
struct AddHashtagPinnedTimelineView: View {
|
||||||
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@Binding var pinnedTimelines: [Timeline]
|
||||||
|
@StateObject private var viewModel = SearchViewModel()
|
||||||
|
@State private var searchTask: Task<Void, Never>?
|
||||||
|
@State private var isSearching = false
|
||||||
|
@State private var searchResults: [String] = []
|
||||||
|
|
||||||
|
private var savedAndFollowedHashtags: [String] {
|
||||||
|
var tags = Set<String>()
|
||||||
|
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
|
||||||
|
for saved in (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? [] {
|
||||||
|
tags.insert(saved.name)
|
||||||
|
}
|
||||||
|
for followed in mastodonController.followedHashtags {
|
||||||
|
tags.insert(followed.name)
|
||||||
|
}
|
||||||
|
return Array(tags).sorted(using: SemiCaseSensitiveComparator())
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
list
|
||||||
|
.navigationTitle("Search")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $viewModel.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Search for hashtags"))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
|
.onReceive(viewModel.$searchQuery, perform: { newValue in
|
||||||
|
isSearching = !newValue.isEmpty
|
||||||
|
})
|
||||||
|
.onReceive(viewModel.$searchQuery.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main), perform: { _ in
|
||||||
|
searchTask?.cancel()
|
||||||
|
searchTask = Task {
|
||||||
|
try? await updateSearchResults()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var list: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
if viewModel.searchQuery.isEmpty {
|
||||||
|
forEachTag(savedAndFollowedHashtags)
|
||||||
|
} else {
|
||||||
|
forEachTag(searchResults)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.opacity(isSearching ? 1 : 0)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func forEachTag(_ tags: [String]) -> some View {
|
||||||
|
ForEach(tags, id: \.self) { tag in
|
||||||
|
Button {
|
||||||
|
pinnedTimelines.append(.tag(hashtag: tag))
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text("#\(tag)")
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.disabled(pinnedTimelines.contains(.tag(hashtag: tag)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSearchResults() async throws {
|
||||||
|
guard !viewModel.searchQuery.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSearching = true
|
||||||
|
let req = Client.search(query: viewModel.searchQuery, types: [.hashtags])
|
||||||
|
let (results, _) = try await mastodonController.run(req)
|
||||||
|
searchResults = results.hashtags.map(\.name)
|
||||||
|
isSearching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SearchViewModel: ObservableObject {
|
||||||
|
@Published var searchQuery = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
//struct AddHashtagPinnedTimelineView_Previews: PreviewProvider {
|
||||||
|
// static var previews: some View {
|
||||||
|
// AddHashtagPinnedTimelineView()
|
||||||
|
// }
|
||||||
|
//}
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// FiltersView.swift
|
// CustomizeTimelinesView.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 11/30/22.
|
// Created by Shadowfacts on 11/30/22.
|
||||||
|
@ -9,18 +9,18 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
struct FiltersView: View {
|
struct CustomizeTimelinesView: View {
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
FiltersList()
|
CustomizeTimelinesList()
|
||||||
.environmentObject(mastodonController)
|
.environmentObject(mastodonController)
|
||||||
.environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext)
|
.environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FiltersList: View {
|
struct CustomizeTimelinesList: View {
|
||||||
@EnvironmentObject private var mastodonController: MastodonController
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
@ObservedObject private var preferences = Preferences.shared
|
@ObservedObject private var preferences = Preferences.shared
|
||||||
@FetchRequest(sortDescriptors: []) private var filters: FetchedResults<FilterMO>
|
@FetchRequest(sortDescriptors: []) private var filters: FetchedResults<FilterMO>
|
||||||
|
@ -50,6 +50,8 @@ struct FiltersList: View {
|
||||||
|
|
||||||
private var navigationBody: some View {
|
private var navigationBody: some View {
|
||||||
List {
|
List {
|
||||||
|
PinnedTimelinesView(accountPreferences: mastodonController.accountPreferences)
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: $preferences.hideReblogsInTimelines) {
|
Toggle(isOn: $preferences.hideReblogsInTimelines) {
|
||||||
Text("Hide Reblogs")
|
Text("Hide Reblogs")
|
||||||
|
@ -62,18 +64,27 @@ struct FiltersList: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
filtersForEach(unexpiredFilters)
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
EditFilterView(filter: EditedFilter(), create: true, originallyExpired: false)
|
EditFilterView(filter: EditedFilter(), create: true, originallyExpired: false)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Add Filter", systemImage: "plus")
|
Label("Add Filter", systemImage: "plus")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Active Filters")
|
||||||
}
|
}
|
||||||
|
|
||||||
filtersSection(unexpiredFilters, header: Text("Active"))
|
if !expiredFilters.isEmpty {
|
||||||
filtersSection(expiredFilters, header: Text("Expired"))
|
Section {
|
||||||
|
filtersForEach(expiredFilters)
|
||||||
|
} header: {
|
||||||
|
Text("Expired Filters")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(Text("Filters"))
|
.navigationTitle(Text("Customize Timelines"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
@ -95,30 +106,26 @@ struct FiltersList: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func filtersSection(_ filters: [FilterMO], header: some View) -> some View {
|
private func filtersForEach(_ filters: [FilterMO]) -> some View {
|
||||||
if !filters.isEmpty {
|
if !filters.isEmpty {
|
||||||
Section {
|
ForEach(filters, id: \.id) { filter in
|
||||||
ForEach(filters, id: \.id) { filter in
|
NavigationLink {
|
||||||
NavigationLink {
|
EditFilterView(filter: EditedFilter(filter), create: false, originallyExpired: filter.expiresAt != nil && filter.expiresAt! <= Date())
|
||||||
EditFilterView(filter: EditedFilter(filter), create: false, originallyExpired: filter.expiresAt != nil && filter.expiresAt! <= Date())
|
} label: {
|
||||||
} label: {
|
FilterRow(filter: filter)
|
||||||
FilterRow(filter: filter)
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
deleteFilter(filter)
|
|
||||||
} label: {
|
|
||||||
Label("Delete Filter", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onDelete { indices in
|
.contextMenu {
|
||||||
for filter in indices.map({ filters[$0] }) {
|
Button(role: .destructive) {
|
||||||
deleteFilter(filter)
|
deleteFilter(filter)
|
||||||
|
} label: {
|
||||||
|
Label("Delete Filter", systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
}
|
||||||
header
|
.onDelete { indices in
|
||||||
|
for filter in indices.map({ filters[$0] }) {
|
||||||
|
deleteFilter(filter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -108,7 +108,6 @@ struct EditFilterView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Toggle("Expires", isOn: expires)
|
Toggle("Expires", isOn: expires)
|
||||||
|
|
||||||
if expires.wrappedValue {
|
if expires.wrappedValue {
|
||||||
|
@ -143,7 +142,7 @@ struct EditFilterView: View {
|
||||||
Text("Contexts")
|
Text("Contexts")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Edit Filter")
|
.navigationTitle(create ? "Add Filter" : "Edit Filter")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
@ -151,7 +150,7 @@ struct EditFilterView: View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
} else {
|
} else {
|
||||||
Button(create ? "Create" : "Save") {
|
Button("Save") {
|
||||||
saveFilter()
|
saveFilter()
|
||||||
}
|
}
|
||||||
.disabled(!filter.isValid(for: mastodonController) || (!edited && originallyExpired))
|
.disabled(!filter.isValid(for: mastodonController) || (!edited && originallyExpired))
|
|
@ -0,0 +1,130 @@
|
||||||
|
//
|
||||||
|
// PinnedTimelinesView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/20/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
struct PinnedTimelinesView: View {
|
||||||
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
|
@ObservedObject private var accountPreferences: AccountPreferences
|
||||||
|
|
||||||
|
@State private var isShowingAddHashtagSheet = false
|
||||||
|
// store this separately from AccountPreferences in the view, b/c the @LazilyDecoding wrapper breaks animations
|
||||||
|
@State private var pinnedTimelines: [Timeline]
|
||||||
|
|
||||||
|
init(accountPreferences: AccountPreferences) {
|
||||||
|
self.accountPreferences = accountPreferences
|
||||||
|
self.pinnedTimelines = accountPreferences.pinnedTimelines
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
ForEach(pinnedTimelines, id: \.id) { timeline in
|
||||||
|
HStack {
|
||||||
|
Label {
|
||||||
|
if case .list(id: let id) = timeline,
|
||||||
|
let list = mastodonController.lists.first(where: { $0.id == id }) {
|
||||||
|
Text(list.title)
|
||||||
|
} else if case .tag(hashtag: let tag) = timeline {
|
||||||
|
Text(tag)
|
||||||
|
} else {
|
||||||
|
Text(timeline.title)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(uiImage: timeline.image.withRenderingMode(.alwaysTemplate))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "line.3.horizontal")
|
||||||
|
.foregroundColor(Color(.lightGray))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onMove { indices, newOffset in
|
||||||
|
pinnedTimelines.move(fromOffsets: indices, toOffset: newOffset)
|
||||||
|
}
|
||||||
|
.onDelete { indices in
|
||||||
|
pinnedTimelines.remove(atOffsets: indices)
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
ForEach([Timeline.home, .public(local: true), .public(local: false)], id: \.id) { timeline in
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
pinnedTimelines.append(timeline)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label {
|
||||||
|
Text(timeline.title)
|
||||||
|
} icon: {
|
||||||
|
Image(uiImage: timeline.image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(pinnedTimelines.contains(timeline))
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu("List…") {
|
||||||
|
ForEach(mastodonController.lists, id: \.id) { list in
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
pinnedTimelines.append(list.timeline)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(list.title)
|
||||||
|
}
|
||||||
|
.disabled(pinnedTimelines.contains(list.timeline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
isShowingAddHashtagSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Hashtag…", systemImage: "number")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Add…", systemImage: "plus")
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
|
} header: {
|
||||||
|
Text("Pinned Timelines")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isShowingAddHashtagSheet, content: {
|
||||||
|
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||||
|
})
|
||||||
|
.onReceive(accountPreferences.publisher(for: \.pinnedTimelinesData)) { _ in
|
||||||
|
if pinnedTimelines != accountPreferences.pinnedTimelines {
|
||||||
|
pinnedTimelines = accountPreferences.pinnedTimelines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: pinnedTimelines) { newValue in
|
||||||
|
if accountPreferences.pinnedTimelines != newValue {
|
||||||
|
accountPreferences.pinnedTimelines = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate extension Timeline {
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .home:
|
||||||
|
return "home"
|
||||||
|
case .public(local: let local):
|
||||||
|
return "public:\(local)"
|
||||||
|
case .list(id: let id):
|
||||||
|
return "list:\(id)"
|
||||||
|
case .tag(hashtag: let tag):
|
||||||
|
return "tag:\(tag)"
|
||||||
|
case .direct:
|
||||||
|
return "direct"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -206,7 +206,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
|
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
|
||||||
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []
|
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
|
||||||
|
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []
|
||||||
var items = saved.map {
|
var items = saved.map {
|
||||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||||
}
|
}
|
||||||
|
@ -219,7 +220,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func fetchSavedInstances() -> [SavedInstance] {
|
private func fetchSavedInstances() -> [SavedInstance] {
|
||||||
let req = SavedInstance.fetchRequest()
|
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
|
||||||
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
|
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
|
||||||
do {
|
do {
|
||||||
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
||||||
|
@ -278,7 +279,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
func removeSavedHashtag(_ hashtag: Hashtag) {
|
func removeSavedHashtag(_ hashtag: Hashtag) {
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
if let hashtag = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
|
let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)
|
||||||
|
if let hashtag = try? context.fetch(req).first {
|
||||||
context.delete(hashtag)
|
context.delete(hashtag)
|
||||||
try! context.save()
|
try! context.save()
|
||||||
}
|
}
|
||||||
|
@ -286,7 +288,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
func removeSavedInstance(_ instanceURL: URL) {
|
func removeSavedInstance(_ instanceURL: URL) {
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
if let instance = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first {
|
let req = SavedInstance.fetchRequest(url: instanceURL, account: mastodonController.accountInfo!)
|
||||||
|
if let instance = try? context.fetch(req).first {
|
||||||
context.delete(instance)
|
context.delete(instance)
|
||||||
try! context.save()
|
try! context.save()
|
||||||
}
|
}
|
||||||
|
|
|
@ -232,7 +232,8 @@ class MainSidebarViewController: UIViewController {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
|
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
|
||||||
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []
|
let req = SavedHashtag.fetchRequest(account: mastodonController.accountInfo!)
|
||||||
|
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(req)) ?? []
|
||||||
var items = saved.map {
|
var items = saved.map {
|
||||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||||
}
|
}
|
||||||
|
@ -245,7 +246,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func fetchSavedInstances() -> [SavedInstance] {
|
private func fetchSavedInstances() -> [SavedInstance] {
|
||||||
let req = SavedInstance.fetchRequest()
|
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
|
||||||
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
|
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
|
||||||
do {
|
do {
|
||||||
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
||||||
|
|
|
@ -11,9 +11,6 @@ import Pachyderm
|
||||||
|
|
||||||
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> {
|
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> {
|
||||||
|
|
||||||
private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title")
|
|
||||||
private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title")
|
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
var initialMode: NotificationsMode?
|
var initialMode: NotificationsMode?
|
||||||
|
@ -22,20 +19,14 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
|
||||||
self.initialMode = initialMode
|
self.initialMode = initialMode
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
let notifications = NotificationsTableViewController(allowedTypes: Pachyderm.Notification.Kind.allCases, mastodonController: mastodonController)
|
super.init(pages: [.all, .mentions]) { page in
|
||||||
notifications.title = notificationsTitle
|
let vc = NotificationsTableViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
|
||||||
notifications.userActivity = UserActivityManager.checkNotificationsActivity(mode: .allNotifications)
|
vc.title = page.title
|
||||||
|
vc.userActivity = page.userActivity
|
||||||
|
return vc
|
||||||
|
}
|
||||||
|
|
||||||
let mentions = NotificationsTableViewController(allowedTypes: [.mention], mastodonController: mastodonController)
|
title = Page.all.title
|
||||||
mentions.title = mentionsTitle
|
|
||||||
mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
|
|
||||||
|
|
||||||
super.init(pages: [
|
|
||||||
(.all, notificationsTitle, notifications),
|
|
||||||
(.mentions, mentionsTitle, mentions),
|
|
||||||
])
|
|
||||||
|
|
||||||
title = notificationsTitle
|
|
||||||
tabBarItem.image = UIImage(systemName: "bell.fill")
|
tabBarItem.image = UIImage(systemName: "bell.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,9 +52,40 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
|
||||||
selectPage(page, animated: false)
|
selectPage(page, animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Page {
|
enum Page: SegmentedPageViewControllerPage {
|
||||||
case all
|
case all
|
||||||
case mentions
|
case mentions
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .all:
|
||||||
|
return NSLocalizedString("Notifications", comment: "notifications tab title")
|
||||||
|
case .mentions:
|
||||||
|
return NSLocalizedString("Mentions", comment: "mentions tab title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var segmentedControlTitle: String {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedTypes: [Pachyderm.Notification.Kind] {
|
||||||
|
switch self {
|
||||||
|
case .all:
|
||||||
|
return Pachyderm.Notification.Kind.allCases
|
||||||
|
case .mentions:
|
||||||
|
return [.mention]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var userActivity: NSUserActivity {
|
||||||
|
switch self {
|
||||||
|
case .all:
|
||||||
|
return UserActivityManager.checkNotificationsActivity(mode: .allNotifications)
|
||||||
|
case .mentions:
|
||||||
|
return UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,7 @@ class OnboardingViewController: UINavigationController {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func tryLoginTo(instanceURL: URL) async throws {
|
private func tryLoginTo(instanceURL: URL) async throws {
|
||||||
let mastodonController = MastodonController(instanceURL: instanceURL)
|
let mastodonController = MastodonController(instanceURL: instanceURL, transient: true)
|
||||||
let clientID: String
|
let clientID: String
|
||||||
let clientSecret: String
|
let clientSecret: String
|
||||||
do {
|
do {
|
||||||
|
|
|
@ -16,7 +16,8 @@ class HashtagTimelineViewController: TimelineViewController {
|
||||||
var toggleSaveButton: UIBarButtonItem!
|
var toggleSaveButton: UIBarButtonItem!
|
||||||
|
|
||||||
private var isHashtagSaved: Bool {
|
private var isHashtagSaved: Bool {
|
||||||
mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name))
|
let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)
|
||||||
|
return mastodonController.persistentContainer.viewContext.objectExists(for: req)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isHashtagFollowed: Bool {
|
private var isHashtagFollowed: Bool {
|
||||||
|
@ -47,10 +48,10 @@ class HashtagTimelineViewController: TimelineViewController {
|
||||||
|
|
||||||
private func toggleSave() {
|
private func toggleSave() {
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
|
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)).first {
|
||||||
context.delete(existing)
|
context.delete(existing)
|
||||||
} else {
|
} else {
|
||||||
_ = SavedHashtag(hashtag: hashtag, context: context)
|
_ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context)
|
||||||
}
|
}
|
||||||
mastodonController.persistentContainer.save(context: context)
|
mastodonController.persistentContainer.save(context: context)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,8 @@ class InstanceTimelineViewController: TimelineViewController {
|
||||||
private var toggleSaveButton: UIBarButtonItem!
|
private var toggleSaveButton: UIBarButtonItem!
|
||||||
|
|
||||||
private var isInstanceSaved: Bool {
|
private var isInstanceSaved: Bool {
|
||||||
parentMastodonController!.persistentContainer.viewContext.objectExists(for: SavedInstance.fetchRequest(url: instanceURL))
|
let req = SavedInstance.fetchRequest(url: instanceURL, account: parentMastodonController!.accountInfo!)
|
||||||
|
return parentMastodonController!.persistentContainer.viewContext.objectExists(for: req)
|
||||||
}
|
}
|
||||||
private var toggleSaveButtonTitle: String {
|
private var toggleSaveButtonTitle: String {
|
||||||
if isInstanceSaved {
|
if isInstanceSaved {
|
||||||
|
@ -83,12 +84,13 @@ class InstanceTimelineViewController: TimelineViewController {
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
@objc func toggleSaveButtonPressed() {
|
@objc func toggleSaveButtonPressed() {
|
||||||
let context = parentMastodonController!.persistentContainer.viewContext
|
let context = parentMastodonController!.persistentContainer.viewContext
|
||||||
let existing = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first
|
let req = SavedInstance.fetchRequest(url: instanceURL, account: parentMastodonController!.accountInfo!)
|
||||||
|
let existing = try? context.fetch(req).first
|
||||||
if let existing = existing {
|
if let existing = existing {
|
||||||
context.delete(existing)
|
context.delete(existing)
|
||||||
delegate?.didUnsaveInstance(url: instanceURL)
|
delegate?.didUnsaveInstance(url: instanceURL)
|
||||||
} else {
|
} else {
|
||||||
_ = SavedInstance(url: instanceURL, context: context)
|
_ = SavedInstance(url: instanceURL, account: parentMastodonController!.accountInfo!, context: context)
|
||||||
delegate?.didSaveInstance(url: instanceURL)
|
delegate?.didSaveInstance(url: instanceURL)
|
||||||
}
|
}
|
||||||
mastodonController.persistentContainer.save(context: context)
|
mastodonController.persistentContainer.save(context: context)
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
|
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
|
||||||
|
|
||||||
|
@ -17,33 +18,27 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
|
private var pinnedTimelinesObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
init(mastodonController: MastodonController) {
|
init(mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
|
let pages = mastodonController.accountPreferences.pinnedTimelines.map {
|
||||||
home.title = homeTitle
|
Page(mastodonController: mastodonController, timeline: $0)
|
||||||
home.persistsState = true
|
}
|
||||||
|
super.init(pages: pages) { page in
|
||||||
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
|
let vc = TimelineViewController(for: page.timeline, mastodonController: page.mastodonController)
|
||||||
federated.title = federatedTitle
|
vc.title = page.segmentedControlTitle
|
||||||
federated.persistsState = true
|
vc.persistsState = true
|
||||||
|
return vc
|
||||||
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
|
}
|
||||||
local.title = localTitle
|
|
||||||
local.persistsState = true
|
|
||||||
|
|
||||||
super.init(pages: [
|
|
||||||
(.home, "Home", home),
|
|
||||||
(.local, "Local", local),
|
|
||||||
(.federated, "Federated", federated),
|
|
||||||
])
|
|
||||||
|
|
||||||
title = homeTitle
|
title = homeTitle
|
||||||
tabBarItem.image = UIImage(systemName: "house.fill")
|
tabBarItem.image = UIImage(systemName: "house.fill")
|
||||||
|
|
||||||
let filtersItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), style: .plain, target: self, action: #selector(filtersPressed))
|
let customizeItem = UIBarButtonItem(image: UIImage(systemName: "slider.horizontal.3"), style: .plain, target: self, action: #selector(customizePressed))
|
||||||
filtersItem.accessibilityLabel = "Filters"
|
customizeItem.accessibilityLabel = "Customize Timelines"
|
||||||
navigationItem.leftBarButtonItem = filtersItem
|
navigationItem.rightBarButtonItem = customizeItem
|
||||||
|
|
||||||
let jumpToPresentName = NSMutableAttributedString("Jump to Present")
|
let jumpToPresentName = NSMutableAttributedString("Jump to Present")
|
||||||
// otherwise it pronounces it as 'pɹizˈənt'
|
// otherwise it pronounces it as 'pɹizˈənt'
|
||||||
|
@ -51,7 +46,7 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
||||||
jumpToPresentName.addAttribute(.accessibilitySpeechIPANotation, value: "ˈprɛ.zənt", range: NSRange(location: "Jump to ".count, length: "Present".count))
|
jumpToPresentName.addAttribute(.accessibilitySpeechIPANotation, value: "ˈprɛ.zənt", range: NSRange(location: "Jump to ".count, length: "Present".count))
|
||||||
segmentedControl.accessibilityCustomActions = [
|
segmentedControl.accessibilityCustomActions = [
|
||||||
UIAccessibilityCustomAction(attributedName: jumpToPresentName, actionHandler: { [unowned self] _ in
|
UIAccessibilityCustomAction(attributedName: jumpToPresentName, actionHandler: { [unowned self] _ in
|
||||||
guard let vc = pageControllers[currentIndex] as? TimelineViewController else {
|
guard let vc = currentViewController as? TimelineViewController else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
|
@ -60,42 +55,61 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
pinnedTimelinesObservation = mastodonController.accountPreferences.observe(\.pinnedTimelinesData, changeHandler: { [unowned self] _, _ in
|
||||||
|
let pages = self.mastodonController.accountPreferences.pinnedTimelines.map {
|
||||||
|
Page(mastodonController: self.mastodonController, timeline: $0)
|
||||||
|
}
|
||||||
|
self.setPages(pages, animated: false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectTimeline(_ timeline: Timeline, animated: Bool) {
|
||||||
|
self.selectPage(Page(mastodonController: mastodonController, timeline: timeline), animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
return (pageControllers[currentIndex] as! TimelineViewController).stateRestorationActivity()
|
return (currentViewController as! TimelineViewController).stateRestorationActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
func restoreActivity(_ activity: NSUserActivity) {
|
||||||
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
|
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let page: Page
|
let page = Page(mastodonController: mastodonController, timeline: timeline)
|
||||||
switch timeline {
|
|
||||||
case .home:
|
|
||||||
page = .home
|
|
||||||
case .public(local: false):
|
|
||||||
page = .federated
|
|
||||||
case .public(local: true):
|
|
||||||
page = .local
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectPage(page, animated: false)
|
selectPage(page, animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func filtersPressed() {
|
@objc private func customizePressed() {
|
||||||
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
|
present(UIHostingController(rootView: CustomizeTimelinesView(mastodonController: mastodonController)), animated: true)
|
||||||
}
|
|
||||||
|
|
||||||
enum Page: Hashable {
|
|
||||||
case home
|
|
||||||
case local
|
|
||||||
case federated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension TimelinesPageViewController {
|
||||||
|
struct Page: SegmentedPageViewControllerPage {
|
||||||
|
let mastodonController: MastodonController
|
||||||
|
let timeline: Timeline
|
||||||
|
|
||||||
|
static func ==(lhs: Page, rhs: Page) -> Bool {
|
||||||
|
return lhs.timeline == rhs.timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
var segmentedControlTitle: String {
|
||||||
|
if case let .list(id) = timeline,
|
||||||
|
let list = try? mastodonController.persistentContainer.viewContext.fetch(ListMO.fetchRequest(id: id)).first {
|
||||||
|
return list.title
|
||||||
|
} else {
|
||||||
|
return timeline.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -111,7 +111,7 @@ extension MenuActionProvider {
|
||||||
mastodonController.loggedIn {
|
mastodonController.loggedIn {
|
||||||
let name = hashtag.name.lowercased()
|
let name = hashtag.name.lowercased()
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name)).first
|
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first
|
||||||
let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker"
|
let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker"
|
||||||
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
|
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
|
||||||
actionsSection = [
|
actionsSection = [
|
||||||
|
@ -119,7 +119,7 @@ extension MenuActionProvider {
|
||||||
if let existing = existing {
|
if let existing = existing {
|
||||||
context.delete(existing)
|
context.delete(existing)
|
||||||
} else {
|
} else {
|
||||||
_ = SavedHashtag(hashtag: hashtag, context: context)
|
_ = SavedHashtag(hashtag: hashtag, account: mastodonController.accountInfo!, context: context)
|
||||||
}
|
}
|
||||||
mastodonController.persistentContainer.save(context: context)
|
mastodonController.persistentContainer.save(context: context)
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,35 +8,39 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController {
|
protocol SegmentedPageViewControllerPage: Hashable {
|
||||||
|
var segmentedControlTitle: String { get }
|
||||||
|
}
|
||||||
|
|
||||||
let pages: [Page]
|
class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController {
|
||||||
let pageControllers: [UIViewController]
|
|
||||||
|
private(set) var pages: [Page]!
|
||||||
|
private let pageProvider: (Page) -> UIViewController
|
||||||
|
private var pageControllers = [Page: UIViewController]()
|
||||||
|
|
||||||
private var initialPage: Page
|
private var initialPage: Page
|
||||||
private var currentPage: Page
|
private var currentPage: Page
|
||||||
var currentIndex: Int {
|
var currentIndex: Int! {
|
||||||
pages.firstIndex(of: currentPage)!
|
pages.firstIndex(of: currentPage)
|
||||||
|
}
|
||||||
|
var currentViewController: UIViewController {
|
||||||
|
viewControllers!.first!
|
||||||
}
|
}
|
||||||
|
|
||||||
let segmentedControl = ScrollingSegmentedControl<Page>()
|
let segmentedControl = ScrollingSegmentedControl<Page>()
|
||||||
|
|
||||||
init(pages: [(Page, String, UIViewController)]) {
|
init(pages: [Page], pageProvider: @escaping (Page) -> UIViewController) {
|
||||||
precondition(!pages.isEmpty)
|
precondition(!pages.isEmpty)
|
||||||
|
|
||||||
self.pages = pages.map(\.0)
|
self.pageProvider = pageProvider
|
||||||
self.pageControllers = pages.map(\.2)
|
|
||||||
|
|
||||||
initialPage = self.pages.first!
|
initialPage = pages.first!
|
||||||
currentPage = self.pages.first!
|
currentPage = pages.first!
|
||||||
|
|
||||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||||
|
|
||||||
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
|
setPages(pages, animated: false)
|
||||||
// before the view has necessarily loaded
|
|
||||||
segmentedControl.options = pages.map {
|
|
||||||
.init(value: $0.0, name: $0.1)
|
|
||||||
}
|
|
||||||
segmentedControl.didSelectOption = { [unowned self] option in
|
segmentedControl.didSelectOption = { [unowned self] option in
|
||||||
if let option {
|
if let option {
|
||||||
self.selectPage(option, animated: true)
|
self.selectPage(option, animated: true)
|
||||||
|
@ -54,6 +58,26 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setPages(_ pages: [Page], animated: Bool) {
|
||||||
|
precondition(!pages.isEmpty)
|
||||||
|
|
||||||
|
self.pages = pages
|
||||||
|
|
||||||
|
if !pages.contains(currentPage) {
|
||||||
|
selectPage(pages.first!, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in pageControllers.keys where !pages.contains(key) {
|
||||||
|
pageControllers.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
|
||||||
|
// before the view has necessarily loaded
|
||||||
|
segmentedControl.options = pages.map {
|
||||||
|
.init(value: $0, name: $0.segmentedControlTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
@ -80,12 +104,23 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
|
||||||
initialPage = page
|
initialPage = page
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let prevIndex = currentIndex
|
let direction: UIPageViewController.NavigationDirection
|
||||||
currentPage = page
|
if let prevIndex = currentIndex {
|
||||||
let index = pages.firstIndex(of: page)!
|
let index = pages.firstIndex(of: page)!
|
||||||
let newController = pageControllers[index]
|
direction = index - prevIndex > 0 ? .forward : .reverse
|
||||||
|
} else {
|
||||||
|
direction = .forward
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPage = page
|
||||||
|
let newController: UIViewController
|
||||||
|
if let existing = pageControllers[page] {
|
||||||
|
newController = existing
|
||||||
|
} else {
|
||||||
|
newController = pageProvider(page)
|
||||||
|
pageControllers[page] = newController
|
||||||
|
}
|
||||||
|
|
||||||
let direction: UIPageViewController.NavigationDirection = index - prevIndex > 0 ? .forward : .reverse
|
|
||||||
setViewControllers([newController], direction: direction, animated: animated)
|
setViewControllers([newController], direction: direction, animated: animated)
|
||||||
navigationItem.title = newController.title
|
navigationItem.title = newController.title
|
||||||
|
|
||||||
|
@ -108,7 +143,7 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
|
||||||
|
|
||||||
extension SegmentedPageViewController: TabBarScrollableViewController {
|
extension SegmentedPageViewController: TabBarScrollableViewController {
|
||||||
func tabBarScrollToTop() {
|
func tabBarScrollToTop() {
|
||||||
if let scrollableVC = pageControllers[currentIndex] as? TabBarScrollableViewController {
|
if let scrollableVC = currentViewController as? TabBarScrollableViewController {
|
||||||
scrollableVC.tabBarScrollToTop()
|
scrollableVC.tabBarScrollToTop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -116,7 +151,7 @@ extension SegmentedPageViewController: TabBarScrollableViewController {
|
||||||
|
|
||||||
extension SegmentedPageViewController: BackgroundableViewController {
|
extension SegmentedPageViewController: BackgroundableViewController {
|
||||||
func sceneDidEnterBackground() {
|
func sceneDidEnterBackground() {
|
||||||
if let current = pageControllers[currentIndex] as? BackgroundableViewController {
|
if let current = currentViewController as? BackgroundableViewController {
|
||||||
current.sceneDidEnterBackground()
|
current.sceneDidEnterBackground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -124,7 +159,7 @@ extension SegmentedPageViewController: BackgroundableViewController {
|
||||||
|
|
||||||
extension SegmentedPageViewController: StatusBarTappableViewController {
|
extension SegmentedPageViewController: StatusBarTappableViewController {
|
||||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||||
if let current = pageControllers[currentIndex] as? StatusBarTappableViewController {
|
if let current = currentViewController as? StatusBarTappableViewController {
|
||||||
return current.handleStatusBarTapped(xPosition: xPosition)
|
return current.handleStatusBarTapped(xPosition: xPosition)
|
||||||
}
|
}
|
||||||
return .continue
|
return .continue
|
||||||
|
|
|
@ -216,23 +216,11 @@ class UserActivityManager {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch timeline {
|
if mastodonController.accountPreferences.pinnedTimelines.contains(timeline) {
|
||||||
case .home, .public(true), .public(false):
|
|
||||||
navigationController.popToRootViewController(animated: false)
|
navigationController.popToRootViewController(animated: false)
|
||||||
let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController
|
let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController
|
||||||
let page: TimelinesPageViewController.Page
|
rootController.selectTimeline(timeline, animated: false)
|
||||||
switch timeline {
|
} else {
|
||||||
case .home:
|
|
||||||
page = .home
|
|
||||||
case .public(local: false):
|
|
||||||
page = .federated
|
|
||||||
case .public(local: true):
|
|
||||||
page = .local
|
|
||||||
default:
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
rootController.selectPage(page, animated: false)
|
|
||||||
default:
|
|
||||||
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||||
navigationController.pushViewController(timeline, animated: false)
|
navigationController.pushViewController(timeline, animated: false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,18 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>iCloud.space.vaccor.Tusker</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
|
|
|
@ -89,6 +89,9 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
|
||||||
label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)"
|
label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)"
|
||||||
optionsStack.addArrangedSubview(label)
|
optionsStack.addArrangedSubview(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSelectedIndicatorView()
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSelectedOption(_ value: Value, animated: Bool) {
|
func setSelectedOption(_ value: Value, animated: Bool) {
|
||||||
|
|
|
@ -529,6 +529,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
attrStr.append(showStr)
|
attrStr.append(showStr)
|
||||||
filteredLabel.attributedText = attrStr
|
filteredLabel.attributedText = attrStr
|
||||||
setContentViewMode(.filtered)
|
setContentViewMode(.filtered)
|
||||||
|
return
|
||||||
case .hide:
|
case .hide:
|
||||||
fatalError("unreachable")
|
fatalError("unreachable")
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue