diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index 0d450c0af..ab67c4751 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -25,13 +25,6 @@ 030E86462AC6FC1B000283A6 /* DefaultTextInputType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E86452AC6FC1B000283A6 /* DefaultTextInputType.swift */; }; 030E86482AC6FD1D000283A6 /* _assignIfNotEqual.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E86472AC6FD1D000283A6 /* _assignIfNotEqual.swift */; }; 030E864C2AC7037F000283A6 /* SearchBarExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E864B2AC7037F000283A6 /* SearchBarExtensions.swift */; }; - 0315E9F12B41BD2800E3BA88 /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315E9F02B41BD2800E3BA88 /* PostFeedView.swift */; }; - 0315E9F32B41C1F900E3BA88 /* PostFeedView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315E9F22B41C1F900E3BA88 /* PostFeedView+Logic.swift */; }; - 0315E9F52B41C3EB00E3BA88 /* CommunityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315E9F42B41C3EB00E3BA88 /* CommunityView.swift */; }; - 0315E9F72B41CD0C00E3BA88 /* FeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315E9F62B41CD0C00E3BA88 /* FeedView.swift */; }; - 0315E9F92B41D6DC00E3BA88 /* FeedParentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315E9F82B41D6DC00E3BA88 /* FeedParentView.swift */; }; - 0315E9FB2B41E09A00E3BA88 /* FeedView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315E9FA2B41E09A00E3BA88 /* FeedView+Logic.swift */; }; - 0315E9FD2B41E36300E3BA88 /* PostFeedView+MenuFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0315E9FC2B41E36300E3BA88 /* PostFeedView+MenuFunctions.swift */; }; 031A617C2B1BDFD100ABF23B /* AdvancedAccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031A617B2B1BDFD100ABF23B /* AdvancedAccountSettingsView.swift */; }; 031A617E2B1CE90F00ABF23B /* ChangePasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031A617D2B1CE90F00ABF23B /* ChangePasswordView.swift */; }; 031A61802B1CEA7300ABF23B /* ChangePassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031A617F2B1CEA7300ABF23B /* ChangePassword.swift */; }; @@ -67,7 +60,6 @@ 03A276792AFD903600C0D66B /* CommunityModel+MenuFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A276782AFD903600C0D66B /* CommunityModel+MenuFunctions.swift */; }; 03A2767B2AFE560000C0D66B /* CommunityModel+SwipeActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A2767A2AFE560000C0D66B /* CommunityModel+SwipeActions.swift */; }; 03A2767D2AFE656700C0D66B /* UserModel+MenuFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A2767C2AFE656700C0D66B /* UserModel+MenuFunctions.swift */; }; - 03A40DAD2AD5EA11005F019F /* NoPostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A40DAC2AD5EA11005F019F /* NoPostsView.swift */; }; 03B643572A6864CD00F65700 /* TabBarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B643562A6864CD00F65700 /* TabBarSettingsView.swift */; }; 03B7AAEF2ABCB9DC00068B23 /* ContentTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B7AAEE2ABCB9DC00068B23 /* ContentTracker.swift */; }; 03B7AAF12ABE404300068B23 /* ContentModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B7AAF02ABE404300068B23 /* ContentModel.swift */; }; @@ -169,13 +161,11 @@ 50DBB8E02A805836002870B1 /* MockErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50DBB8DF2A805836002870B1 /* MockErrorHandler.swift */; }; 50EC39B22A346DDC00E014C2 /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EC39B12A346DDC00E014C2 /* URLHandler.swift */; }; 50F2851C2A5C5C1500CF8865 /* TokenRefreshView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50F2851B2A5C5C1500CF8865 /* TokenRefreshView.swift */; }; - 6317ABCB2A37292700603D76 /* FeedType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6317ABCA2A37292700603D76 /* FeedType.swift */; }; 6318DE5427FB958800CC2AD6 /* Stickied Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6318DE5327FB958800CC2AD6 /* Stickied Tag.swift */; }; 6318EDC327EE4D7F00BFCAE8 /* Feed Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6318EDC227EE4D7F00BFCAE8 /* Feed Post.swift */; }; 6322A5CB27F77A4D00135D4F /* Loading View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6322A5CA27F77A4D00135D4F /* Loading View.swift */; }; 6322A5D027F8629700135D4F /* UserLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6322A5CF27F8629700135D4F /* UserLinkView.swift */; }; 6322A5D227F88CFD00135D4F /* Time Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6322A5D127F88CFD00135D4F /* Time Parser.swift */; }; - 632578182A29F83C00446A66 /* PostSortMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632578172A29F83C00446A66 /* PostSortMenu.swift */; }; 632E8EE627EE63D3007E8D75 /* UpvoteButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632E8EE527EE63D3007E8D75 /* UpvoteButtonView.swift */; }; 632E8EE827EE63DB007E8D75 /* DownvoteButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632E8EE727EE63DB007E8D75 /* DownvoteButtonView.swift */; }; 6332FDBD27EFAF7C0009A98A /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 6332FDBC27EFAF7B0009A98A /* Settings.bundle */; }; @@ -283,9 +273,9 @@ 6D7782362A48EED8008AC1BF /* APIPrivateMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D7782352A48EED8008AC1BF /* APIPrivateMessage.swift */; }; 6D8003792A45FD1300363206 /* Bundle+VersionNumbers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8003782A45FD1300363206 /* Bundle+VersionNumbers.swift */; }; 6D80037B2A46458800363206 /* Lazy Load Expanded Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D80037A2A46458800363206 /* Lazy Load Expanded Post.swift */; }; - 6D8F08FF2A4029AE003EB4FD /* Community List View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8F08FE2A4029AE003EB4FD /* Community List View.swift */; }; + 6D8F08FF2A4029AE003EB4FD /* CommunityListSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8F08FE2A4029AE003EB4FD /* CommunityListSection.swift */; }; 6D91D4552A415994006B8F9A /* CommunityListSidebarEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D91D4542A415994006B8F9A /* CommunityListSidebarEntry.swift */; }; - 6D91D4582A4159D8006B8F9A /* CommunityListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D91D4572A4159D8006B8F9A /* CommunityListRowViews.swift */; }; + 6D91D4582A4159D8006B8F9A /* FavoriteStarButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D91D4572A4159D8006B8F9A /* FavoriteStarButtonStyle.swift */; }; 6DA61F812A55B83F001EA633 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA61F802A55B83F001EA633 /* SearchView.swift */; }; 6DA61F872A5720EA001EA633 /* RecentSearchesTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA61F862A5720EA001EA633 /* RecentSearchesTracker.swift */; }; 6DA61F892A575DF1001EA633 /* URL+WithIconSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA61F882A575DF1001EA633 /* URL+WithIconSize.swift */; }; @@ -305,7 +295,6 @@ B104A6DC2A59BF3C00B3E725 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = B104A6DB2A59BF3C00B3E725 /* NukeUI */; }; B104A6DE2A59BF3C00B3E725 /* NukeVideo in Frameworks */ = {isa = PBXBuildFile; productRef = B104A6DD2A59BF3C00B3E725 /* NukeVideo */; }; B104A6E02A59C19400B3E725 /* OperationQueue+ConvenienceInit.swift in Sources */ = {isa = PBXBuildFile; fileRef = B104A6DF2A59C19400B3E725 /* OperationQueue+ConvenienceInit.swift */; }; - B11A1A782A4EFF2B00520DB4 /* Feed Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11A1A772A4EFF2B00520DB4 /* Feed Root.swift */; }; B11D72832A49FAA7009DC22F /* Cached Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11D72822A49FAA7009DC22F /* Cached Image.swift */; }; B14E93C02A45CA3400D6DA93 /* Post Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14E93BF2A45CA3400D6DA93 /* Post Link.swift */; }; B14E93C22A45D3B300D6DA93 /* Community Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14E93C12A45D3B300D6DA93 /* Community Link.swift */; }; @@ -327,6 +316,8 @@ CD05E7792A4E381A0081D102 /* PostSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD05E7782A4E381A0081D102 /* PostSize.swift */; }; CD05E77F2A4F263B0081D102 /* Menu Function.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD05E77E2A4F263B0081D102 /* Menu Function.swift */; }; CD0BE42F2A65A73600314B24 /* Haptic Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0BE42E2A65A73600314B24 /* Haptic Manager.swift */; }; + CD12627A2B4759BC007549F9 /* StandardPostTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1262792B4759BC007549F9 /* StandardPostTracker.swift */; }; + CD12627D2B475E45007549F9 /* PostModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD12627C2B475E45007549F9 /* PostModel+TrackerItem.swift */; }; CD1446182A58FC3B00610EF1 /* InfoStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1446172A58FC3B00610EF1 /* InfoStackView.swift */; }; CD14461B2A5A4B6D00610EF1 /* PostSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD14461A2A5A4B6D00610EF1 /* PostSettingsView.swift */; }; CD1446212A5B328E00610EF1 /* Privacy Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1446202A5B328E00610EF1 /* Privacy Policy.swift */; }; @@ -391,6 +382,10 @@ CD45BCEE2A75CA7200A2899C /* Thumbnail Image View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD45BCED2A75CA7200A2899C /* Thumbnail Image View.swift */; }; CD46C1F62B0D0A5700065953 /* EnvironmentValues+TabReselectionHashValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD46C1F52B0D0A5700065953 /* EnvironmentValues+TabReselectionHashValue.swift */; }; CD46C1F82B0D0A8A00065953 /* View+ReselectAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD46C1F72B0D0A8A00065953 /* View+ReselectAction.swift */; }; + CD4BAD352B4B2C0B00A1E726 /* FeedsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */; }; + CD4BAD3B2B4C6C3200A1E726 /* FeedRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD3A2B4C6C3200A1E726 /* FeedRowView.swift */; }; + CD4BAD3D2B4C6C8E00A1E726 /* FeedType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD3C2B4C6C8E00A1E726 /* FeedType.swift */; }; + CD4BAD432B507F2B00A1E726 /* AggregateFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD422B507F2B00A1E726 /* AggregateFeedView.swift */; }; CD4DBC032A6F803C001A1E61 /* ReplyToPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4DBC022A6F803C001A1E61 /* ReplyToPost.swift */; }; CD525F652A4B6D8F00BCA794 /* CommunityLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD525F642A4B6D8F00BCA794 /* CommunityLinkView.swift */; }; CD59E8A52A72C943005757F4 /* MarkAllAsReadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD59E8A42A72C943005757F4 /* MarkAllAsReadRequest.swift */; }; @@ -424,6 +419,7 @@ CD863FBC2A6B026400A31ED9 /* DocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD863FBB2A6B026400A31ED9 /* DocumentView.swift */; }; CD8C55342A95515C0060B75B /* Onboarding Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8C55332A95515C0060B75B /* Onboarding Text.swift */; }; CD8CF2092AF3F131009FFC23 /* Firm Info.ahap in Resources */ = {isa = PBXBuildFile; fileRef = CD8CF2082AF3F131009FFC23 /* Firm Info.ahap */; }; + CD963FCB2B5F0388002352FD /* DefaultFeedType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD963FCA2B5F0388002352FD /* DefaultFeedType.swift */; }; CD9A03C62B34D20500C16276 /* EnvironmentValues+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9A03C52B34D20500C16276 /* EnvironmentValues+Navigation.swift */; }; CD9A03C82B389F7000C16276 /* EnvironmentValues+FeedType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9A03C72B389F7000C16276 /* EnvironmentValues+FeedType.swift */; }; CD9A49D12B045B64001E18A0 /* ZoomableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9A49D02B045B64001E18A0 /* ZoomableContainer.swift */; }; @@ -448,6 +444,8 @@ CDB45C602AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB45C5F2AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift */; }; CDB45C622AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB45C612AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift */; }; CDB45C642AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB45C632AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift */; }; + CDBCBA202B537A4B0070F60D /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBCBA1F2B537A4B0070F60D /* PostFeedView.swift */; }; + CDBCBA242B54A5F40070F60D /* NoPostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBCBA232B54A5F40070F60D /* NoPostsView.swift */; }; CDC1C93C2A7AA76000072E3D /* InternetSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC1C93B2A7AA76000072E3D /* InternetSpeed.swift */; }; CDC1C93F2A7AB8C700072E3D /* AccessibilitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC1C93E2A7AB8C700072E3D /* AccessibilitySettingsView.swift */; }; CDC1C9412A7ABA9C00072E3D /* ReadMarkStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC1C9402A7ABA9C00072E3D /* ReadMarkStyle.swift */; }; @@ -456,6 +454,7 @@ CDC65D8F2A86B6DD007205E5 /* DeleteUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC65D8E2A86B6DD007205E5 /* DeleteUser.swift */; }; CDC65D912A86B830007205E5 /* DeleteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC65D902A86B830007205E5 /* DeleteAccountView.swift */; }; CDC6A8CA2A6F1C8D00CC11AC /* AssociatedIconProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC6A8C92A6F1C8D00CC11AC /* AssociatedIconProtocol.swift */; }; + CDCA28D42B58AF53009D9F54 /* PostFeedView+MenuFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCA28D32B58AF53009D9F54 /* PostFeedView+MenuFunctions.swift */; }; CDCBD7242A8D62FF00387A2C /* InstanceMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCBD7232A8D62FF00387A2C /* InstanceMetadata.swift */; }; CDCBD7262A8D69A200387A2C /* Instance Picker View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCBD7252A8D69A200387A2C /* Instance Picker View.swift */; }; CDCBD7282A8D6B7700387A2C /* Instance Picker View Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCBD7272A8D6B7700387A2C /* Instance Picker View Logic.swift */; }; @@ -492,8 +491,11 @@ CDEBC3282A9A57F200518D9D /* Content Model Identifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEBC3272A9A57F200518D9D /* Content Model Identifier.swift */; }; CDEBC32A2A9A580B00518D9D /* Post Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEBC3292A9A580B00518D9D /* Post Model.swift */; }; CDEBC32C2A9A582500518D9D /* Votes Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEBC32B2A9A582500518D9D /* Votes Model.swift */; }; - CDEBC32E2A9A583900518D9D /* Post Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEBC32D2A9A583900518D9D /* Post Tracker.swift */; }; CDEBC3392A9ADE6C00518D9D /* APIClient+Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEBC3382A9ADE6C00518D9D /* APIClient+Post.swift */; }; + CDEC95122B5B318B004BA288 /* CommunityFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEC95112B5B318B004BA288 /* CommunityFeedView.swift */; }; + CDEC95142B5CBC42004BA288 /* AggregateFeedView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEC95132B5CBC42004BA288 /* AggregateFeedView+Logic.swift */; }; + CDEC95162B5D8C05004BA288 /* SavedFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEC95152B5D8C05004BA288 /* SavedFeedView.swift */; }; + CDEC95192B5D950D004BA288 /* PostFeedView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDEC95182B5D950D004BA288 /* PostFeedView+Logic.swift */; }; CDF1EF162A6C3BC2003594B6 /* End Of Feed View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF1EF152A6C3BC2003594B6 /* End Of Feed View.swift */; }; CDF1EF182A6C40C9003594B6 /* Menu Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF1EF172A6C40C9003594B6 /* Menu Button.swift */; }; CDF8425C2A49E4C000723DA0 /* APIPersonMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF8425B2A49E4C000723DA0 /* APIPersonMentionView.swift */; }; @@ -565,13 +567,6 @@ 030E86452AC6FC1B000283A6 /* DefaultTextInputType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTextInputType.swift; sourceTree = ""; }; 030E86472AC6FD1D000283A6 /* _assignIfNotEqual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _assignIfNotEqual.swift; sourceTree = ""; }; 030E864B2AC7037F000283A6 /* SearchBarExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarExtensions.swift; sourceTree = ""; }; - 0315E9F02B41BD2800E3BA88 /* PostFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedView.swift; sourceTree = ""; }; - 0315E9F22B41C1F900E3BA88 /* PostFeedView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostFeedView+Logic.swift"; sourceTree = ""; }; - 0315E9F42B41C3EB00E3BA88 /* CommunityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityView.swift; sourceTree = ""; }; - 0315E9F62B41CD0C00E3BA88 /* FeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedView.swift; sourceTree = ""; }; - 0315E9F82B41D6DC00E3BA88 /* FeedParentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedParentView.swift; sourceTree = ""; }; - 0315E9FA2B41E09A00E3BA88 /* FeedView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedView+Logic.swift"; sourceTree = ""; }; - 0315E9FC2B41E36300E3BA88 /* PostFeedView+MenuFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostFeedView+MenuFunctions.swift"; sourceTree = ""; }; 031A617B2B1BDFD100ABF23B /* AdvancedAccountSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedAccountSettingsView.swift; sourceTree = ""; }; 031A617D2B1CE90F00ABF23B /* ChangePasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangePasswordView.swift; sourceTree = ""; }; 031A617F2B1CEA7300ABF23B /* ChangePassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangePassword.swift; sourceTree = ""; }; @@ -607,7 +602,6 @@ 03A276782AFD903600C0D66B /* CommunityModel+MenuFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommunityModel+MenuFunctions.swift"; sourceTree = ""; }; 03A2767A2AFE560000C0D66B /* CommunityModel+SwipeActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommunityModel+SwipeActions.swift"; sourceTree = ""; }; 03A2767C2AFE656700C0D66B /* UserModel+MenuFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserModel+MenuFunctions.swift"; sourceTree = ""; }; - 03A40DAC2AD5EA11005F019F /* NoPostsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoPostsView.swift; sourceTree = ""; }; 03B643562A6864CD00F65700 /* TabBarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarSettingsView.swift; sourceTree = ""; }; 03B7AAEE2ABCB9DC00068B23 /* ContentTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTracker.swift; sourceTree = ""; }; 03B7AAF02ABE404300068B23 /* ContentModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentModel.swift; sourceTree = ""; }; @@ -709,13 +703,11 @@ 50EC39B12A346DDC00E014C2 /* URLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandler.swift; sourceTree = ""; }; 50F2851B2A5C5C1500CF8865 /* TokenRefreshView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenRefreshView.swift; sourceTree = ""; }; 630D753C27F65E44006E60C9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 6317ABCA2A37292700603D76 /* FeedType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedType.swift; sourceTree = ""; }; 6318DE5327FB958800CC2AD6 /* Stickied Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Stickied Tag.swift"; sourceTree = ""; }; 6318EDC227EE4D7F00BFCAE8 /* Feed Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feed Post.swift"; sourceTree = ""; }; 6322A5CA27F77A4D00135D4F /* Loading View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Loading View.swift"; sourceTree = ""; }; 6322A5CF27F8629700135D4F /* UserLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLinkView.swift; sourceTree = ""; }; 6322A5D127F88CFD00135D4F /* Time Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Time Parser.swift"; sourceTree = ""; }; - 632578172A29F83C00446A66 /* PostSortMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSortMenu.swift; sourceTree = ""; }; 632E8EE527EE63D3007E8D75 /* UpvoteButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpvoteButtonView.swift; sourceTree = ""; }; 632E8EE727EE63DB007E8D75 /* DownvoteButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownvoteButtonView.swift; sourceTree = ""; }; 6332FDBC27EFAF7B0009A98A /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -824,9 +816,9 @@ 6D7782352A48EED8008AC1BF /* APIPrivateMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPrivateMessage.swift; sourceTree = ""; }; 6D8003782A45FD1300363206 /* Bundle+VersionNumbers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bundle+VersionNumbers.swift"; sourceTree = ""; }; 6D80037A2A46458800363206 /* Lazy Load Expanded Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Lazy Load Expanded Post.swift"; sourceTree = ""; }; - 6D8F08FE2A4029AE003EB4FD /* Community List View.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Community List View.swift"; sourceTree = ""; }; + 6D8F08FE2A4029AE003EB4FD /* CommunityListSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommunityListSection.swift; sourceTree = ""; }; 6D91D4542A415994006B8F9A /* CommunityListSidebarEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListSidebarEntry.swift; sourceTree = ""; }; - 6D91D4572A4159D8006B8F9A /* CommunityListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListRowViews.swift; sourceTree = ""; }; + 6D91D4572A4159D8006B8F9A /* FavoriteStarButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteStarButtonStyle.swift; sourceTree = ""; }; 6DA61F802A55B83F001EA633 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 6DA61F862A5720EA001EA633 /* RecentSearchesTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSearchesTracker.swift; sourceTree = ""; }; 6DA61F882A575DF1001EA633 /* URL+WithIconSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+WithIconSize.swift"; sourceTree = ""; }; @@ -843,7 +835,6 @@ ADDC9E392A5CEAA100383D58 /* BlockPerson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockPerson.swift; sourceTree = ""; }; B104A6DF2A59C19400B3E725 /* OperationQueue+ConvenienceInit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperationQueue+ConvenienceInit.swift"; sourceTree = ""; }; B104A6E12A5AFC9F00B3E725 /* Mlem.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mlem.entitlements; sourceTree = ""; }; - B11A1A772A4EFF2B00520DB4 /* Feed Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feed Root.swift"; sourceTree = ""; }; B11D72822A49FAA7009DC22F /* Cached Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cached Image.swift"; sourceTree = ""; }; B14E93BF2A45CA3400D6DA93 /* Post Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post Link.swift"; sourceTree = ""; }; B14E93C12A45D3B300D6DA93 /* Community Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Community Link.swift"; sourceTree = ""; }; @@ -865,6 +856,9 @@ CD05E7782A4E381A0081D102 /* PostSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSize.swift; sourceTree = ""; }; CD05E77E2A4F263B0081D102 /* Menu Function.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Menu Function.swift"; sourceTree = ""; }; CD0BE42E2A65A73600314B24 /* Haptic Manager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Haptic Manager.swift"; sourceTree = ""; }; + CD1262792B4759BC007549F9 /* StandardPostTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandardPostTracker.swift; sourceTree = ""; }; + CD12627B2B475A80007549F9 /* README - Generic Trackers.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "README - Generic Trackers.md"; sourceTree = ""; }; + CD12627C2B475E45007549F9 /* PostModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostModel+TrackerItem.swift"; sourceTree = ""; }; CD1446172A58FC3B00610EF1 /* InfoStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoStackView.swift; sourceTree = ""; }; CD14461A2A5A4B6D00610EF1 /* PostSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSettingsView.swift; sourceTree = ""; }; CD1446202A5B328E00610EF1 /* Privacy Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Privacy Policy.swift"; sourceTree = ""; }; @@ -928,6 +922,10 @@ CD45BCED2A75CA7200A2899C /* Thumbnail Image View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Thumbnail Image View.swift"; sourceTree = ""; }; CD46C1F52B0D0A5700065953 /* EnvironmentValues+TabReselectionHashValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+TabReselectionHashValue.swift"; sourceTree = ""; }; CD46C1F72B0D0A8A00065953 /* View+ReselectAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ReselectAction.swift"; sourceTree = ""; }; + CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedsView.swift; sourceTree = ""; }; + CD4BAD3A2B4C6C3200A1E726 /* FeedRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedRowView.swift; sourceTree = ""; }; + CD4BAD3C2B4C6C8E00A1E726 /* FeedType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedType.swift; sourceTree = ""; }; + CD4BAD422B507F2B00A1E726 /* AggregateFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregateFeedView.swift; sourceTree = ""; }; CD4DBC022A6F803C001A1E61 /* ReplyToPost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyToPost.swift; sourceTree = ""; }; CD525F642A4B6D8F00BCA794 /* CommunityLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLinkView.swift; sourceTree = ""; }; CD59E8A42A72C943005757F4 /* MarkAllAsReadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkAllAsReadRequest.swift; sourceTree = ""; }; @@ -961,6 +959,7 @@ CD863FBB2A6B026400A31ED9 /* DocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentView.swift; sourceTree = ""; }; CD8C55332A95515C0060B75B /* Onboarding Text.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Onboarding Text.swift"; sourceTree = ""; }; CD8CF2082AF3F131009FFC23 /* Firm Info.ahap */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Firm Info.ahap"; sourceTree = ""; }; + CD963FCA2B5F0388002352FD /* DefaultFeedType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultFeedType.swift; sourceTree = ""; }; CD9A03C52B34D20500C16276 /* EnvironmentValues+Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+Navigation.swift"; sourceTree = ""; }; CD9A03C72B389F7000C16276 /* EnvironmentValues+FeedType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+FeedType.swift"; sourceTree = ""; }; CD9A49D02B045B64001E18A0 /* ZoomableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableContainer.swift; sourceTree = ""; }; @@ -985,6 +984,8 @@ CDB45C5F2AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionModel+TrackerItem.swift"; sourceTree = ""; }; CDB45C612AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReplyModel+TrackerItem.swift"; sourceTree = ""; }; CDB45C632AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageModel+TrackerItem.swift"; sourceTree = ""; }; + CDBCBA1F2B537A4B0070F60D /* PostFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedView.swift; sourceTree = ""; }; + CDBCBA232B54A5F40070F60D /* NoPostsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoPostsView.swift; sourceTree = ""; }; CDC1C93B2A7AA76000072E3D /* InternetSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetSpeed.swift; sourceTree = ""; }; CDC1C93E2A7AB8C700072E3D /* AccessibilitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilitySettingsView.swift; sourceTree = ""; }; CDC1C9402A7ABA9C00072E3D /* ReadMarkStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkStyle.swift; sourceTree = ""; }; @@ -993,6 +994,7 @@ CDC65D8E2A86B6DD007205E5 /* DeleteUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteUser.swift; sourceTree = ""; }; CDC65D902A86B830007205E5 /* DeleteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountView.swift; sourceTree = ""; }; CDC6A8C92A6F1C8D00CC11AC /* AssociatedIconProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssociatedIconProtocol.swift; sourceTree = ""; }; + CDCA28D32B58AF53009D9F54 /* PostFeedView+MenuFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostFeedView+MenuFunctions.swift"; sourceTree = ""; }; CDCBD7232A8D62FF00387A2C /* InstanceMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceMetadata.swift; sourceTree = ""; }; CDCBD7252A8D69A200387A2C /* Instance Picker View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance Picker View.swift"; sourceTree = ""; }; CDCBD7272A8D6B7700387A2C /* Instance Picker View Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance Picker View Logic.swift"; sourceTree = ""; }; @@ -1029,8 +1031,11 @@ CDEBC3272A9A57F200518D9D /* Content Model Identifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content Model Identifier.swift"; sourceTree = ""; }; CDEBC3292A9A580B00518D9D /* Post Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post Model.swift"; sourceTree = ""; }; CDEBC32B2A9A582500518D9D /* Votes Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Votes Model.swift"; sourceTree = ""; }; - CDEBC32D2A9A583900518D9D /* Post Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post Tracker.swift"; sourceTree = ""; }; CDEBC3382A9ADE6C00518D9D /* APIClient+Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient+Post.swift"; sourceTree = ""; }; + CDEC95112B5B318B004BA288 /* CommunityFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityFeedView.swift; sourceTree = ""; }; + CDEC95132B5CBC42004BA288 /* AggregateFeedView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AggregateFeedView+Logic.swift"; sourceTree = ""; }; + CDEC95152B5D8C05004BA288 /* SavedFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedFeedView.swift; sourceTree = ""; }; + CDEC95182B5D950D004BA288 /* PostFeedView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostFeedView+Logic.swift"; sourceTree = ""; }; CDF1EF152A6C3BC2003594B6 /* End Of Feed View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "End Of Feed View.swift"; sourceTree = ""; }; CDF1EF172A6C40C9003594B6 /* Menu Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Menu Button.swift"; sourceTree = ""; }; CDF8425B2A49E4C000723DA0 /* APIPersonMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPersonMentionView.swift; sourceTree = ""; }; @@ -1332,6 +1337,7 @@ 03FD64FD2AE538C600957AA9 /* Community */ = { isa = PBXGroup; children = ( + CD62D12D2B5ED48000395BD9 /* Community List */, 03EEEAF82ABB985D0087F8D8 /* CommunityModel.swift */, 03A276782AFD903600C0D66B /* CommunityModel+MenuFunctions.swift */, 03A2767A2AFE560000C0D66B /* CommunityModel+SwipeActions.swift */, @@ -1485,6 +1491,7 @@ 50F830EC2A4C8F8D00D67099 /* Generics */ = { isa = PBXGroup; children = ( + CD12627B2B475A80007549F9 /* README - Generic Trackers.md */, CDB45C5B2AF1A1D800A1FF08 /* CoreTracker.swift */, CD4368AD2AE23ED400BD8BD1 /* StandardTracker.swift */, CD4368AF2AE23F1400BD8BD1 /* ChildTracker.swift */, @@ -1627,16 +1634,6 @@ path = Accounts; sourceTree = ""; }; - 6332FDD427F080FA0009A98A /* Community List */ = { - isa = PBXGroup; - children = ( - 6D8F08FE2A4029AE003EB4FD /* Community List View.swift */, - 505240E42A86E32700EA4558 /* CommunityListModel.swift */, - 6D91D4532A41597B006B8F9A /* Components */, - ); - path = "Community List"; - sourceTree = ""; - }; 63344C522A07D189001BC616 /* Views */ = { isa = PBXGroup; children = ( @@ -1660,15 +1657,6 @@ path = Styles; sourceTree = ""; }; - 63344C6F2A098054001BC616 /* Components */ = { - isa = PBXGroup; - children = ( - 632578172A29F83C00446A66 /* PostSortMenu.swift */, - 03A40DAC2AD5EA11005F019F /* NoPostsView.swift */, - ); - path = Components; - sourceTree = ""; - }; 6363D5B827EE196700E34822 = { isa = PBXGroup; children = ( @@ -1773,7 +1761,7 @@ 6363D5F427EE1BAE00E34822 /* Tabs */ = { isa = PBXGroup; children = ( - CD2E14782A6B283D004198DE /* Feeds */, + CD4BAD382B4C6C1B00A1E726 /* Feeds */, 6DA61F7F2A55B831001EA633 /* Search */, 6DE1183A2A4A215F00810C7E /* Profile */, 6DFF50412A48DEC0001E648D /* Inbox */, @@ -1991,6 +1979,7 @@ isa = PBXGroup; children = ( 50F830EC2A4C8F8D00D67099 /* Generics */, + CD1262782B47597E007549F9 /* Feeds */, CDF8425F2A49EA2A00723DA0 /* Inbox */, 6386E02E2A03ED39006B3C1D /* Comment Tracker.swift */, 63344C4E2A07BD2A001BC616 /* Filters Tracker.swift */, @@ -2001,7 +1990,6 @@ B1955A1E2A606F010056CF99 /* EasterFlagsTracker.swift */, CDB0117E2A6F70A000D043EB /* Editor Tracker.swift */, 50785F702A98C4F600117245 /* SiteInformationTracker.swift */, - CDEBC32D2A9A583900518D9D /* Post Tracker.swift */, 03B7AAEE2ABCB9DC00068B23 /* ContentTracker.swift */, ); path = Trackers; @@ -2048,7 +2036,6 @@ children = ( 6DA7E9A02A50763B0095AB68 /* User */, CD64832B2A38CE4200EE6CA3 /* Settings */, - 6317ABCA2A37292700603D76 /* FeedType.swift */, CD6483352A39F20800EE6CA3 /* Post Type.swift */, 03C905CB2B3C88F700B9082F /* SearchTab.swift */, 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.swift */, @@ -2058,20 +2045,11 @@ CD2053132ACBAF150000AA38 /* AvatarType.swift */, CD4368BD2AE23FA600BD8BD1 /* LoadingState.swift */, CD4368C92AE2428C00BD8BD1 /* ContentIdentifiable.swift */, + CD4BAD3C2B4C6C8E00A1E726 /* FeedType.swift */, ); path = Enums; sourceTree = ""; }; - 6D91D4532A41597B006B8F9A /* Components */ = { - isa = PBXGroup; - children = ( - 6D91D4542A415994006B8F9A /* CommunityListSidebarEntry.swift */, - 6D91D4572A4159D8006B8F9A /* CommunityListRowViews.swift */, - 505240E62A88D36D00EA4558 /* SectionIndexTitles.swift */, - ); - path = Components; - sourceTree = ""; - }; 6DA61F7F2A55B831001EA633 /* Search */ = { isa = PBXGroup; children = ( @@ -2160,6 +2138,14 @@ path = APIClient; sourceTree = ""; }; + CD1262782B47597E007549F9 /* Feeds */ = { + isa = PBXGroup; + children = ( + CD1262792B4759BC007549F9 /* StandardPostTracker.swift */, + ); + path = Feeds; + sourceTree = ""; + }; CD14461F2A5B328600610EF1 /* Data */ = { isa = PBXGroup; children = ( @@ -2339,24 +2325,6 @@ path = TimeInterval; sourceTree = ""; }; - CD2E14782A6B283D004198DE /* Feeds */ = { - isa = PBXGroup; - children = ( - 0315E9F42B41C3EB00E3BA88 /* CommunityView.swift */, - 03EF1D0B2B434CB10056175C /* CommunityStatsView.swift */, - 0315E9F62B41CD0C00E3BA88 /* FeedView.swift */, - 0315E9FA2B41E09A00E3BA88 /* FeedView+Logic.swift */, - 0315E9F82B41D6DC00E3BA88 /* FeedParentView.swift */, - 0315E9F02B41BD2800E3BA88 /* PostFeedView.swift */, - 0315E9F22B41C1F900E3BA88 /* PostFeedView+Logic.swift */, - 0315E9FC2B41E36300E3BA88 /* PostFeedView+MenuFunctions.swift */, - B11A1A772A4EFF2B00520DB4 /* Feed Root.swift */, - 6332FDD427F080FA0009A98A /* Community List */, - 63344C6F2A098054001BC616 /* Components */, - ); - path = Feeds; - sourceTree = ""; - }; CD2E14792A6B285F004198DE /* Posts */ = { isa = PBXGroup; children = ( @@ -2445,10 +2413,35 @@ CDB45C5F2AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift */, CDB45C632AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift */, CDB45C612AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift */, + CD12627C2B475E45007549F9 /* PostModel+TrackerItem.swift */, ); path = "Tracker Items"; sourceTree = ""; }; + CD4BAD382B4C6C1B00A1E726 /* Feeds */ = { + isa = PBXGroup; + children = ( + CD62D12F2B5EE18300395BD9 /* Community List */, + CDEC95172B5D8D06004BA288 /* Feed Types */, + CD4BAD392B4C6C2500A1E726 /* Components */, + CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */, + ); + path = Feeds; + sourceTree = ""; + }; + CD4BAD392B4C6C2500A1E726 /* Components */ = { + isa = PBXGroup; + children = ( + 03EF1D0B2B434CB10056175C /* CommunityStatsView.swift */, + CD4BAD3A2B4C6C3200A1E726 /* FeedRowView.swift */, + CDBCBA232B54A5F40070F60D /* NoPostsView.swift */, + CDBCBA1F2B537A4B0070F60D /* PostFeedView.swift */, + CDCA28D32B58AF53009D9F54 /* PostFeedView+MenuFunctions.swift */, + CDEC95182B5D950D004BA288 /* PostFeedView+Logic.swift */, + ); + path = Components; + sourceTree = ""; + }; CD525F662A4B892900BCA794 /* Links */ = { isa = PBXGroup; children = ( @@ -2459,6 +2452,25 @@ path = Links; sourceTree = ""; }; + CD62D12D2B5ED48000395BD9 /* Community List */ = { + isa = PBXGroup; + children = ( + 6D8F08FE2A4029AE003EB4FD /* CommunityListSection.swift */, + 505240E42A86E32700EA4558 /* CommunityListModel.swift */, + ); + path = "Community List"; + sourceTree = ""; + }; + CD62D12F2B5EE18300395BD9 /* Community List */ = { + isa = PBXGroup; + children = ( + 6D91D4542A415994006B8F9A /* CommunityListSidebarEntry.swift */, + 6D91D4572A4159D8006B8F9A /* FavoriteStarButtonStyle.swift */, + 505240E62A88D36D00EA4558 /* SectionIndexTitles.swift */, + ); + path = "Community List"; + sourceTree = ""; + }; CD64832B2A38CE4200EE6CA3 /* Settings */ = { isa = PBXGroup; children = ( @@ -2470,6 +2482,7 @@ CDC1C9402A7ABA9C00072E3D /* ReadMarkStyle.swift */, CD6483A52A82FAF200A5AE84 /* ProfileTabLabel.swift */, CDDB2EDD2A85C2F1001D4B16 /* HapticPriority.swift */, + CD963FCA2B5F0388002352FD /* DefaultFeedType.swift */, ); path = Settings; sourceTree = ""; @@ -2696,6 +2709,17 @@ path = Content; sourceTree = ""; }; + CDEC95172B5D8D06004BA288 /* Feed Types */ = { + isa = PBXGroup; + children = ( + CDEC95112B5B318B004BA288 /* CommunityFeedView.swift */, + CD4BAD422B507F2B00A1E726 /* AggregateFeedView.swift */, + CDEC95132B5CBC42004BA288 /* AggregateFeedView+Logic.swift */, + CDEC95152B5D8C05004BA288 /* SavedFeedView.swift */, + ); + path = "Feed Types"; + sourceTree = ""; + }; CDF842582A49D23800723DA0 /* Messages */ = { isa = PBXGroup; children = ( @@ -2978,7 +3002,6 @@ CD6F29AA2A78003A00F20B6B /* PostRepository.swift in Sources */, 63F0C7A82A0522FC00A18C5D /* Saved Account Tracker.swift in Sources */, E449C5972B35239500E3BCF4 /* InboxReplyView.swift in Sources */, - 0315E9FD2B41E36300E3BA88 /* PostFeedView+MenuFunctions.swift in Sources */, 6372186A2A3A2AAD008C4816 /* GetComment.swift in Sources */, 03C905CA2B3C834C00B9082F /* AvatarBannerView.swift in Sources */, 03F76FA42B2F5F3500E2B54A /* UploadProgressView.swift in Sources */, @@ -3016,7 +3039,6 @@ 5064D0432A6E645D00B22EE3 /* Notifiable.swift in Sources */, 039439932A99098900463032 /* InternetConnectionManager.swift in Sources */, CD82A2592A71775E00111034 /* UnreadTracker.swift in Sources */, - CDEBC32E2A9A583900518D9D /* Post Tracker.swift in Sources */, CD4368CA2AE2428C00BD8BD1 /* ContentIdentifiable.swift in Sources */, CD3FBCE32A4A844800B2063F /* Replies Feed View.swift in Sources */, 637218652A3A2AAD008C4816 /* GetPosts.swift in Sources */, @@ -3046,6 +3068,7 @@ ADDC9E3A2A5CEAA100383D58 /* BlockPerson.swift in Sources */, CD6F29A82A77FF1700F20B6B /* MarkPostRead.swift in Sources */, 031A617E2B1CE90F00ABF23B /* ChangePasswordView.swift in Sources */, + CD4BAD3D2B4C6C8E00A1E726 /* FeedType.swift in Sources */, 6372186B2A3A2AAD008C4816 /* GetComments.swift in Sources */, B1DD00BD2A62DDEC002A7B39 /* RecognizedLemmyInstances.swift in Sources */, 6DA61F892A575DF1001EA633 /* URL+WithIconSize.swift in Sources */, @@ -3058,6 +3081,7 @@ 507573942A5AD59E00AA7ABD /* EquatableError.swift in Sources */, CDE6A81A2A490B970062D161 /* Inbox ReplyBodyView.swift in Sources */, 50811B3C2A92059C006BA3F2 /* BlockCommunityResponse+Mock.swift in Sources */, + CD12627A2B4759BC007549F9 /* StandardPostTracker.swift in Sources */, CD391F9E2A539F1800E213B5 /* ReplyToMention.swift in Sources */, CD1446272A5B36DA00610EF1 /* EULA.swift in Sources */, 500C168E2A66FAAB006F243B /* HapticManager+Dependency.swift in Sources */, @@ -3072,7 +3096,6 @@ CDB0117F2A6F70A000D043EB /* Editor Tracker.swift in Sources */, 030E86482AC6FD1D000283A6 /* _assignIfNotEqual.swift in Sources */, 6354F30A2A2E20040074C08D /* View+Alert.swift in Sources */, - 0315E9F52B41C3EB00E3BA88 /* CommunityView.swift in Sources */, 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */, 03C905C82B3C70E200B9082F /* UserView.swift in Sources */, 6372186C2A3A2AAD008C4816 /* SaveComment.swift in Sources */, @@ -3081,7 +3104,6 @@ 50811B382A920545006BA3F2 /* APICommunityModeratorView+Mock.swift in Sources */, 50F2851C2A5C5C1500CF8865 /* TokenRefreshView.swift in Sources */, 03F4DCA32B1A8B0400556C67 /* AccountGeneralSettingsView.swift in Sources */, - 0315E9FB2B41E09A00E3BA88 /* FeedView+Logic.swift in Sources */, 507573962A5AD5CF00AA7ABD /* ContextualError.swift in Sources */, 50C99B592A61D889005D57DD /* APIClient+Dependency.swift in Sources */, 031A617C2B1BDFD100ABF23B /* AdvancedAccountSettingsView.swift in Sources */, @@ -3090,10 +3112,10 @@ CDB45C5E2AF1A96C00A1FF08 /* AssociatedColorProtocol.swift in Sources */, CD3FBCE92A4B482700B2063F /* Generic Merge.swift in Sources */, E47B2B762A902DE200629AF7 /* SettingsValues.swift in Sources */, + CDBCBA242B54A5F40070F60D /* NoPostsView.swift in Sources */, CDA145ED2A510AC100DDAFC9 /* MarkCommentReplyAsReadRequest.swift in Sources */, CD391F982A537E8E00E213B5 /* ReplyToComment.swift in Sources */, 5064D03D2A6DE0AA00B22EE3 /* Notifier.swift in Sources */, - 0315E9F32B41C1F900E3BA88 /* PostFeedView+Logic.swift in Sources */, CD9A49D52B0587F1001E18A0 /* ImageDetailView.swift in Sources */, CDC65D912A86B830007205E5 /* DeleteAccountView.swift in Sources */, 039C8DBD2B361C160096BAAF /* AccountButtonView.swift in Sources */, @@ -3119,6 +3141,7 @@ CDDB08782A5DF1330075BFEE /* CommentSettingsView.swift in Sources */, 6386E02C2A03D1EC006B3C1D /* App State.swift in Sources */, 504106CD2A744D7F000AAEF8 /* CommentRepository+Dependency.swift in Sources */, + CD4BAD432B507F2B00A1E726 /* AggregateFeedView.swift in Sources */, 6372186F2A3A2AAD008C4816 /* SearchRequest.swift in Sources */, 03EC92952AC064AE007BBE7E /* SearchHomeView.swift in Sources */, CD46C1F62B0D0A5700065953 /* EnvironmentValues+TabReselectionHashValue.swift in Sources */, @@ -3129,7 +3152,6 @@ CDC6A8CA2A6F1C8D00CC11AC /* AssociatedIconProtocol.swift in Sources */, 030E86412AC6F692000283A6 /* SearchBar.swift in Sources */, 50785F762A9A684300117245 /* SavedAccountTracker+Dependency.swift in Sources */, - 632578182A29F83C00446A66 /* PostSortMenu.swift in Sources */, 504ECBAE2AB45B2A006C0B96 /* LemmyURL.swift in Sources */, CDA217EA2A63093E00BDA173 /* ReportComment.swift in Sources */, CDA217E82A63029B00BDA173 /* ReportMention.swift in Sources */, @@ -3138,6 +3160,7 @@ 637218672A3A2AAD008C4816 /* GetPersonDetails.swift in Sources */, B1A26FE12A44AAB200B91A32 /* EnvironmentValues+NavigationPathWithRoutes.swift in Sources */, CDD55D222B2674BD002020C7 /* String+ParseLinks.swift in Sources */, + CDEC95162B5D8C05004BA288 /* SavedFeedView.swift in Sources */, 6332FDC027EFB05F0009A98A /* Settings Item.swift in Sources */, 031A93D62AC847DA0077030C /* UploadConfirmationView.swift in Sources */, CD8C55342A95515C0060B75B /* Onboarding Text.swift in Sources */, @@ -3150,7 +3173,6 @@ CD4368B82AE23F5400BD8BD1 /* ParentTrackerProtocol.swift in Sources */, 637218492A3A2AAD008C4816 /* APICommentReplyView.swift in Sources */, CD4368C82AE2426700BD8BD1 /* ReplyModel.swift in Sources */, - 6317ABCB2A37292700603D76 /* FeedType.swift in Sources */, CDC65D8F2A86B6DD007205E5 /* DeleteUser.swift in Sources */, CD6483382A3A0F2200EE6CA3 /* NSFW Tag.swift in Sources */, 503422582AAB798600EFE88D /* AppFlow.swift in Sources */, @@ -3168,6 +3190,7 @@ 6D7782362A48EED8008AC1BF /* APIPrivateMessage.swift in Sources */, CDE3BA892A8C64BD00B972E2 /* Collapsible Text Item.swift in Sources */, 50CC4A742A9CB10B0074C845 /* TimestampedValue.swift in Sources */, + CDCA28D42B58AF53009D9F54 /* PostFeedView+MenuFunctions.swift in Sources */, 505240E72A88D36D00EA4558 /* SectionIndexTitles.swift in Sources */, 5064D0452A71549C00B22EE3 /* NotificationMessage.swift in Sources */, E4F0B56F2ABD00A000BC3E4A /* View+PresentationBackgroundInteraction.swift in Sources */, @@ -3200,6 +3223,7 @@ CD29ED3B2B2E8624006937CE /* String+IsNotEmpty.swift in Sources */, CD391F9A2A537EF900E213B5 /* CommentBodyView.swift in Sources */, 63344C562A07D81D001BC616 /* Array+Prepend.swift in Sources */, + CDBCBA202B537A4B0070F60D /* PostFeedView.swift in Sources */, CDDCF64F2A672C0A003DA3AC /* FancyTabBarLabel.swift in Sources */, CD04D5D92A3614BE008EF95B /* Large Post.swift in Sources */, CDF8425E2A49E61A00723DA0 /* APIPersonMention.swift in Sources */, @@ -3209,6 +3233,7 @@ CD863FBC2A6B026400A31ED9 /* DocumentView.swift in Sources */, CD8461662A96F9EB0026A627 /* Website Indicator View.swift in Sources */, 038A16E92A7A9C640087987E /* LayoutWidget.swift in Sources */, + CDEC95192B5D950D004BA288 /* PostFeedView+Logic.swift in Sources */, CD9A03C82B389F7000C16276 /* EnvironmentValues+FeedType.swift in Sources */, 50811B2E2A92046D006BA3F2 /* URL+Mock.swift in Sources */, 03C905CC2B3C88F700B9082F /* SearchTab.swift in Sources */, @@ -3217,8 +3242,10 @@ CD391F962A535F5400E213B5 /* ResponseEditorView.swift in Sources */, 03EEEAF92ABB985D0087F8D8 /* CommunityModel.swift in Sources */, CD391F8B2A53371300E213B5 /* ExpandedPostLogic.swift in Sources */, + CD4BAD352B4B2C0B00A1E726 /* FeedsView.swift in Sources */, CDCBD7242A8D62FF00387A2C /* InstanceMetadata.swift in Sources */, CD18DC6B2A5202D4002C56BC /* MarkPersonMentionAsReadRequest.swift in Sources */, + CD4BAD3B2B4C6C3200A1E726 /* FeedRowView.swift in Sources */, CD1824402AA8E24100D9BEB5 /* View+DestructiveConfirmation.swift in Sources */, CD82A2502A7162D400111034 /* GetPersonUnreadCount.swift in Sources */, 030D00882AD1BB2600953B1D /* UserModel+ContentModel.swift in Sources */, @@ -3281,7 +3308,6 @@ 6D7782342A48EE8C008AC1BF /* APIPrivateMessageView.swift in Sources */, 030E86392AC6B44B000283A6 /* DeletePictrsFile.swift in Sources */, 50811B342A9204EB006BA3F2 /* APICommunityAggregates+Mock.swift in Sources */, - B11A1A782A4EFF2B00520DB4 /* Feed Root.swift in Sources */, CDC1C93F2A7AB8C700072E3D /* AccessibilitySettingsView.swift in Sources */, 50C99B622A629C06005D57DD /* ErrorHandler+Dependency.swift in Sources */, CD309C462A93FBD300988F95 /* Logo View.swift in Sources */, @@ -3347,10 +3373,11 @@ 03EF1D0C2B434CB10056175C /* CommunityStatsView.swift in Sources */, 6363D5C727EE196700E34822 /* ContentView.swift in Sources */, 03F4DC9D2B193F4C00556C67 /* MatrixLinkView.swift in Sources */, - 6D8F08FF2A4029AE003EB4FD /* Community List View.swift in Sources */, + 6D8F08FF2A4029AE003EB4FD /* CommunityListSection.swift in Sources */, 035EB0CA2A8687C200227859 /* JumpButtonView.swift in Sources */, 5016A2B12A67EB8600B257E8 /* UIViewController+TopMostViewController.swift in Sources */, 6372184C2A3A2AAD008C4816 /* APIPostView.swift in Sources */, + CD12627D2B475E45007549F9 /* PostModel+TrackerItem.swift in Sources */, CDB0117D2A6F703800D043EB /* CommentEditor.swift in Sources */, 0308E1162B0EA42B000CA955 /* APILocalUserView.swift in Sources */, 030E863F2AC6C5E9000283A6 /* PictrsImageModel.swift in Sources */, @@ -3384,6 +3411,7 @@ 039C8DB92B35A81C0096BAAF /* AccountIconStack.swift in Sources */, CDCBD7262A8D69A200387A2C /* Instance Picker View.swift in Sources */, 03C905CE2B3C8DC400B9082F /* UserView+Logic.swift in Sources */, + CDEC95122B5B318B004BA288 /* CommunityFeedView.swift in Sources */, 6372185B2A3A2AAD008C4816 /* APICommunityView.swift in Sources */, 030E86442AC6F6D5000283A6 /* SearchBar+NavigationView.swift in Sources */, 637218552A3A2AAD008C4816 /* APITagline.swift in Sources */, @@ -3395,7 +3423,6 @@ 88B165B82A8643F4007C9115 /* View+NavigationBarColor.swift in Sources */, 030AC0522A64666C00037155 /* UserSettingsView.swift in Sources */, CDA2C5262A705D6000649D5A /* PostEditor.swift in Sources */, - 03A40DAD2AD5EA11005F019F /* NoPostsView.swift in Sources */, E449C5912B2AA8A300E3BCF4 /* AccountDiscussionLanguagesView.swift in Sources */, 6372184A2A3A2AAD008C4816 /* APIPost.swift in Sources */, 6D693A3E2A5113DF009E2D76 /* CreatePostReport.swift in Sources */, @@ -3404,7 +3431,6 @@ CD69F56F2A41EDF50028D4F7 /* View+SwipeyActions.swift in Sources */, 637218632A3A2AAD008C4816 /* CreatePost.swift in Sources */, CDE6A80B2A43E9F00062D161 /* CommentSortType.swift in Sources */, - 0315E9F92B41D6DC00E3BA88 /* FeedParentView.swift in Sources */, 503BA26F2A2C94540052516C /* URL+Identifiable.swift in Sources */, CD6483322A38D3A600EE6CA3 /* ScoreCounterView.swift in Sources */, 50CC4A7A2A9CC45D0074C845 /* InstanceMetadata+Mock.swift in Sources */, @@ -3419,15 +3445,14 @@ 038A16E12A75AA880087987E /* LayoutWidgetModel.swift in Sources */, 63344C4F2A07BD2A001BC616 /* Filters Tracker.swift in Sources */, 03B7AAEF2ABCB9DC00068B23 /* ContentTracker.swift in Sources */, + CDEC95142B5CBC42004BA288 /* AggregateFeedView+Logic.swift in Sources */, 50811B2C2A920443006BA3F2 /* Date+Mock.swift in Sources */, CD391FA02A545F8600E213B5 /* Compact Post.swift in Sources */, - 0315E9F72B41CD0C00E3BA88 /* FeedView.swift in Sources */, B1A5A8152A4C882F00F203DB /* AlternativeIcon.swift in Sources */, 637218512A3A2AAD008C4816 /* APILocalSiteRateLimit.swift in Sources */, 50BC1ABB2A8D6A5A00E3C48B /* ScoringOperation.swift in Sources */, CD4368BA2AE23F6400BD8BD1 /* TrackerItem.swift in Sources */, 6386E0362A042C59006B3C1D /* Contributor.swift in Sources */, - 0315E9F12B41BD2800E3BA88 /* PostFeedView.swift in Sources */, E40E018C2AABF85500410B2C /* AppRoutes.swift in Sources */, CD18DC6F2A5209C3002C56BC /* MarkPrivateMessageAsReadRequest.swift in Sources */, CD82A2552A716C7C00111034 /* APIPersonUnreadCounts.swift in Sources */, @@ -3436,6 +3461,7 @@ 6363D5C527EE196700E34822 /* MlemApp.swift in Sources */, CDF1EF162A6C3BC2003594B6 /* End Of Feed View.swift in Sources */, 637218542A3A2AAD008C4816 /* APILanguage.swift in Sources */, + CD963FCB2B5F0388002352FD /* DefaultFeedType.swift in Sources */, 50CC4A722A9CB07F0074C845 /* TimeInterval+Period.swift in Sources */, AD1B0D372A5F7A260006F554 /* Licenses.swift in Sources */, 6372186E2A3A2AAD008C4816 /* DeleteComment.swift in Sources */, @@ -3452,7 +3478,7 @@ CDA217E62A63016A00BDA173 /* ReportMessage.swift in Sources */, CD9DD8832A622A6C0044EA8E /* ReportCommentReply.swift in Sources */, CD3FBCE12A4A836000B2063F /* AllItemsFeedView.swift in Sources */, - 6D91D4582A4159D8006B8F9A /* CommunityListRowViews.swift in Sources */, + 6D91D4582A4159D8006B8F9A /* FavoriteStarButtonStyle.swift in Sources */, 63F0C7B92A0533C700A18C5D /* Add Account View.swift in Sources */, 63E5D3922A13CF2300EC1FBD /* Favorite Community Tracker.swift in Sources */, B1B78D642A51D53900F72485 /* AppDelegate.swift in Sources */, @@ -3636,6 +3662,7 @@ INFOPLIST_FILE = Mlem/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Mlem; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -3677,6 +3704,7 @@ INFOPLIST_FILE = Mlem/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Mlem; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Mlem/API/APIClient/APIClient+Comment.swift b/Mlem/API/APIClient/APIClient+Comment.swift index 641463aa1..99c6430af 100644 --- a/Mlem/API/APIClient/APIClient+Comment.swift +++ b/Mlem/API/APIClient/APIClient+Comment.swift @@ -12,7 +12,7 @@ extension APIClient { func loadComments( for postId: Int, maxDepth: Int = 15, - type: FeedType = .all, + type: APIListingType = .all, sort: CommentSortType? = nil, page: Int? = nil, limit: Int? = nil, diff --git a/Mlem/API/APIClient/APIClient+Post.swift b/Mlem/API/APIClient/APIClient+Post.swift index aef5c95c2..055ba6fe7 100644 --- a/Mlem/API/APIClient/APIClient+Post.swift +++ b/Mlem/API/APIClient/APIClient+Post.swift @@ -14,7 +14,7 @@ extension APIClient { page: Int, cursor: String?, sort: PostSortType?, - type: FeedType, + type: APIListingType, limit: Int?, savedOnly: Bool?, communityName: String? diff --git a/Mlem/API/APIClient/APIClient.swift b/Mlem/API/APIClient/APIClient.swift index d3fd5b270..8ba95cf80 100644 --- a/Mlem/API/APIClient/APIClient.swift +++ b/Mlem/API/APIClient/APIClient.swift @@ -389,7 +389,7 @@ extension APIClient { query: String, searchType: SearchType, sortOption: PostSortType, - listingType: FeedType, + listingType: APIListingType, page: Int?, limit: Int? ) async throws -> SearchResponse { diff --git a/Mlem/API/Models/ListingType.swift b/Mlem/API/Models/ListingType.swift index 2f14e1d52..dfa6e9d11 100644 --- a/Mlem/API/Models/ListingType.swift +++ b/Mlem/API/Models/ListingType.swift @@ -11,10 +11,11 @@ enum APIListingType: String, Codable { case all = "All" case local = "Local" case subscribed = "Subscribed" + case moderatorView = "ModeratorView" // Pre 0.18.0 it appears that they used integers instead of strings here. We can remove this intialiser once we drop support for old versions. To fully support both systems, we'd also need to *encode* back into the correct integer or string format. I'd rather not go through the effort for instance versions that most people don't use any more, so I've disabled the option to edit account settings on instances running <0.18.0 // - sjmarf - + // TODO: 0.17 deprecation remove this initialiser init(from decoder: Decoder) throws { @@ -28,7 +29,7 @@ enum APIListingType: String, Codable { } self = value } else if let intValue = try? container.decode(Int.self) { - guard 0...2 ~= intValue else { + guard 0 ... 2 ~= intValue else { throw DecodingError.dataCorruptedError( in: container, debugDescription: "Must be an integer in range 0...2." diff --git a/Mlem/API/Requests/Comment/GetComments.swift b/Mlem/API/Requests/Comment/GetComments.swift index ee0864cb7..e920e48e4 100644 --- a/Mlem/API/Requests/Comment/GetComments.swift +++ b/Mlem/API/Requests/Comment/GetComments.swift @@ -19,7 +19,7 @@ struct GetCommentsRequest: APIGetRequest { session: APISession, postId: Int, maxDepth: Int, - type: FeedType, + type: APIListingType, sort: CommentSortType?, page: Int?, limit: Int?, diff --git a/Mlem/API/Requests/Post/GetPosts.swift b/Mlem/API/Requests/Post/GetPosts.swift index a5d980b0e..48892cf70 100644 --- a/Mlem/API/Requests/Post/GetPosts.swift +++ b/Mlem/API/Requests/Post/GetPosts.swift @@ -21,7 +21,7 @@ struct GetPostsRequest: APIGetRequest { page: Int, cursor: String?, sort: PostSortType?, - type: FeedType, + type: APIListingType, limit: Int? = nil, savedOnly: Bool? = nil, communityName: String? = nil diff --git a/Mlem/API/Requests/SearchRequest.swift b/Mlem/API/Requests/SearchRequest.swift index 713d42c0c..5ea200cd9 100644 --- a/Mlem/API/Requests/SearchRequest.swift +++ b/Mlem/API/Requests/SearchRequest.swift @@ -29,7 +29,7 @@ struct SearchRequest: APIGetRequest { query: String, searchType: SearchType, sortOption: PostSortType, - listingType: FeedType, + listingType: APIListingType, page: Int?, communityId: Int?, communityName: String?, diff --git a/Mlem/ContentView.swift b/Mlem/ContentView.swift index 564d22fcb..44426dff5 100644 --- a/Mlem/ContentView.swift +++ b/Mlem/ContentView.swift @@ -53,7 +53,7 @@ struct ContentView: View { var body: some View { FancyTabBar(selection: $tabSelection, navigationSelection: $tabNavigation, dragUpGestureCallback: showAccountSwitcherDragCallback) { Group { - FeedRoot() + FeedsView() .fancyTabItem(tag: TabSelection.feeds) { FancyTabBarLabel( tag: TabSelection.feeds, @@ -76,18 +76,18 @@ struct ContentView: View { } ProfileView(user: myUser) - .fancyTabItem(tag: TabSelection.profile) { - FancyTabBarLabel( - tag: TabSelection.profile, - customText: appState.tabDisplayName, - symbolConfiguration: .init( - symbol: FancyTabBarLabel.SymbolConfiguration.profile.symbol, - activeSymbol: FancyTabBarLabel.SymbolConfiguration.profile.activeSymbol, - remoteSymbolUrl: appState.profileTabRemoteSymbolUrl + .fancyTabItem(tag: TabSelection.profile) { + FancyTabBarLabel( + tag: TabSelection.profile, + customText: appState.tabDisplayName, + symbolConfiguration: .init( + symbol: FancyTabBarLabel.SymbolConfiguration.profile.symbol, + activeSymbol: FancyTabBarLabel.SymbolConfiguration.profile.activeSymbol, + remoteSymbolUrl: appState.profileTabRemoteSymbolUrl + ) ) - ) - .simultaneousGesture(accountSwitchLongPress) - } + .simultaneousGesture(accountSwitchLongPress) + } SearchRoot() .fancyTabItem(tag: TabSelection.search) { diff --git a/Mlem/Enums/FeedType.swift b/Mlem/Enums/FeedType.swift index dc7a3dab0..f33052fde 100644 --- a/Mlem/Enums/FeedType.swift +++ b/Mlem/Enums/FeedType.swift @@ -2,76 +2,144 @@ // FeedType.swift // Mlem // -// Created by Jonathan de Jong on 12.06.2023. +// Created by Eric Andrews on 2024-01-08. // +import Foundation import SwiftUI -enum FeedType: String, Encodable, SettingsOptions, AssociatedColor { - var id: Self { self } - +enum FeedType { + case all, local, subscribed, saved + case community(CommunityModel) + + static var allAggregateFeedCases: [FeedType] = [.all, .local, .subscribed, .saved] + var label: String { - return rawValue + switch self { + case .all: "All" + case .local: "Local" + case .subscribed: "Subscribed" + case .saved: "Saved" + case let .community(communityModel): communityModel.name + } } - var description: String { + /// Maps FeedType to APIListingType + var toApiListingType: APIListingType { switch self { - case .all: - return "Subscribed communities from all instances" - case .local: - return "Local communities from your server" - case .subscribed: - return "All communities that federate with your server" + case .all: .all + case .local: .local + case .subscribed: .subscribed + case .saved: .all // TODO: change this? + case .community: .subscribed } } - var color: Color? { + /// String for use in shortcuts + var toShortcutString: String { + switch self { + case .all: "All" + case .local: "Local" + case .subscribed: "Subscribed" + case .saved: "Saved" // TODO: change this? + case .community: "Subscribed" + } + } + + static func fromShortcutString(shortcut: String?) -> FeedType? { + switch shortcut { + case "All": + return .all + case "Local": + return .local + case "Subscribed": + return .subscribed + case "Saved": + return .saved + default: + return nil + } + } + + var communityId: Int? { + switch self { + case let .community(communityModel): communityModel.communityId + default: nil + } + } +} + +extension FeedType: Hashable, Identifiable { + func hash(into hasher: inout Hasher) { switch self { case .all: - return .blue + hasher.combine("all") case .local: - return .green + hasher.combine("local") case .subscribed: - return .red + hasher.combine("subscribed") + case .saved: + hasher.combine("saved") + case let .community(communityModel): + hasher.combine("community") + hasher.combine(communityModel.communityId) } } - - case subscribed = "Subscribed" - case local = "Local" - case all = "All" + + var id: Int { hashValue } } extension FeedType: AssociatedIcon { var iconName: String { switch self { - case .all: return Icons.federatedFeed - case .local: return Icons.localFeed - case .subscribed: return Icons.subscribedFeed + case .all: Icons.federatedFeed + case .local: Icons.localFeed + case .subscribed: Icons.subscribedFeed + case .saved: Icons.savedFeed + case .community: Icons.community } } var iconNameFill: String { switch self { - case .all: return Icons.federatedFeedFill - case .local: return Icons.localFeedFill - case .subscribed: return Icons.subscribedFeedFill + case .all: Icons.federatedFeedFill + case .local: Icons.localFeedFill + case .subscribed: Icons.subscribedFeedFill + case .saved: Icons.savedFeedFill + case .community: Icons.communityFill } } var iconNameCircle: String { switch self { - case .all: return Icons.federatedFeedCircle - case .local: return Icons.localFeedCircle - case .subscribed: return Icons.subscribedFeedCircle + case .all: Icons.federatedFeedCircle + case .local: Icons.localFeedCircle + case .subscribed: Icons.subscribedFeedCircle + case .saved: Icons.savedFeedCircle + case .community: Icons.community } } /// Icon to use in system settings. This should be removed when the "unified symbol handling" is closed var settingsIconName: String { switch self { - case .all: return "circle.hexagongrid" - case .local: return "house" - case .subscribed: return "newspaper" + case .all: "circle.hexagongrid" + case .local: "house" + case .subscribed: "newspaper" + case .saved: Icons.save + case .community: Icons.community + } + } +} + +extension FeedType: AssociatedColor { + var color: Color? { + switch self { + case .all: .blue + case .local: .purple + case .subscribed: .red + case .saved: .green + case .community: .blue } } } diff --git a/Mlem/Enums/Settings/DefaultFeedType.swift b/Mlem/Enums/Settings/DefaultFeedType.swift new file mode 100644 index 000000000..ff1ba0eb8 --- /dev/null +++ b/Mlem/Enums/Settings/DefaultFeedType.swift @@ -0,0 +1,34 @@ +// +// DefaultFeedType.swift +// Mlem +// +// Created by Eric Andrews on 2024-01-22. +// + +import Foundation + +enum DefaultFeedType: String, SettingsOptions, CaseIterable { + case all, local, subscribed, saved + + var label: String { rawValue.capitalized } + + var settingsIconName: String { + switch self { + case .all: Icons.federatedFeed + case .local: Icons.localFeed + case .subscribed: Icons.subscribedFeed + case .saved: Icons.savedFeed + } + } + + var toFeedType: FeedType { + switch self { + case .all: .all + case .local: .local + case .subscribed: .subscribed + case .saved: .saved + } + } + + var id: Self { self } +} diff --git a/Mlem/Enums/User/UserViewTab.swift b/Mlem/Enums/User/UserViewTab.swift index b17fb9979..932a77c77 100644 --- a/Mlem/Enums/User/UserViewTab.swift +++ b/Mlem/Enums/User/UserViewTab.swift @@ -8,7 +8,7 @@ import Foundation enum UserViewTab: String, CaseIterable, Identifiable { - case overview, comments, posts, communities, saved + case overview, comments, posts, communities var id: Self { self } diff --git a/Mlem/Extensions/Tracker Items/PostModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/PostModel+TrackerItem.swift new file mode 100644 index 000000000..c0e60c70b --- /dev/null +++ b/Mlem/Extensions/Tracker Items/PostModel+TrackerItem.swift @@ -0,0 +1,17 @@ +// +// PostModel+TrackerItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-01-04. +// + +import Foundation + +extension PostModel: TrackerItem { + func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + switch sortType { + case .published: + return .published(published) + } + } +} diff --git a/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift b/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift index 39b2217be..961f401a5 100644 --- a/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift +++ b/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift @@ -22,39 +22,16 @@ struct HandleLemmyLinksDisplay: ViewModifier { @AppStorage("upvoteOnSave") var upvoteOnSave = false - // swiftlint:disable function_body_length // swiftlint:disable:next cyclomatic_complexity func body(content: Content) -> some View { content .navigationDestination(for: AppRoute.self) { route in switch route { case let .community(community): - CommunityView(community: community) + CommunityFeedView(communityModel: community) .environmentObject(appState) .environmentObject(filtersTracker) .environmentObject(quickLookState) - case let .communityLinkWithContext(context): - FeedParentView(community: context.community, feedType: context.feedType) - .environmentObject(appState) - .environmentObject(filtersTracker) - .environmentObject(quickLookState) - case let .apiPostView(post): - let postModel = PostModel(from: post) - let postTracker = PostTracker( - shouldPerformMergeSorting: false, - internetSpeed: internetSpeed, - initialItems: [postModel], - upvoteOnSave: upvoteOnSave - ) - // swiftlint:disable:next redundant_discardable_let - let _ = postTracker.add([postModel]) - ExpandedPost(post: postModel) - .environmentObject(postTracker) - .environmentObject(appState) - .environmentObject(quickLookState) - case let .apiPost(post): - LazyLoadExpandedPost(post: post) - .environmentObject(quickLookState) case let .apiPerson(user): UserView(user: UserModel(from: user)) .environmentObject(appState) @@ -63,9 +40,9 @@ struct HandleLemmyLinksDisplay: ViewModifier { UserView(user: user, communityContext: communityContext) .environmentObject(appState) .environmentObject(quickLookState) - case let .postLinkWithContext(post): - ExpandedPost(post: post.post, community: post.community, scrollTarget: post.scrollTarget) - .environmentObject(post.postTracker) + case let .postLinkWithContext(postLink): + ExpandedPost(post: postLink.post, community: postLink.community, scrollTarget: postLink.scrollTarget) + .environmentObject(postLink.postTracker) .environmentObject(appState) .environmentObject(quickLookState) .environmentObject(layoutWidgetTracker) @@ -87,8 +64,6 @@ struct HandleLemmyLinksDisplay: ViewModifier { } } } - - // swiftlint:enable function_body_length @ViewBuilder // swiftlint:disable:next cyclomatic_complexity diff --git a/Mlem/Info.plist b/Mlem/Info.plist index 51709c328..a4a5a42b1 100644 --- a/Mlem/Info.plist +++ b/Mlem/Info.plist @@ -19,8 +19,6 @@ ITSAppUsesNonExemptEncryption - NSPhotoLibraryAddUsageDescription - NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/Mlem/MlemApp.swift b/Mlem/MlemApp.swift index 921b71e03..a177ea32f 100644 --- a/Mlem/MlemApp.swift +++ b/Mlem/MlemApp.swift @@ -73,42 +73,17 @@ struct MlemApp: App { private func setupAppShortcuts() { guard accountsTracker.savedAccounts.first != nil else { return } - - // Subscribed Feed - let subscribedIcon = UIApplicationShortcutIcon(systemImageName: Icons.subscribedFeed) - let subscribedFeedItem = UIApplicationShortcutItem( - type: FeedType.subscribed.rawValue, - localizedTitle: "Subscribed", - localizedSubtitle: nil, - icon: subscribedIcon, - userInfo: nil - ) - - // Local Feed - let localIcon = UIApplicationShortcutIcon(systemImageName: Icons.localFeed) - let localFeedItem = UIApplicationShortcutItem( - type: FeedType.local.rawValue, - localizedTitle: "Local", - localizedSubtitle: nil, - icon: localIcon, - userInfo: nil - ) - - // All Feed - let allIcon = UIApplicationShortcutIcon(systemImageName: Icons.federatedFeed) - let allFeedItem = UIApplicationShortcutItem( - type: FeedType.all.rawValue, - localizedTitle: "All", - localizedSubtitle: nil, - icon: allIcon, - userInfo: nil - ) - - UIApplication.shared.shortcutItems = [ - subscribedFeedItem, - localFeedItem, - allFeedItem - ] + + UIApplication.shared.shortcutItems = FeedType.allAggregateFeedCases.map { feedType in + let icon = UIApplicationShortcutIcon(systemImageName: feedType.iconName) + return UIApplicationShortcutItem( + type: feedType.toShortcutString, + localizedTitle: feedType.label, + localizedSubtitle: nil, + icon: icon, + userInfo: nil + ) + } } /// A variable describing the initial flow the application should run after start-up diff --git a/Mlem/Models/Composers/PostEditor.swift b/Mlem/Models/Composers/PostEditor.swift index 8509888d6..eab90c22f 100644 --- a/Mlem/Models/Composers/PostEditor.swift +++ b/Mlem/Models/Composers/PostEditor.swift @@ -12,38 +12,23 @@ struct PostEditorModel: Identifiable { var id: Int { community.communityId } let community: CommunityModel - var postTracker: PostTracker! + let postTracker: StandardPostTracker? let editPost: PostModel? - var responseCallback: ((PostModel) -> Void)? + /// Initializer for creating a post. If `postTracker` is provided, the new post will be prepended to it. init( community: CommunityModel, - postTracker: PostTracker? = nil, - responseCallback: ((PostModel) -> Void)? = nil + postTracker: StandardPostTracker? ) { self.community = community + self.postTracker = postTracker self.editPost = nil - self.responseCallback = responseCallback - self.initialiseTracker(postTracker) } - init( - post: PostModel, - postTracker: PostTracker? = nil, - responseCallback: ((PostModel) -> Void)? = nil - ) { - self.editPost = post + /// Initializer for editing a post + init(post: PostModel) { self.community = post.community - self.responseCallback = responseCallback - self.initialiseTracker(postTracker) - } - - private mutating func initialiseTracker(_ postTracker: PostTracker?) { - @AppStorage("upvoteOnSave") var upvoteOnSave = false - if let postTracker { - self.postTracker = postTracker - } else { - self.postTracker = .init(shouldPerformMergeSorting: false, internetSpeed: .slow, upvoteOnSave: upvoteOnSave) - } + self.postTracker = nil + self.editPost = post } } diff --git a/Mlem/Views/Tabs/Feeds/Community List/CommunityListModel.swift b/Mlem/Models/Content/Community/Community List/CommunityListModel.swift similarity index 90% rename from Mlem/Views/Tabs/Feeds/Community List/CommunityListModel.swift rename to Mlem/Models/Content/Community/Community List/CommunityListModel.swift index 30a439fe5..ac03883f4 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/CommunityListModel.swift +++ b/Mlem/Models/Content/Community/Community List/CommunityListModel.swift @@ -1,9 +1,9 @@ -// +// // CommunityListModel.swift // Mlem // // Created by mormaer on 11/08/2023. -// +// // import Combine @@ -11,7 +11,6 @@ import Dependencies import Foundation class CommunityListModel: ObservableObject { - @Dependency(\.communityRepository) var communityRepository @Dependency(\.errorHandler) var errorHandler @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker @@ -41,7 +40,7 @@ class CommunityListModel: ObservableObject { // load our subscribed communities let subscriptions = try await communityRepository .loadSubscriptions() - .map { $0.community } + .map(\.community) // load our favourite communities let favorites = favoriteCommunitiesTracker.favoritesForCurrentAccount @@ -69,36 +68,37 @@ class CommunityListModel: ObservableObject { } } - var visibleSections: [CommunitySection] { + var visibleSections: [CommunityListSection] { allSections() - // Only show sections which have labels to show + // Only show sections which have labels to show .filter { communitySection -> Bool in communitySection.inlineHeaderLabel != nil } - // Only show letter headers for letters we have in our community list + // Only show letter headers for letters we have in our community list .filter { communitySection -> Bool in communities .contains(where: { communitySection.sidebarEntry - .contains(community: $0, isSubscribed: isSubscribed(to: $0)) }) + .contains(community: $0, isSubscribed: isSubscribed(to: $0)) + }) } } - func communities(for section: CommunitySection) -> [APICommunity] { + func communities(for section: CommunityListSection) -> [APICommunity] { // Filter down to sidebar entry which wants us - return communities + communities .filter { community -> Bool in section.sidebarEntry.contains(community: community, isSubscribed: isSubscribed(to: community)) } } - func allSections() -> [CommunitySection] { - var sections = [CommunitySection]() + func allSections() -> [CommunityListSection] { + var sections = [CommunityListSection]() sections.append( withDependencies(from: self) { - CommunitySection( + CommunityListSection( viewId: "top", sidebarEntry: EmptySidebarEntry( sidebarLabel: nil, @@ -112,7 +112,7 @@ class CommunityListModel: ObservableObject { sections.append( withDependencies(from: self) { - CommunitySection( + CommunityListSection( viewId: "favorites", sidebarEntry: FavoritesSidebarEntry( sidebarLabel: nil, @@ -128,7 +128,7 @@ class CommunityListModel: ObservableObject { sections.append( withDependencies(from: self) { - CommunitySection( + CommunityListSection( viewId: "non_letter_titles", sidebarEntry: RegexCommunityNameSidebarEntry( communityNameRegex: /^[^a-zA-Z]/, @@ -144,12 +144,12 @@ class CommunityListModel: ObservableObject { return sections } - func alphabeticSections() -> [CommunitySection] { + func alphabeticSections() -> [CommunityListSection] { let alphabet: [String] = .alphabet return alphabet.map { character in withDependencies(from: self) { // This looks sinister but I didn't know how to string replace in a non-string based regex - CommunitySection( + CommunityListSection( viewId: character, sidebarEntry: RegexCommunityNameSidebarEntry( communityNameRegex: (try? Regex("^[\(character.uppercased())\(character.lowercased())]"))!, @@ -218,7 +218,7 @@ class CommunityListModel: ObservableObject { private func combine(_ subscriptions: [APICommunity], _ favorites: [APICommunity]) { // store the values for future use... self.subscriptions = subscriptions - self.favoriteCommunities = favorites + favoriteCommunities = favorites // combine and sort the two lists, excluding duplicates let combined = subscriptions + favorites.filter { !subscriptions.contains($0) } diff --git a/Mlem/Models/Content/Community/Community List/CommunityListSection.swift b/Mlem/Models/Content/Community/Community List/CommunityListSection.swift new file mode 100644 index 000000000..049a3b97b --- /dev/null +++ b/Mlem/Models/Content/Community/Community List/CommunityListSection.swift @@ -0,0 +1,17 @@ +// +// CommunityListSection.swift +// Mlem +// +// Created by Jake Shirey on 17.06.2023. +// + +import Dependencies +import SwiftUI + +struct CommunityListSection: Identifiable { + let id = UUID() + let viewId: String + let sidebarEntry: any SidebarEntry + let inlineHeaderLabel: String? + let accessibilityLabel: String +} diff --git a/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift b/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift index aee6f65d7..a6f924a76 100644 --- a/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift +++ b/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift @@ -9,18 +9,18 @@ import Foundation import SwiftUI extension CommunityModel { - func newPostMenuFunction(editorTracker: EditorTracker, postTracker: PostTracker? = nil) -> MenuFunction { - return .standardMenuFunction( - text: "New Post", - imageName: Icons.sendFill, - destructiveActionPrompt: nil, - enabled: true - ) { - editorTracker.openEditor(with: PostEditorModel( - community: self, - postTracker: postTracker - )) - } + func newPostMenuFunction(editorTracker: EditorTracker, postTracker: StandardPostTracker? = nil) -> MenuFunction { + .standardMenuFunction( + text: "New Post", + imageName: Icons.sendFill, + destructiveActionPrompt: nil, + enabled: true + ) { + editorTracker.openEditor(with: PostEditorModel( + community: self, + postTracker: postTracker + )) + } } func subscribeMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) throws -> StandardMenuFunction { @@ -46,7 +46,7 @@ extension CommunityModel { } func favoriteMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> StandardMenuFunction { - return .init( + .init( text: favorited ? "Unfavorite" : "Favorite", imageName: favorited ? Icons.unfavorite : Icons.favorite, destructiveActionPrompt: favorited ? "Really unfavorite \(community.name)?" : nil, @@ -86,9 +86,9 @@ extension CommunityModel { } func menuFunctions( - _ callback: @escaping (_ item: Self) -> Void = { _ in }, editorTracker: EditorTracker? = nil, - postTracker: PostTracker? = nil + postTracker: StandardPostTracker? = nil, + _ callback: @escaping (_ item: Self) -> Void = { _ in } ) -> [MenuFunction] { var functions: [MenuFunction] = .init() if let editorTracker { diff --git a/Mlem/Models/Content/Community/CommunityModel.swift b/Mlem/Models/Content/Community/CommunityModel.swift index 8066a62d9..c5ddb9ab6 100644 --- a/Mlem/Models/Content/Community/CommunityModel.swift +++ b/Mlem/Models/Content/Community/CommunityModel.swift @@ -74,80 +74,80 @@ struct CommunityModel { var defaultPostLanguage: Int? init(from response: GetCommunityResponse) { - self.update(with: response) + update(with: response) } init(from response: CommunityResponse) { - self.update(with: response) + update(with: response) } init(from communityView: APICommunityView) { - self.update(with: communityView) + update(with: communityView) } init(from community: APICommunity, subscribed: Bool? = nil) { - self.update(with: community) + update(with: community) if let subscribed { self.subscribed = subscribed } } mutating func update(with response: CommunityResponse) { - self.discussionLanguages = response.discussionLanguages - self.update(with: response.communityView) + discussionLanguages = response.discussionLanguages + update(with: response.communityView) } mutating func update(with response: GetCommunityResponse) { - self.site = response.site - self.moderators = response.moderators.map { UserModel(from: $0.moderator) } - self.discussionLanguages = response.discussionLanguages - self.defaultPostLanguage = response.defaultPostLanguage - self.update(with: response.communityView) + site = response.site + moderators = response.moderators.map { UserModel(from: $0.moderator) } + discussionLanguages = response.discussionLanguages + defaultPostLanguage = response.defaultPostLanguage + update(with: response.communityView) } mutating func update(with communityView: APICommunityView) { - self.subscribed = communityView.subscribed.isSubscribed - self.blocked = communityView.blocked + subscribed = communityView.subscribed.isSubscribed + blocked = communityView.blocked - self.subscriberCount = communityView.counts.subscribers - self.postCount = communityView.counts.posts - self.commentCount = communityView.counts.comments - self.activeUserCount = .init( + subscriberCount = communityView.counts.subscribers + postCount = communityView.counts.posts + commentCount = communityView.counts.comments + activeUserCount = .init( sixMonths: communityView.counts.usersActiveHalfYear, month: communityView.counts.usersActiveMonth, week: communityView.counts.usersActiveWeek, day: communityView.counts.usersActiveDay ) - self.update(with: communityView.community) + update(with: communityView.community) } mutating func update(with community: APICommunity) { self.community = community - self.communityId = community.id - self.instanceId = community.instanceId + communityId = community.id + instanceId = community.instanceId - self.name = community.name - self.displayName = community.title - self.description = community.description + name = community.name + displayName = community.title + description = community.description - self.avatar = community.iconUrl - self.banner = community.bannerUrl + avatar = community.iconUrl + banner = community.bannerUrl - self.nsfw = community.nsfw - self.local = community.local - self.removed = community.removed - self.deleted = community.deleted - self.hidden = community.hidden - self.postingRestrictedToMods = community.postingRestrictedToMods + nsfw = community.nsfw + local = community.local + removed = community.removed + deleted = community.deleted + hidden = community.hidden + postingRestrictedToMods = community.postingRestrictedToMods - self.creationDate = community.published - self.updatedDate = community.updated + creationDate = community.published + updatedDate = community.updated - self.communityUrl = community.actorId + communityUrl = community.actorId @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker - self.favorited = favoriteCommunitiesTracker.isFavorited(community) + favorited = favoriteCommunitiesTracker.isFavorited(community) } func toggleSubscribe(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async throws { @@ -186,7 +186,7 @@ struct CommunityModel { var new = self new.favorited.toggle() if new.favorited { - favoriteCommunitiesTracker.favorite(self.community) + favoriteCommunitiesTracker.favorite(community) if let subscribed, !subscribed { try await new.toggleSubscribe { community in var community = community @@ -237,7 +237,7 @@ struct CommunityModel { } var fullyQualifiedName: String? { - if let host = self.communityUrl.host() { + if let host = communityUrl.host() { return "\(name!)@\(host)" } return nil @@ -258,7 +258,7 @@ struct CommunityModel { } static func mock() -> CommunityModel { - return .init(from: GetCommunityResponse.mock()) + .init(from: GetCommunityResponse.mock()) } } diff --git a/Mlem/Models/Content/Post Model.swift b/Mlem/Models/Content/Post Model.swift index 49081467b..6c2aac56c 100644 --- a/Mlem/Models/Content/Post Model.swift +++ b/Mlem/Models/Content/Post Model.swift @@ -5,26 +5,35 @@ // Created by Eric Andrews on 2023-08-26. // +import Dependencies import Foundation /// Internal model to represent a post /// Note: this is just the first pass at decoupling the internal models from the API models--to avoid massive merge conflicts and an unreviewably large PR, I've kept the structure practically identical, and will slowly morph it over the course of several PRs. Eventually all of the API types that this model uses will go away and everything downstream of the repositories won't ever know there's an API at all :) -struct PostModel { - let postId: Int - let post: APIPost - let creator: UserModel - let community: CommunityModel - var votes: VotesModel - let commentCount: Int - let unreadCommentCount: Int - let saved: Bool - let read: Bool - let published: Date - let updated: Date? - let links: [LinkType] +class PostModel: ContentIdentifiable, ObservableObject { + @Dependency(\.hapticManager) var hapticManager + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.postRepository) var postRepository + + var postId: Int + var post: APIPost + var creator: UserModel + var community: CommunityModel + @Published var votes: VotesModel + var commentCount: Int + @Published var unreadCommentCount: Int + @Published var saved: Bool + @Published var read: Bool + @Published var deleted: Bool + var published: Date + var updated: Date? + var links: [LinkType] var uid: ContentModelIdentifier { .init(contentType: .post, contentId: postId) } + // prevents a voting operation from ocurring while another is ocurring + var voting: Bool = false + /// Creates a PostModel from an APIPostView /// - Parameter apiPostView: APIPostView to create a PostModel representation of init(from apiPostView: APIPostView) { @@ -37,6 +46,7 @@ struct PostModel { self.unreadCommentCount = apiPostView.unreadComments self.saved = apiPostView.saved self.read = apiPostView.read + self.deleted = apiPostView.post.deleted self.published = apiPostView.post.published self.updated = apiPostView.post.updated @@ -66,6 +76,7 @@ struct PostModel { unreadCommentCount: Int? = nil, saved: Bool? = nil, read: Bool? = nil, + deleted: Bool? = nil, published: Date? = nil, updated: Date? = nil ) { @@ -78,12 +89,151 @@ struct PostModel { self.unreadCommentCount = unreadCommentCount ?? other.unreadCommentCount self.saved = saved ?? other.saved self.read = read ?? other.read + self.deleted = deleted ?? other.deleted self.published = published ?? other.published self.updated = updated ?? other.updated self.links = PostModel.parseLinks(from: self.post.body) } + // MARK: Main Actor State Change Methods + + @MainActor func reinit(from postModel: PostModel) { + postId = postModel.postId + post = postModel.post + creator = postModel.creator + community = postModel.community + votes = postModel.votes + commentCount = postModel.commentCount + unreadCommentCount = postModel.unreadCommentCount + saved = postModel.saved + read = postModel.read + published = postModel.published + updated = postModel.updated + links = postModel.links + } + + @MainActor + func setVotes(_ newVotes: VotesModel) { + votes = newVotes + } + + @MainActor + func setRead(_ newRead: Bool) { + read = newRead + } + + @MainActor + func setSaved(_ newSaved: Bool) { + saved = newSaved + } + + @MainActor + func setDeleted(_ newDeleted: Bool) { + deleted = newDeleted + } + + // MARK: Interaction Methods + + func vote(inputOp: ScoringOperation) async { + hapticManager.play(haptic: .lightSuccess, priority: .low) + let operation = votes.myVote == inputOp ? ScoringOperation.resetVote : inputOp + + // state fake + let original: PostModel = .init(from: self) + await setVotes(votes.applyScoringOperation(operation: operation)) + await setRead(true) + + // API call + do { + let updatedPost = try await postRepository.ratePost(postId: postId, operation: operation) + await reinit(from: updatedPost) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await reinit(from: original) + } + } + + func markRead(_ newRead: Bool) async { + // state fake + let original: PostModel = .init(from: self) + await setRead(newRead) + + // API call + do { + let updatedPost = try await postRepository.markRead(post: self, read: newRead) + await reinit(from: updatedPost) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await reinit(from: original) + } + } + + func toggleSave(upvoteOnSave: Bool) async { + let shouldSave: Bool = !saved + + // state fake + let original: PostModel = .init(from: self) + await setSaved(shouldSave) + await setRead(true) + if shouldSave, upvoteOnSave, votes.myVote != .upvote { + await setVotes(votes.applyScoringOperation(operation: .upvote)) + } + + // API call + do { + let saveResponse = try await postRepository.savePost(postId: postId, shouldSave: shouldSave) + + if shouldSave, upvoteOnSave { + let voteResponse = try await postRepository.ratePost(postId: postId, operation: .upvote) + await reinit(from: voteResponse) + } else { + await reinit(from: saveResponse) + } + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await reinit(from: original) + } + } + + func edit( + name: String?, + url: String?, + body: String?, + nsfw: Bool? + ) async { + // no need to state fake because editor spins until call completes + do { + hapticManager.play(haptic: .success, priority: .high) + let response = try await postRepository.editPost(postId: postId, name: name, url: url, body: body, nsfw: nsfw) + await reinit(from: response) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + } + } + + func delete() async { + // state fake + let original: PostModel = .init(from: self) + await setDeleted(true) + + // API call + do { + let deletedResponse = try await postRepository.deletePost(postId: postId, shouldDelete: true) + await reinit(from: deletedResponse) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await reinit(from: original) + } + } + + // MARK: Utility Methods + var postType: PostType { // post with URL: either image or link if let postUrl = post.linkUrl { @@ -107,10 +257,6 @@ struct PostModel { } } -extension PostModel: Identifiable { - var id: Int { hashValue } -} - extension PostModel: Hashable { /// Hashes all fields for which state changes should trigger view updates. func hash(into hasher: inout Hasher) { @@ -121,3 +267,13 @@ extension PostModel: Hashable { hasher.combine(post.updated) } } + +extension PostModel: Identifiable { + var id: Int { hashValue } +} + +extension PostModel: Equatable { + static func == (lhs: PostModel, rhs: PostModel) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Mlem/Models/Navigation Contexts/Community Link.swift b/Mlem/Models/Navigation Contexts/Community Link.swift index d3d52f7d6..acd3469d3 100644 --- a/Mlem/Models/Navigation Contexts/Community Link.swift +++ b/Mlem/Models/Navigation Contexts/Community Link.swift @@ -21,5 +21,5 @@ struct CommunityLinkWithContext: Equatable, Identifiable, Hashable { var id: Int { hashValue } let community: CommunityModel? - let feedType: FeedType + let feedType: APIListingType } diff --git a/Mlem/Models/Navigation Contexts/Post Link.swift b/Mlem/Models/Navigation Contexts/Post Link.swift index 50494b866..88a24f5bb 100644 --- a/Mlem/Models/Navigation Contexts/Post Link.swift +++ b/Mlem/Models/Navigation Contexts/Post Link.swift @@ -21,6 +21,6 @@ struct PostLinkWithContext: Equatable, Identifiable, Hashable { let post: PostModel var community: CommunityModel? - let postTracker: PostTracker + let postTracker: StandardPostTracker var scrollTarget: Int? } diff --git a/Mlem/Models/Trackers/Feeds/StandardPostTracker.swift b/Mlem/Models/Trackers/Feeds/StandardPostTracker.swift new file mode 100644 index 000000000..b2f995a6e --- /dev/null +++ b/Mlem/Models/Trackers/Feeds/StandardPostTracker.swift @@ -0,0 +1,295 @@ +// +// StandardPostTracker.swift +// Mlem +// +// Created by Eric Andrews on 2024-01-04. +// + +import Dependencies +import Foundation +import Nuke + +/// Enumeration of criteria on which to filter a post +enum PostFilter: Hashable { + /// Post is filtered because it was read + case read + + /// Post is filtered because it contains a blocked keyword + case keyword + + /// Post is filtered because the user is blocked (associated value is user id) + case blockedUser(Int) + + /// Post is filtered because community is blocked (associated value is community id) + case blockedCommunity(Int) + + func hash(into hasher: inout Hasher) { + switch self { + case .read: + hasher.combine("read") + case .keyword: + hasher.combine("keyword") + case let .blockedUser(userId): + hasher.combine("blockedUser") + hasher.combine(userId) + case let .blockedCommunity(communityId): + hasher.combine("blockedCommunity") + hasher.combine(communityId) + } + } +} + +/// Post tracker for use with single feeds. Supports all post sorting types, but is not suitable for multi-feed use. +class StandardPostTracker: StandardTracker { + @Dependency(\.postRepository) var postRepository + @Dependency(\.personRepository) var personRepository + @Dependency(\.persistenceRepository) var persistenceRepository + @Dependency(\.siteInformation) var siteInformation + @Dependency(\.apiClient) var apiClient + + // TODO: ERIC keyword filters could be more elegant + var filteredKeywords: [String] + + var feedType: FeedType + private(set) var postSortType: PostSortType + private var filters: [PostFilter: Int] + + // prefetching + private let prefetcher = ImagePrefetcher( + pipeline: ImagePipeline.shared, + destination: .memoryCache, + maxConcurrentRequestCount: 40 + ) + + init(internetSpeed: InternetSpeed, sortType: PostSortType, showReadPosts: Bool, feedType: FeedType) { + @Dependency(\.persistenceRepository) var persistenceRepository + + self.feedType = feedType + self.postSortType = sortType + + self.filteredKeywords = persistenceRepository.loadFilteredKeywords() + self.filters = [.keyword: 0] + if !showReadPosts { + filters[.read] = 0 + } + + super.init(internetSpeed: internetSpeed) + } + + override func refresh(clearBeforeRefresh: Bool) async throws { + filteredKeywords = persistenceRepository.loadFilteredKeywords() + try await super.refresh(clearBeforeRefresh: clearBeforeRefresh) + } + + // MARK: StandardTracker Loading Methods + + override func fetchPage(page: Int) async throws -> FetchResponse { + let (items, cursor) = try await loadPageHelper(page: page) + + let filteredItems = filter(items) + preloadImages(filteredItems) + return .init(items: filteredItems, cursor: cursor, numFiltered: items.count - filteredItems.count) + } + + override func fetchCursor(cursor: String?) async throws -> FetchResponse { + let (items, cursor) = try await postRepository.loadPage( + communityId: feedType.communityId, + page: page, + cursor: cursor, + sort: postSortType, + type: feedType.toApiListingType, + limit: internetSpeed.pageSize + ) + + let filteredItems = filter(items) + preloadImages(filteredItems) + return .init(items: filteredItems, cursor: cursor, numFiltered: items.count - filteredItems.count) + } + + /// Helper function to make loading saved items and feed items look the same to `fetchPage` + func loadPageHelper(page: Int) async throws -> (items: [PostModel], cursor: String?) { + if feedType == .saved { + guard let userId = siteInformation.myUserInfo?.localUserView.person.id else { + assertionFailure("Called loadPageHelper with no valid user!") + return (items: .init(), cursor: nil) + } + + if page > 1 { + return (items: .init(), cursor: nil) + } + + let savedContentData = try await personRepository.loadUserDetails( + for: userId, + limit: internetSpeed.pageSize, + savedOnly: true + ) + return (items: savedContentData.posts.map { PostModel(from: $0) }, cursor: nil) + } else { + return try await postRepository.loadPage( + communityId: feedType.communityId, + page: page, + cursor: nil, + sort: postSortType, + type: feedType.toApiListingType, + limit: internetSpeed.pageSize + ) + } + } + + // MARK: Custom Behavior + + /// Changes the post sort type to the specified value and reloads the feed + func changeSortType(to newSortType: PostSortType, forceRefresh: Bool = false) async { + // don't do anything if sort type not changed + guard postSortType != newSortType || forceRefresh else { + return + } + + postSortType = newSortType + do { + try await refresh(clearBeforeRefresh: true) + } catch { + errorHandler.handle(error) + } + } + + @MainActor + func changeFeedType(to newFeedType: FeedType) async { + // don't do anything if feed type not changed + guard feedType != newFeedType else { + return + } + + feedType = newFeedType + do { + try await refresh(clearBeforeRefresh: true) + } catch { + errorHandler.handle(error) + } + } + + @available( + *, + deprecated, + message: "Compatibility function for UserView. Should be removed and UserView refactored to use new multi-trackers." + ) + func reset(with newPosts: [PostModel]) async { + await setItems(newPosts) + } + + /// Applies a filter to all items currently in the tracker, but does **NOT** add the filter to the tracker! + /// Use in situations where filtering is handled server-side but should be retroactively applied to the current set of posts (e.g., filtering posts from a blocked user or community) + /// - Parameter filter: filter to apply + func applyFilter(_ filter: PostFilter) async { + await setItems(items.filter { shouldFilterPost($0, filters: [filter]) == nil }) + } + + /// Adds a filter to the tracker, removing all current posts that do not pass the filter and filtering out all future posts that do not pass the filter. + /// Use in situations where filtering is handled client-side (e.g., filtering read posts or keywords) + /// - Parameter newFilter: NewPostFilterReason describing the filter to apply + func addFilter(_ newFilter: PostFilter) async { + guard !filters.keys.contains(newFilter) else { + assertionFailure("Cannot apply new filter (already present in filters!)") + return + } + + filters[newFilter] = 0 + await setItems(filter(items)) + + if items.isEmpty { + do { + try await refresh(clearBeforeRefresh: false) + } catch { + errorHandler.handle(error) + } + } + } + + func removeFilter(_ filterToRemove: PostFilter) async { + guard filters.keys.contains(filterToRemove) else { + assertionFailure("Cannot remove filter (not present in filters!)") + return + } + + filters.removeValue(forKey: filterToRemove) + do { + try await refresh(clearBeforeRefresh: true) + } catch { + errorHandler.handle(error) + } + } + + func getFilteredCount(for filter: PostFilter) -> Int { + filters[filter, default: 0] + } + + /// Filters a given list of posts. Updates the counts of filtered posts in `filters` + /// - Parameter posts: list of posts to filter + /// - Returns: list of posts with filtered posts removed + private func filter(_ posts: [PostModel]) -> [PostModel] { + var ret: [PostModel] = .init() + + for post in posts { + if let filterReason = shouldFilterPost(post, filters: Array(filters.keys)) { + filters[filterReason] = filters[filterReason, default: 0] + 1 + } else { + ret.append(post) + } + } + + return ret + } + + /// Given a post, determines whether it should be filtered + /// - Returns: the first reason according to which the post should be filtered, if applicable, or nil if the post should not be filtered + private func shouldFilterPost(_ postModel: PostModel, filters: [PostFilter]) -> PostFilter? { + for filter in filters { + switch filter { + case .read: + if postModel.read { return filter } + case .keyword: + if postModel.post.name.lowercased().contains(filteredKeywords) { return filter } + case let .blockedUser(userId): + if postModel.creator.userId == userId { return filter } + case let .blockedCommunity(communityId): + if postModel.community.communityId == communityId { return filter } + } + } + return nil + } + + private func preloadImages(_ newPosts: [PostModel]) { + URLSession.shared.configuration.urlCache = AppConstants.urlCache + var imageRequests: [ImageRequest] = [] + for post in newPosts { + // preload user and community avatars--fetching both because we don't know which we'll need, but these are super tiny + // so it's probably not an API crime, right? + if let communityAvatarLink = post.community.avatar { + imageRequests.append(ImageRequest(url: communityAvatarLink.withIconSize(Int(AppConstants.smallAvatarSize * 2)))) + } + + if let userAvatarLink = post.creator.avatar { + imageRequests.append(ImageRequest(url: userAvatarLink.withIconSize(Int(AppConstants.largeAvatarSize * 2)))) + } + + switch post.postType { + case let .image(url): + // images: only load the image + imageRequests.append(ImageRequest(url: url, priority: .high)) + case let .link(url): + // websites: load image and favicon + if let baseURL = post.post.linkUrl?.host, + let favIconURL = URL(string: "https://www.google.com/s2/favicons?sz=64&domain=\(baseURL)") { + imageRequests.append(ImageRequest(url: favIconURL)) + } + if let url { + imageRequests.append(ImageRequest(url: url, priority: .high)) + } + default: + break + } + } + + prefetcher.startPrefetching(with: imageRequests) + } +} diff --git a/Mlem/Models/Trackers/Generics/ChildTracker.swift b/Mlem/Models/Trackers/Generics/ChildTracker.swift index 2c722a03b..b56666bc2 100644 --- a/Mlem/Models/Trackers/Generics/ChildTracker.swift +++ b/Mlem/Models/Trackers/Generics/ChildTracker.swift @@ -8,7 +8,16 @@ import Foundation class ChildTracker: StandardTracker, ChildTrackerProtocol { private weak var parentTracker: (any ParentTrackerProtocol)? - private var cursor: Int = 0 + private var streamCursor: Int = 0 + + private(set) var sortType: TrackerSortType + + var allItems: [ParentItem] { items.map { toParent(item: $0) }} + + init(internetSpeed: InternetSpeed, sortType: TrackerSortType) { + self.sortType = sortType + super.init(internetSpeed: internetSpeed) + } func toParent(item: Item) -> ParentItem { preconditionFailure("This method must be implemented by the inheriting class") @@ -19,28 +28,31 @@ class ChildTracker: StandardTracker< } /// Gets the next item in the feed stream and increments the cursor - /// **WARNING** this is NOT a thread-safe function! Only one thread at a time may call this function! /// - Returns: next item in the feed stream + /// - Warning: This is NOT a thread-safe function! Only one thread at a time may call this function! func consumeNextItem() -> ParentItem? { - assert(cursor < items.count, "consumeNextItem called on a tracker without a next item (cursor: \(cursor), count: \(items.count))!") + assert( + streamCursor < items.count, + "consumeNextItem called on a tracker without a next item (cursor: \(streamCursor), count: \(items.count))!" + ) - if cursor < items.count { - cursor += 1 - return toParent(item: items[cursor - 1]) + if streamCursor < items.count { + streamCursor += 1 + return toParent(item: items[streamCursor - 1]) } return nil } /// Gets the sort value of the next item in feed stream for a given sort type without affecting the cursor. The sort type must match the sort type of this tracker. - /// **WARNING** this is NOT a thread-safe function! Only one thread at a time may call this function! /// - Parameter sortType: type of sorting being performed /// - Returns: sorting value of the next tracker item corresponding to the given sort type + /// - Warning: This is NOT a thread-safe function! Only one thread at a time may call this function! func nextItemSortVal(sortType: TrackerSortType) async throws -> TrackerSortVal? { assert(sortType == self.sortType, "Conflicting types for sortType! This will lead to unexpected sorting behavior.") - if cursor < items.count { - return items[cursor].sortVal(sortType: sortType) + if streamCursor < items.count { + return items[streamCursor].sortVal(sortType: sortType) } else { // if done loading, return nil if loadingState == .done { @@ -49,19 +61,19 @@ class ChildTracker: StandardTracker< // otherwise, wait for the next page to load and try to return the first value // if the next page is already loading, this call to loadNextPage will be noop, but still wait until that load completes thanks to the semaphore - await loadNextPage() - return cursor < items.count ? items[cursor].sortVal(sortType: sortType) : nil + await loadMoreItems() + return streamCursor < items.count ? items[streamCursor].sortVal(sortType: sortType) : nil } } /// Resets the cursor to 0 but does not unload any items func resetCursor() { - cursor = 0 + streamCursor = 0 } func refresh(clearBeforeRefresh: Bool, notifyParent: Bool = true) async throws { try await refresh(clearBeforeRefresh: clearBeforeRefresh) - cursor = 0 + streamCursor = 0 if notifyParent, let parentTracker { await parentTracker.refresh(clearBeforeFetch: clearBeforeRefresh) @@ -69,8 +81,8 @@ class ChildTracker: StandardTracker< } func reset(notifyParent: Bool = true) async { - await reset() - cursor = 0 + await clear() + streamCursor = 0 if notifyParent, let parentTracker { await parentTracker.reset() } @@ -80,7 +92,7 @@ class ChildTracker: StandardTracker< let newItems = items.filter(filter) let removed = items.count - newItems.count - cursor = 0 + streamCursor = 0 await setItems(newItems) return removed diff --git a/Mlem/Models/Trackers/Generics/ChildTrackerProtocol.swift b/Mlem/Models/Trackers/Generics/ChildTrackerProtocol.swift index 82d0140a4..cb97ea78c 100644 --- a/Mlem/Models/Trackers/Generics/ChildTrackerProtocol.swift +++ b/Mlem/Models/Trackers/Generics/ChildTrackerProtocol.swift @@ -9,8 +9,12 @@ import Foundation protocol ChildTrackerProtocol: AnyObject { associatedtype Item: TrackerItem associatedtype ParentItem: TrackerItem + + /// All items present in the tracker + /// - Warning: this should not be directly accessed by the parent except to perform filtering! + var allItems: [ParentItem] { get } - // stream support methods + // MARK: stream support methods func setParentTracker(_ newParent: any ParentTrackerProtocol) @@ -20,7 +24,7 @@ protocol ChildTrackerProtocol: AnyObject { func resetCursor() - // loading methods + // MARK: loading methods func reset(notifyParent: Bool) async diff --git a/Mlem/Models/Trackers/Generics/CoreTracker.swift b/Mlem/Models/Trackers/Generics/CoreTracker.swift index f06f4b8c2..4464432f0 100644 --- a/Mlem/Models/Trackers/Generics/CoreTracker.swift +++ b/Mlem/Models/Trackers/Generics/CoreTracker.swift @@ -7,7 +7,7 @@ import Foundation -/// Class providing common tracker functionality for BasicTracker and ParentTracker +/// Class providing common tracker functionality for StandardTracker and ParentTracker class CoreTracker: ObservableObject { @Published var items: [Item] = .init() @Published private(set) var loadingState: LoadingState = .idle @@ -17,11 +17,9 @@ class CoreTracker: ObservableObject { private(set) var fallbackThreshold: ContentModelIdentifier? private(set) var internetSpeed: InternetSpeed - private(set) var sortType: TrackerSortType - - init(internetSpeed: InternetSpeed, sortType: TrackerSortType) { + + init(internetSpeed: InternetSpeed) { self.internetSpeed = internetSpeed - self.sortType = sortType } /// If the given item is the loading threshold item, loads more content @@ -30,12 +28,12 @@ class CoreTracker: ObservableObject { if loadingState == .idle, item.uid == threshold || item.uid == fallbackThreshold { // this is a synchronous function that wraps the loading as a task so that the task is attached to the tracker itself, not the view that calls it, and is therefore safe from being cancelled by view redraws Task(priority: .userInitiated) { - await loadNextPage() + await loadMoreItems() } } } - func loadNextPage() async { + func loadMoreItems() async { preconditionFailure("This method must be overridden by the inheriting class") } @@ -60,6 +58,11 @@ class CoreTracker: ObservableObject { updateThresholds() } + @MainActor + func prependItem(_ newItem: Item) async { + items.prepend(newItem) + } + private func updateThresholds() { if items.isEmpty { threshold = nil diff --git a/Mlem/Models/Trackers/Generics/ParentTracker.swift b/Mlem/Models/Trackers/Generics/ParentTracker.swift index abaa5b5e9..74bbe402a 100644 --- a/Mlem/Models/Trackers/Generics/ParentTracker.swift +++ b/Mlem/Models/Trackers/Generics/ParentTracker.swift @@ -14,11 +14,14 @@ class ParentTracker: CoreTracker, ParentTrackerProtocol private var childTrackers: [any ChildTrackerProtocol] = .init() private let loadingSemaphore: AsyncSemaphore = .init(value: 1) + + private(set) var sortType: TrackerSortType init(internetSpeed: InternetSpeed, sortType: TrackerSortType, childTrackers: [any ChildTrackerProtocol]) { self.childTrackers = childTrackers + self.sortType = sortType - super.init(internetSpeed: internetSpeed, sortType: sortType) + super.init(internetSpeed: internetSpeed) for child in self.childTrackers { child.setParentTracker(self) @@ -36,7 +39,7 @@ class ParentTracker: CoreTracker, ParentTrackerProtocol // MARK: loading methods /// Loads the next page of items - override func loadNextPage() async { + override func loadMoreItems() async { guard loadingState != .done else { return } @@ -72,11 +75,17 @@ class ParentTracker: CoreTracker, ParentTrackerProtocol /// Filters out items according to the given filtering function. /// - Parameter filter: function that, given an Item, returns true if the item should REMAIN in the tracker func filter(with filter: @escaping (Item) -> Bool) async { - // build set of uids to remove + // build set of uids to remove. need to iterate through every item in every tracker because trackers may have items that should be filtered but are not present in the parent yet var uidsToFilter: Set = .init() - items.forEach { item in - if !filter(item) { - uidsToFilter.insert(item.uid) + childTrackers.forEach { child in + child.allItems.forEach { item in + guard let item = item as? Item else { + assertionFailure("Could not convert to parent type!") + return + } + if !filter(item) { + uidsToFilter.insert(item.uid) + } } } @@ -100,6 +109,8 @@ class ParentTracker: CoreTracker, ParentTrackerProtocol return removed } + print("[\(Item.self) tracker] removed \(removed) items, fetching more") + // reload all non-removed items let remaining = items.count - removed let newItems = await fetchNextItems(numItems: max(remaining, abs(AppConstants.infiniteLoadThresholdOffset) + 1)) diff --git a/Mlem/Models/Trackers/Generics/README - Generic Trackers.md b/Mlem/Models/Trackers/Generics/README - Generic Trackers.md new file mode 100644 index 000000000..60f7de79c --- /dev/null +++ b/Mlem/Models/Trackers/Generics/README - Generic Trackers.md @@ -0,0 +1,23 @@ +# Generic Trackers + +This group contains a set of generic classes intended to back feed views. This document is intended as a high-level overview of the design principles and a quickstart guide for using the trackers; for detailed information, refer to the inline documentation. + +## Tracker Operation + +The heart of a tracker is very simple: an array of items and a method for loading more. + +Note that tracker models must be classes, not structs, as much of the logic is built on the assumption that items will be passed by reference. + +## Tracker Types + +There are three types of trackers: `StandardTracker`, `ChildTracker`, and `ParentTracker`. `CoreTracker` holds shared logic between these trackers, and should **not** be used! + +`StandardTracker` should be used for feeds with a single item type (e.g., the main posts feed). To use it, simply create an inheriting class + +`ChildTracker` and `ParentTracker` should always be used in conjunction! They handle feeds with mixed item types (e.g., the inbox feed). `ChildTracker` is a modified version of `StandardTracker`, and can safely be used to drive its own feed in addition to the mixed feed (as is done in the inbox). `ParentTracker` offers a similar interface, but functions radically differently: it relies on its `ChildTracker`s to load items! + +To create a multi-tracker, first create a protocol `MyTrackerItem` conforming to `TrackerItem` and an enum `AnyMyTrackerItem` conforming to `MyTrackerItem`. For each child type, create an extension conforming it to `MyTrackerItem` and add a case to `AnyMyTrackerItem` for that type with the associated value of the content type. With that done, create one child tracker for each child type (`class FooTracker: ChildTracker`) and a single parent tracker inheriting from `ParentTracker` (`class MyTracker: ParentTracker { + /// Items returned + let items: [Item] + + /// New cursor, if applicable + let cursor: String? + + /// Number of items filtered out + let numFiltered: Int + + /// True if the response has content, false otherwise. It is possible for a filter to remove all fetched items; this avoids that triggering an erroneous end of feed. + var hasContent: Bool { items.count + numFiltered > 0 } +} + class StandardTracker: CoreTracker { @Dependency(\.errorHandler) var errorHandler - // loading state + /// loading state private var ids: Set = .init(minimumCapacity: 1000) - private(set) var page: Int = 0 // number of the most recently loaded page--0 indicates no content + /// number of the most recently loaded page. 0 indicates no content. + private(set) var page: Int = 0 + /// cursor of the most recently loaded page. nil indicates no content. + private(set) var loadingCursor: String? private let loadingSemaphore: AsyncSemaphore = .init(value: 1) // MARK: - Main actor methods @@ -30,64 +63,133 @@ class StandardTracker: CoreTracker { // MARK: - External methods - override func loadNextPage() async { + override func loadMoreItems() async { do { - try await loadPage(page + 1) + // declare this once here to avoid nasty race conditions + let pageToLoad = page + 1 + + if pageToLoad == 1 { + // for loading first page, always use refresh--functions identically for page and cursor + try await load(action: .refresh(false)) + } else { + // for loading subsequent pages, use cursor if available, page otherwise + if let loadingCursor { + try await load(action: .loadCursor(loadingCursor)) + } else { + try await load(action: .loadPage(pageToLoad)) + } + } } catch { errorHandler.handle(error) } } func refresh(clearBeforeRefresh: Bool) async throws { - try await loadPage(1, clearBeforeRefresh: clearBeforeRefresh) + try await load(action: .refresh(clearBeforeRefresh)) } - func reset() async { + func clear() async { do { - try await loadPage(0) + try await load(action: .clear) } catch { assertionFailure("Exception thrown when resetting, this should not be possible!") - await clear() // this is not a thread-safe use of clear, but I'm using it here because we should never get here + await clearHelper() // this is not a thread-safe use of clear, but I'm using it here because we should never get here } } - // MARK: - Internal tracking methods + // MARK: - Internal methods - /// Loads the requested page. To account for the fact that multiple threads might request a load at the same time, this function requires that the caller pass in what it thinks is the next page to load. If that is not the next page by the time that call is allowed to execute, its request will be ignored. + /// Performs the requested loading operation. To account for the fact that multiple threads might request a load at the same time, this function requires that the caller pass in what it thinks is the next page or cursor to load. If that is not the next page/cursor by the time that call is allowed to execute, its request will be ignored. /// This grants this function an additional, extremely useful property: calling `await loadPage` while `loadPage` is already being executed will, practically speaking, await the in-flight request. /// There is additional logic to handle the reset case--because page is updated at the end of this call, if reset() set the page to 0 itself and a reset call were made while another loading call was in-flight, the in-flight call would update page before the reset call went through and the reset call's load would be aborted. Instead, this method takes on responsibility for resetting--calling it on page 0 clears the tracker, and page 1 refreshes it /// - Parameter page: page number to load - func loadPage(_ pageToLoad: Int, clearBeforeRefresh: Bool = false) async throws { - assert(!clearBeforeRefresh || pageToLoad == 1, "clearBeforeRefresh cannot be true if not loading page 1") - + func load(action: LoadAction) async throws { // only one thread may execute this function at a time await loadingSemaphore.wait() defer { loadingSemaphore.signal() } - - // special reset cases - if pageToLoad == 0 { + + switch action { + case .clear: print("[\(Item.self) tracker] clearing") - await clear() - return - } - - if pageToLoad == 1 { + await clearHelper() + case let .refresh(clearBeforeRefresh): print("[\(Item.self) tracker] refreshing") - if clearBeforeRefresh { - await clear() - } else { - // if not clearing before reset, still clear these fields in order to sanitize the loading state--we just keep the items in place until we have received new ones, which will be set below - page = 0 - ids = .init(minimumCapacity: 1000) - await setLoading(.idle) - } - } - - if pageToLoad > 1 { + try await refreshHelper(clearBeforeRefresh: clearBeforeRefresh) + case let .loadPage(pageToLoad): print("[\(Item.self) tracker] loading page \(pageToLoad)") + try await loadPageHelper(pageToLoad) + case let .loadCursor(cursorToLoad): + print("[\(Item.self) tracker] loading cursor") + try await loadCursorHelper(cursorToLoad) } + } + + /// Fetches the given page of items. This method must be overridden by the instantiating class because different items are loaded differently. Relies on the instantiating class to handle fetch parameters such as unreadOnly and page size. + /// - Parameters: + /// - page: page number to fetch + /// - Returns: tuple of the requested page of items, the cursor returned by the API call (if present), and the number of items that were filtered out. + func fetchPage(page: Int) async throws -> FetchResponse { + preconditionFailure("This method must be implemented by the inheriting class") + } + + // Fetches items from the given cursor. This method must be overridden by the instantiating class because different items are loaded differently. Relies on the instantiating class to handle fetch parameters such as unreadOnly and page size. + /// - Parameters: + /// - cursor: cursor to fetch + /// - Returns: tuple of the requested page of items, the cursor returned by the API call (if present), and the number of items that were filtered out. + func fetchCursor(cursor: String) async throws -> FetchResponse { + preconditionFailure("This method must be implemented by the inheriting class") + } + + // MARK: - Helpers + + /// Filters out items according to the given filtering function. + /// - Parameter filter: function that, given an Item, returns true if the item should REMAIN in the tracker + @discardableResult func filter(with filter: @escaping (Item) -> Bool) async -> Int { + let newItems = items.filter(filter) + let removed = items.count - newItems.count - // do not continue to load if done. this check has to come after the clear/refresh cases because those cases can be called on a .done tracker + await setItems(newItems) + + return removed + } + + /// Given an array of Items, adds their ids to ids. Returns the input filtered to only items not previously present in ids. + /// - Parameter newMessages: array of MessageModel + /// - Returns: `newMessages`, filtered to only messages not already present in ids + private func storeIdsAndDedupe(newItems: [Item]) -> [Item] { + let accepted = newItems.filter { ids.insert($0.uid).inserted } + return accepted + } + + /// Clears the tracker to an empty state. + /// - Warning: **DO NOT** call this method from anywhere but `load`! This is *purely* a helper function for `load` and *will* lead to unexpected behavior if called elsewhere! + private func clearHelper() async { + ids = .init(minimumCapacity: 1000) + page = 0 + await setLoading(.idle) + await setItems(.init()) + } + + /// Clears + /// - Warning: **DO NOT** call this method from anywhere but `load`! This is *purely* a helper function for `load` and *will* lead to unexpected behavior if called elsewhere! + private func refreshHelper(clearBeforeRefresh: Bool) async throws { + if clearBeforeRefresh { + await clearHelper() + } else { + // if not clearing before reset, still clear these fields in order to sanitize the loading state--we just keep the items in place until we have received new ones, which will be set by loadPage/loadCursor + page = 0 + loadingCursor = nil + ids = .init(minimumCapacity: 1000) + await setLoading(.idle) + } + try await loadPageHelper(1) + } + + /// Loads a given page of items + /// - Parameter pageToLoad: page to load + /// - Warning: **DO NOT** call this method from anywhere but `load`! This is *purely* a helper function for `load` and *will* lead to unexpected behavior if called elsewhere! + private func loadPageHelper(_ pageToLoad: Int) async throws { + // do not continue to load if done guard loadingState != .done else { print("[\(Item.self) tracker] done loading, will not continue") return @@ -99,18 +201,21 @@ class StandardTracker: CoreTracker { return } + await setLoading(.loading) + var newItems: [Item] = .init() while newItems.count < internetSpeed.pageSize { - let fetchedItems = try await fetchPage(page: page + 1) + let fetched = try await fetchPage(page: page + 1) page += 1 + loadingCursor = fetched.cursor - if fetchedItems.isEmpty { + if !fetched.hasContent { print("[\(Item.self) tracker] fetch returned no items, setting loading state to done") await setLoading(.done) break } - newItems.append(contentsOf: fetchedItems) + newItems.append(contentsOf: fetched.items) } let allowedItems = storeIdsAndDedupe(newItems: newItems) @@ -126,43 +231,44 @@ class StandardTracker: CoreTracker { await setLoading(.idle) } } - - // MARK: - Helpers - /// Fetches the next page of items. This method must be overridden by the instantiating class because different items are loaded differently. Relies on the instantiating class to handle fetch parameters such as unreadOnly and page size. - /// - Parameters: - /// - page: page number to fetch - /// - Returns: requested page of items - func fetchPage(page: Int) async throws -> [Item] { - preconditionFailure("This method must be implemented by the inheriting class") - } - - /// Filters out items according to the given filtering function. - /// - Parameter filter: function that, given an Item, returns true if the item should REMAIN in the tracker - @discardableResult func filter(with filter: @escaping (Item) -> Bool) async -> Int { - let newItems = items.filter(filter) - let removed = items.count - newItems.count + private func loadCursorHelper(_ cursor: String) async throws { + // do not continue to load if done + guard loadingState != .done else { + print("[\(Item.self) tracker] done loading, will not continue") + return + } + + // do nothing if this is not the next page to load + guard cursor == loadingCursor else { + print("[\(Item.self) tracker] will not load cursor \(cursor) (current cursor is \(String(describing: loadingCursor))") + return + } - await setItems(newItems) + await setLoading(.loading) - return removed - } - - /// Given an array of Items, adds their ids to ids. Returns the input filtered to only items not previously present in ids. - /// - Parameter newMessages: array of MessageModel - /// - Returns: newMessages, filtered to only messages not already present in ids - private func storeIdsAndDedupe(newItems: [Item]) -> [Item] { - let accepted = newItems.filter { ids.insert($0.uid).inserted } - return accepted - } - - /// Clears the tracker to an empty state. - /// **WARNING:** - /// **DO NOT** call this method from anywhere but loadPage! This is *purely* a helper function for loadPage and *will* lead to unexpected behavior if called elsewhere! - private func clear() async { - ids = .init(minimumCapacity: 1000) - page = 0 - await setLoading(.idle) - await setItems(.init()) + var newItems: [Item] = .init() + while newItems.count < internetSpeed.pageSize { + let fetched = try await fetchCursor(cursor: cursor) + + if !fetched.hasContent || fetched.cursor == loadingCursor { + print("[\(Item.self) tracker] fetch returned no items or EOF cursor, setting loading state to done") + await setLoading(.done) + break + } + + loadingCursor = fetched.cursor + page += 1 // not strictly necessary but good for tracking number of loaded pages + + newItems.append(contentsOf: fetched.items) + } + + let allowedItems = storeIdsAndDedupe(newItems: newItems) + + await addItems(allowedItems) + + if loadingState != .done { + await setLoading(.idle) + } } } diff --git a/Mlem/Models/Trackers/Inbox/MentionTracker.swift b/Mlem/Models/Trackers/Inbox/MentionTracker.swift index bcb62f005..69f2ea102 100644 --- a/Mlem/Models/Trackers/Inbox/MentionTracker.swift +++ b/Mlem/Models/Trackers/Inbox/MentionTracker.swift @@ -18,8 +18,10 @@ class MentionTracker: ChildTracker { super.init(internetSpeed: internetSpeed, sortType: sortType) } - override func fetchPage(page: Int) async throws -> [MentionModel] { - try await inboxRepository.loadMentions(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) + override func fetchPage(page: Int) async throws -> FetchResponse { + // TODO: can this return a cursor? + let newItems = try await inboxRepository.loadMentions(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) + return .init(items: newItems, cursor: nil, numFiltered: 0) } override func toParent(item: MentionModel) -> AnyInboxItem { diff --git a/Mlem/Models/Trackers/Inbox/MessageTracker.swift b/Mlem/Models/Trackers/Inbox/MessageTracker.swift index f62c88a86..f0135396f 100644 --- a/Mlem/Models/Trackers/Inbox/MessageTracker.swift +++ b/Mlem/Models/Trackers/Inbox/MessageTracker.swift @@ -17,8 +17,10 @@ class MessageTracker: ChildTracker { super.init(internetSpeed: internetSpeed, sortType: sortType) } - override func fetchPage(page: Int) async throws -> [MessageModel] { - try await inboxRepository.loadMessages(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) + override func fetchPage(page: Int) async throws -> FetchResponse { + // TODO: can this return a cursor? + let newItems = try await inboxRepository.loadMessages(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) + return .init(items: newItems, cursor: nil, numFiltered: 0) } override func toParent(item: MessageModel) -> AnyInboxItem { diff --git a/Mlem/Models/Trackers/Inbox/ReplyTracker.swift b/Mlem/Models/Trackers/Inbox/ReplyTracker.swift index 2854afa92..eb5fb11fc 100644 --- a/Mlem/Models/Trackers/Inbox/ReplyTracker.swift +++ b/Mlem/Models/Trackers/Inbox/ReplyTracker.swift @@ -18,8 +18,10 @@ class ReplyTracker: ChildTracker { super.init(internetSpeed: internetSpeed, sortType: sortType) } - override func fetchPage(page: Int) async throws -> [ReplyModel] { - try await inboxRepository.loadReplies(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) + override func fetchPage(page: Int) async throws -> FetchResponse { + // TODO: can this return a cursor? + let newItems = try await inboxRepository.loadReplies(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) + return .init(items: newItems, cursor: nil, numFiltered: 0) } override func toParent(item: ReplyModel) -> AnyInboxItem { diff --git a/Mlem/Models/Trackers/Post Tracker.swift b/Mlem/Models/Trackers/Post Tracker.swift deleted file mode 100644 index cc4ec6f91..000000000 --- a/Mlem/Models/Trackers/Post Tracker.swift +++ /dev/null @@ -1,541 +0,0 @@ -// -// Post Tracker.swift -// Mlem -// -// Created by Eric Andrews on 2023-08-26. -// - -import Dependencies -import Foundation -import Nuke -import SwiftUI - -enum PostFilterReason { - case read, keyword -} - -// swiftlint:disable type_body_length -// swiftlint:disable file_length -/// New post tracker built on top of the PostRepository instead of calling the API directly. Because this thing works fundamentally differently from the old one, it can't conform to FeedTracker--that's going to need a revamp down the line once everything uses nice shiny middleware models, so for now we're going to have to put up with some ugly -class PostTracker: ObservableObject { - // dependencies - @Dependency(\.postRepository) var postRepository - @Dependency(\.apiClient) var apiClient - @Dependency(\.hapticManager) var hapticManager - @Dependency(\.errorHandler) var errorHandler - - enum LoaderType: Equatable { - case feed(FeedType, sortedBy: PostSortType) - case community(CommunityModel, sortedBy: PostSortType) - } - - enum PostTrackerError: Error { - case notConfiguredForPageLoading - } - - // behavior governors - private let shouldPerformMergeSorting: Bool - private let internetSpeed: InternetSpeed - private let upvoteOnSave: Bool - - // state drivers - @Published private(set) var items: [PostModel] - @Published var type: LoaderType? - @Published var showLoadingIcon: Bool = true - - // utility - private var ids: Set = .init(minimumCapacity: 1000) - private(set) var isLoading: Bool = false // accessible but not published because it causes lots of bad view redraws - private(set) var page: Int = 1 - private(set) var hiddenItems: [PostFilterReason: Int] = .init() - private(set) var currentCursor: String? - - private var hasReachedEnd: Bool = false - - var filter: (PostModel) -> PostFilterReason? - var handleError: ((Error) -> Void)! - - // prefetching - private let prefetcher = ImagePrefetcher( - pipeline: ImagePipeline.shared, - destination: .memoryCache, - maxConcurrentRequestCount: 40 - ) - - init( - shouldPerformMergeSorting: Bool = true, - internetSpeed: InternetSpeed, - initialItems: [PostModel] = .init(), - upvoteOnSave: Bool, - type: LoaderType? = nil, - filter: @escaping (PostModel) -> PostFilterReason? = { _ in nil }, - handleError: ((Error) -> Void)? = nil - ) { - self.shouldPerformMergeSorting = shouldPerformMergeSorting - self.internetSpeed = internetSpeed - self.items = initialItems - self.upvoteOnSave = upvoteOnSave - self.type = type - self.filter = filter - self.handleError = handleError - if self.handleError == nil { - self.handleError = { error in self.errorHandler.handle(error) } - } - } - - // MARK: - Loading Methods - - // TODO: ERIC handle loading state properly - - func getNextPageFromRepository() async throws -> (posts: [PostModel], cursor: String?) { - switch self.type { - case .feed(let feedType, let postSortType): - return try await postRepository.loadPage( - communityId: nil, - page: page, - cursor: currentCursor, - sort: postSortType, - type: feedType, - limit: internetSpeed.pageSize - ) - case .community(let community, let postSortType): - return try await postRepository.loadPage( - communityId: community.communityId, - page: page, - cursor: currentCursor, - sort: postSortType, - type: .subscribed, - limit: internetSpeed.pageSize - ) - case nil: - throw PostTrackerError.notConfiguredForPageLoading - } - } - - func loadNextPage() async { - defer { DispatchQueue.main.async { self.showLoadingIcon = false } } - DispatchQueue.main.async { self.showLoadingIcon = true } - do { - let currentPage = page - - // retry this until we get enough items through the filter to enable autoload - var newPosts: [PostModel] = .init() - let numItems = items.count - repeat { - let (posts, cursor) = try await getNextPageFromRepository() - newPosts = posts - - if newPosts.isEmpty { - hasReachedEnd = true - } else if let currentCursor, cursor == currentCursor { - hasReachedEnd = true - } else { - await add(newPosts) - page += 1 - currentCursor = cursor - } - } while !hasReachedEnd && numItems > items.count + AppConstants.infiniteLoadThresholdOffset - - // so although the API kindly returns `400`/"not_logged_in" for expired - // sessions _without_ 2FA enabled, currently once you enable 2FA on an account - // an expired session for a call with optional authentication such as loading - // posts returns a `200` with an empty list of data 😭 - // if we get back an empty list for page 1, chances are this session is borked and - // the API doesn't want to tell us - so to avoid the user being confused, we'll fire - // off an authenticated call in the background and if appropriate show the expired - // session modal. We should be able to remove this once the API behaves as expected. - if currentPage == 1, newPosts.isEmpty { - try await apiClient.attemptAuthenticatedCall() - } - - // don't preload filtered images - preloadImages(filterItems(items: newPosts)) - } catch { - handleError(error) - } - } - - /// Loads a single post and adds it to the tracker - /// - Parameter postId: id of the post to load - /// - Returns: PostModel of the newly loaded post - @discardableResult - func loadPost(postId: Int) async throws -> PostModel { - let newPost = try await postRepository.loadPost(postId: postId) - await add([newPost], preload: true) - return newPost - } - - @discardableResult - func refresh(clearBeforeFetch: Bool = false) async -> Bool { - defer { DispatchQueue.main.async { self.showLoadingIcon = false } } - DispatchQueue.main.async { self.showLoadingIcon = true } - if clearBeforeFetch { - await reset() - } - - page = 1 - currentCursor = nil - - do { - let (newPosts, cursor) = try await getNextPageFromRepository() - - currentCursor = cursor - await reset(with: newPosts, cursor: cursor) - return true - } catch { - handleError(error) - return false - } - } - - func initFeed() async { - DispatchQueue.main.async { self.showLoadingIcon = true } - if items.isEmpty { - print("Post tracker is empty") - await loadNextPage() - } else { - print("Post tracker is not empty") - DispatchQueue.main.async { self.showLoadingIcon = false } - } - } - - @MainActor - /// Adds a given list of posts to items. Can be configured to perform filtering and preloading. - /// - Parameters: - /// - newItems: list of PostModels to add - /// - preload: true if the new post's image should be preloaded - func add( - _ newItems: [PostModel], - preload: Bool = false - ) { - let accepted = dedupedItems(from: filterItems(items: newItems)) - - if preload { preloadImages(newItems) } - - if !shouldPerformMergeSorting { - RunLoop.main.perform { [self] in - items.append(contentsOf: accepted) - } - return - } - - let merged = merge(arr1: items, arr2: accepted, compare: { $0.published > $1.published }) - RunLoop.main.perform { [self] in - items = merged - } - } - - @MainActor - func reset( - with newItems: [PostModel] = .init(), - cursor: String? = nil - ) { - hasReachedEnd = false - page = newItems.isEmpty ? 1 : 2 - currentCursor = cursor - if page == 1 { - hiddenItems.removeAll() - } - ids = .init(minimumCapacity: 1000) - items = dedupedItems(from: filterItems(items: newItems)) - } - - /// Determines whether the tracker should load more items - /// NOTE: this is equivalent to the old shouldLoadContentPreciselyAfter - @MainActor - func shouldLoadContentAfter(after item: PostModel) -> Bool { - guard !isLoading, !hasReachedEnd else { return false } - - let thresholdIndex = max(0, items.index(items.endIndex, offsetBy: AppConstants.infiniteLoadThresholdOffset)) - if thresholdIndex >= 0, - let itemIndex = items.firstIndex(where: { $0.uid == item.uid }), - itemIndex == thresholdIndex { - return true - } - - return false - } - - // MARK: - Post Management Methods - - /// If a post with the same id as the given post is present in the tracker, replaces it with the given post; otherwise does nothing and quietly returns. - /// - Parameter updatedPost: PostModel representing a post already present in the tracker with a new state - @MainActor - func update(with updatedPost: PostModel) { - guard let index = items.firstIndex(where: { $0.uid == updatedPost.uid }) else { - return - } - - items[index] = updatedPost - } - - @MainActor - func prepend(_ newPost: PostModel) { - guard ids.insert(newPost.uid).inserted else { return } - items.prepend(newPost) - } - - @MainActor - func removeUserPosts(from personId: Int) { - filter { - $0.creator.userId != personId - } - } - - @MainActor - func removeCommunityPosts(from communityId: Int) { - filter { - $0.community.communityId != communityId - } - } - - /// Takes a callback and filters out any entry that returns false - /// Returns the number of entries removed - @discardableResult func filter(_ callback: (PostModel) -> Bool) -> Int { - var removedElements = 0 - - items = items.filter { - let filterResult = callback($0) - - // Remove the ID from the IDs set as well - if !filterResult { - ids.remove($0.uid) - removedElements += 1 - } - return filterResult - } - - return removedElements - } - - // MARK: - Interaction Methods - - /// Applies the given scoring operation to the given post, provided the post is present in ids. If the given operation has already been applied, it will instead send .resetVote. - /// Performs state faking--posts will updated immediately with the predicted state of the post post-update, then updated to match the source of truth when the call returns. - /// - Parameters: - /// - post: PostModel of the post to vote on - /// - inputOp: ScoringOperation to apply to the given post - /// - Returns: PostModel with the updated post state (if the call fails, returns the original post model) - @discardableResult - func voteOnPost(post: PostModel, inputOp: ScoringOperation) async -> PostModel { - // TODO: returning the post does sometimes cause weird unwanted state flickers when spamming interactions - guard !isLoading else { return post } - defer { isLoading = false } - isLoading = true - - // ensure this is a valid post to vote on - guard ids.contains(post.uid) else { - assertionFailure("Upvote called on post not present in tracker") - hapticManager.play(haptic: .failure, priority: .high) - return post - } - - // compute appropriate operation - let operation = post.votes.myVote == inputOp ? ScoringOperation.resetVote : inputOp - - // fake state - let stateFakedPost = PostModel(from: post, votes: post.votes.applyScoringOperation(operation: operation)) - await update(with: stateFakedPost) - hapticManager.play(haptic: .lightSuccess, priority: .low) - - // perform real upvote - do { - let response = try await postRepository.ratePost(postId: post.postId, operation: operation) - await update(with: response) - return response - } catch { - hapticManager.play(haptic: .failure, priority: .high) - errorHandler.handle(error) - return post - } - } - - /// Toggles the save state of the given post. Performs state faking. - /// - Parameter post: PostModel of the post to save - /// - Returns: PostModel with the updated post state (if the call fails, returns the original post model) - @discardableResult - func toggleSave(post: PostModel) async -> PostModel { - guard !isLoading else { return post } - defer { isLoading = false } - isLoading = true - - // ensure this is a valid post to save - guard ids.contains(post.uid) else { - assertionFailure("Save called on post not present in tracker") - hapticManager.play(haptic: .failure, priority: .high) - return post - } - - let shouldSave: Bool = !post.saved - - // fake state - var stateFakedPost = PostModel(from: post, saved: shouldSave) - if upvoteOnSave, stateFakedPost.votes.myVote != .upvote { - stateFakedPost.votes = stateFakedPost.votes.applyScoringOperation(operation: .upvote) - } - await update(with: stateFakedPost) - hapticManager.play(haptic: .success, priority: .high) - - // perform real save - do { - let saveResponse = try await postRepository.savePost(postId: post.postId, shouldSave: shouldSave) - - if shouldSave, upvoteOnSave { - let voteResponse = try await postRepository.ratePost(postId: saveResponse.postId, operation: .upvote) - await update(with: voteResponse) - return voteResponse - } else { - await update(with: saveResponse) - return saveResponse - } - } catch { - hapticManager.play(haptic: .failure, priority: .high) - errorHandler.handle(error) - return post - } - } - - /// Marks the given post as read (does not toggle) - func markRead(post: PostModel) async { - guard !isLoading else { return } - defer { isLoading = false } - isLoading = true - - // ensure this is a valid post to mark read - guard ids.contains(post.uid) else { - assertionFailure("markRead called on post not present in tracker") - hapticManager.play(haptic: .failure, priority: .high) - return - } - - // fake state - let stateFakedPost = PostModel(from: post, read: true) - await update(with: stateFakedPost) - - // perform real read - do { - let response = try await postRepository.markRead(post: post, read: true) - await update(with: response) - } catch { - hapticManager.play(haptic: .failure, priority: .high) - errorHandler.handle(error) - } - } - - func delete(post: PostModel) async { - guard !isLoading else { return } - defer { isLoading = false } - isLoading = true - - // ensure this is a valid post to delete - guard ids.contains(post.uid) else { - assertionFailure("delete called on post not present in tracker") - hapticManager.play(haptic: .failure, priority: .high) - return - } - - // TODO: state faking (should wait until APIPost is replaced with PostContentModel) - - do { - hapticManager.play(haptic: .destructiveSuccess, priority: .high) - let response = try await postRepository.deletePost(postId: post.postId, shouldDelete: true) - await update(with: response) - } catch { - hapticManager.play(haptic: .failure, priority: .high) - errorHandler.handle(error) - } - } - - /// Edits the given post and updates the tracker. Only non-nil fields will be updated. - /// - Parameters: - /// - post: PostModel representing the new state of the post (current state of tracker) - @discardableResult - func edit( - post: PostModel, - name: String?, - url: String?, - body: String?, - nsfw: Bool? - ) async -> PostModel { - guard !isLoading else { return post } - defer { isLoading = false } - isLoading = true - - // ensure this is a valid post to delete - guard ids.contains(post.uid) else { - assertionFailure("edit called on post not present in tracker") - hapticManager.play(haptic: .failure, priority: .high) - return post - } - - // TODO: state faking (should wait until APIPost is replaced with PostContentModel) - - do { - hapticManager.play(haptic: .success, priority: .high) - let response = try await postRepository.editPost(postId: post.postId, name: name, url: url, body: body, nsfw: nsfw) - await update(with: response) - return response - } catch { - hapticManager.play(haptic: .failure, priority: .high) - errorHandler.handle(error) - return post - } - } - - // MARK: - Private Methods - - private func preloadImages(_ newPosts: [PostModel]) { - URLSession.shared.configuration.urlCache = AppConstants.urlCache - var imageRequests: [ImageRequest] = [] - for post in newPosts { - // preload user and community avatars--fetching both because we don't know which we'll need, but these are super tiny - // so it's probably not an API crime, right? - if let communityAvatarLink = post.community.avatar { - imageRequests.append(ImageRequest(url: communityAvatarLink.withIconSize(Int(AppConstants.smallAvatarSize * 2)))) - } - - if let userAvatarLink = post.creator.avatar { - imageRequests.append(ImageRequest(url: userAvatarLink.withIconSize(Int(AppConstants.largeAvatarSize * 2)))) - } - - switch post.postType { - case let .image(url): - // images: only load the image - imageRequests.append(ImageRequest(url: url, priority: .high)) - case let .link(url): - // websites: load image and favicon - if let baseURL = post.post.linkUrl?.host, - let favIconURL = URL(string: "https://www.google.com/s2/favicons?sz=64&domain=\(baseURL)") { - imageRequests.append(ImageRequest(url: favIconURL)) - } - if let url { - imageRequests.append(ImageRequest(url: url, priority: .high)) - } - default: - break - } - } - - prefetcher.startPrefetching(with: imageRequests) - } - - /// Filters a list of PostModels to only those PostModels not present in ids. Updates ids. - private func dedupedItems(from newItems: [PostModel]) -> [PostModel] { - newItems.filter { ids.insert($0.uid).inserted } - } - - private func filterItems( - items: [PostModel] - ) -> [PostModel] { - items.filter { item in - if let reason = self.filter(item) { - self.hiddenItems[reason] = self.hiddenItems[reason, default: 0] + 1 - return false - } - return true - } - } -} - -// swiftlint:enable type_body_length -// swiftlint:enable file_length diff --git a/Mlem/Models/Trackers/SiteInformationTracker.swift b/Mlem/Models/Trackers/SiteInformationTracker.swift index 0fd58be25..ffd0c81a3 100644 --- a/Mlem/Models/Trackers/SiteInformationTracker.swift +++ b/Mlem/Models/Trackers/SiteInformationTracker.swift @@ -23,7 +23,6 @@ class SiteInformationTracker: ObservableObject { version = account.siteVersion Task { do { - let response = try await apiClient.loadSiteInformation() enableDownvotes = response.siteView.localSite.enableDownvotes version = SiteVersion(response.version) diff --git a/Mlem/Navigation/Routes/AppRoutes.swift b/Mlem/Navigation/Routes/AppRoutes.swift index 23543b59d..ef7e2a107 100644 --- a/Mlem/Navigation/Routes/AppRoutes.swift +++ b/Mlem/Navigation/Routes/AppRoutes.swift @@ -12,11 +12,6 @@ import Foundation /// For simple (i.e. linear) navigation flows, you may wish to define a separate set of routes. For example, see `OnboardingRoutes`. /// enum AppRoute: Routable { - case communityLinkWithContext(CommunityLinkWithContext) - - case apiPostView(APIPostView) - case apiPost(APIPost) - case community(CommunityModel) @available(*, deprecated, message: "Use .userProfile instead.") @@ -24,9 +19,11 @@ enum AppRoute: Routable { case userProfile(UserModel, communityContext: CommunityModel? = nil) case postLinkWithContext(PostLinkWithContext) + // case newPostLinkWithContext(NewPostLinkWithContext) case lazyLoadPostLinkWithContext(LazyLoadPostLinkWithContext) // MARK: - Settings + case settings(SettingsPage) case aboutSettings(AboutSettingsPage) case appearanceSettings(AppearanceSettingsPage) @@ -35,14 +32,8 @@ enum AppRoute: Routable { case licenseSettings(LicensesSettingsPage) // swiftlint:disable cyclomatic_complexity - static func makeRoute(_ value: V) throws -> AppRoute where V: Hashable { + static func makeRoute(_ value: some Hashable) throws -> AppRoute { switch value { - case let value as CommunityLinkWithContext: - return .communityLinkWithContext(value) - case let value as APIPostView: - return .apiPostView(value) - case let value as APIPost: - return .apiPost(value) case let value as CommunityModel: return .community(value) case let value as APIPerson: diff --git a/Mlem/Repositories/CommunityRepository.swift b/Mlem/Repositories/CommunityRepository.swift index fa3aef757..d203c4444 100644 --- a/Mlem/Repositories/CommunityRepository.swift +++ b/Mlem/Repositories/CommunityRepository.swift @@ -35,7 +35,7 @@ struct CommunityRepository { var communities = [APICommunityView]() repeat { - let response = try await client.loadCommunityList(sort: nil, page: page, limit: limit, type: FeedType.subscribed.rawValue) + let response = try await client.loadCommunityList(sort: nil, page: page, limit: limit, type: APIListingType.subscribed.rawValue) communities.append(contentsOf: response.communities) hasMorePages = response.communities.count >= limit page += 1 @@ -78,7 +78,7 @@ struct CommunityRepository { } func loadDetails(for id: Int) async throws -> CommunityModel { - CommunityModel(from: try await details(apiClient, id)) + try await CommunityModel(from: details(apiClient, id)) } @discardableResult diff --git a/Mlem/Repositories/PostRepository.swift b/Mlem/Repositories/PostRepository.swift index ef31e540c..6fbe0214c 100644 --- a/Mlem/Repositories/PostRepository.swift +++ b/Mlem/Repositories/PostRepository.swift @@ -17,11 +17,11 @@ class PostRepository { page: Int, cursor: String?, sort: PostSortType?, - type: FeedType, + type: APIListingType, limit: Int, savedOnly: Bool? = nil, communityName: String? = nil - ) async throws -> (posts: [PostModel], cursor: String?) { + ) async throws -> (items: [PostModel], cursor: String?) { let response = try await apiClient.loadPosts( communityId: communityId, page: page, @@ -33,8 +33,8 @@ class PostRepository { communityName: communityName ) - let posts = response.posts.map { PostModel(from: $0) } - return (posts, response.nextPage) + let items = response.posts.map { PostModel(from: $0) } + return (items, response.nextPage) } // swiftlint:enable function_parameter_count @@ -56,7 +56,7 @@ class PostRepository { return PostModel(from: post, read: success ? read : post.read) } - /// Rates a given post. Does not care what the current vote state is; sends the given request no matter what (i.e., calling this with operation .upvote on an already upvoted post will not send a .resetVote, but will instead send a second idempotent .upvote + /// Rates a given post. Does not care what the current vote state is; sends the given request no matter what (i.e., calling this with operation `.upvote` on an already upvoted post will not send a `.resetVote`, but will instead send a second idempotent `.upvote`) /// - Parameters: /// - postId: id of the post to rate /// - operation: ScoringOperation to apply to the given post id @@ -73,7 +73,9 @@ class PostRepository { /// - Returns: PostModel representing the new state of the post func savePost(postId: Int, shouldSave: Bool) async throws -> PostModel { let postView = try await apiClient.savePost(id: postId, shouldSave: shouldSave) - return PostModel(from: postView) + let ret: PostModel = .init(from: postView) + ret.read = true // the API call sets read to true but doesn't include that in the response so we do it here + return ret } func deletePost(postId: Int, shouldDelete: Bool) async throws -> PostModel { diff --git a/Mlem/Views/Shared/Comments/Components/Embedded Post.swift b/Mlem/Views/Shared/Comments/Components/Embedded Post.swift index 52477bae4..8688d3afb 100644 --- a/Mlem/Views/Shared/Comments/Components/Embedded Post.swift +++ b/Mlem/Views/Shared/Comments/Components/Embedded Post.swift @@ -8,9 +8,6 @@ import SwiftUI struct EmbeddedPost: View { - // used to handle the lazy load embedded post--speed doesn't matter because it's not a "real" post tracker - @StateObject var postTracker: PostTracker - let community: APICommunity let post: APIPost let comment: APIComment @@ -19,9 +16,6 @@ struct EmbeddedPost: View { self.community = community self.post = post self.comment = comment - - @AppStorage("upvoteOnSave") var upvoteOnSave = false - self._postTracker = StateObject(wrappedValue: .init(internetSpeed: .slow, upvoteOnSave: upvoteOnSave)) } @State var loadedPostDetails: PostModel? diff --git a/Mlem/Views/Shared/Components/Components/InfoStackView.swift b/Mlem/Views/Shared/Components/Components/InfoStackView.swift index c4f9306a4..279516e90 100644 --- a/Mlem/Views/Shared/Components/Components/InfoStackView.swift +++ b/Mlem/Views/Shared/Components/Components/InfoStackView.swift @@ -132,7 +132,7 @@ struct InfoStackView: View { func unreadRepliesView(commentCount: Int, unreadCommentCount: Int) -> some View { HStack(spacing: AppConstants.iconToTextSpacing) { Image(systemName: Icons.unreadReplies) - Text("\(commentCount)") + Text("\(commentCount)") + Text(" +\(unreadCommentCount)").foregroundColor(.green) } .accessibilityAddTraits(.isStaticText) diff --git a/Mlem/Views/Shared/Components/Thumbnail Image View.swift b/Mlem/Views/Shared/Components/Thumbnail Image View.swift index a8faaf881..b1a88f763 100644 --- a/Mlem/Views/Shared/Components/Thumbnail Image View.swift +++ b/Mlem/Views/Shared/Components/Thumbnail Image View.swift @@ -11,13 +11,12 @@ import SwiftUI struct ThumbnailImageView: View { @AppStorage("shouldBlurNsfw") var shouldBlurNsfw: Bool = true - @EnvironmentObject var postTracker: PostTracker @Dependency(\.errorHandler) var errorHandler @Dependency(\.postRepository) var postRepository @Environment(\.openURL) private var openURL - let post: PostModel + @ObservedObject var post: PostModel var showNsfwFilter: Bool { (post.post.nsfw || post.community.nsfw) && shouldBlurNsfw } @@ -76,7 +75,7 @@ struct ThumbnailImageView: View { /// Synchronous void wrapper for postTracker.markRead to pass into CachedImage as dismiss callback func markPostAsRead() { Task(priority: .userInitiated) { - await postTracker.markRead(post: post) + await post.markRead(true) } } } diff --git a/Mlem/Views/Shared/Composer/PostComposerView+Logic.swift b/Mlem/Views/Shared/Composer/PostComposerView+Logic.swift index c1186399c..634246976 100644 --- a/Mlem/Views/Shared/Composer/PostComposerView+Logic.swift +++ b/Mlem/Views/Shared/Composer/PostComposerView+Logic.swift @@ -6,8 +6,8 @@ // import Foundation -import SwiftUI import PhotosUI +import SwiftUI extension PostComposerView { var hasPostContent: Bool { @@ -25,7 +25,7 @@ extension PostComposerView { var isValidURL: Bool { guard attachmentModel.url.lowercased().hasPrefix("http://") || - attachmentModel.url.lowercased().hasPrefix("https://") else { + attachmentModel.url.lowercased().hasPrefix("https://") else { return false // URL protocol is missing } @@ -53,12 +53,7 @@ extension PostComposerView { isSubmitting = true if let post = editModel.editPost { - let editedPost = await postTracker.edit(post: post, name: postTitle, url: attachmentModel.url, body: postBody, nsfw: isNSFW) - - if let responseCallback = editModel.responseCallback { - responseCallback(editedPost) - } - + await post.edit(name: postTitle, url: attachmentModel.url, body: postBody, nsfw: isNSFW) } else { let response = try await apiClient.createPost( communityId: editModel.community.communityId, @@ -70,9 +65,9 @@ extension PostComposerView { hapticManager.play(haptic: .success, priority: .high) - await MainActor.run { - withAnimation { - postTracker.prepend(PostModel(from: response.postView)) + if let postTracker = editModel.postTracker { + Task { + await postTracker.prependItem(PostModel(from: response.postView)) } } } diff --git a/Mlem/Views/Shared/Composer/PostComposerView.swift b/Mlem/Views/Shared/Composer/PostComposerView.swift index 1a3bd0daf..fe5fea8b2 100644 --- a/Mlem/Views/Shared/Composer/PostComposerView.swift +++ b/Mlem/Views/Shared/Composer/PostComposerView.swift @@ -6,8 +6,8 @@ // import Dependencies -import SwiftUI import PhotosUI +import SwiftUI extension HorizontalAlignment { enum LabelStart: AlignmentID { @@ -32,7 +32,6 @@ struct PostComposerView: View { @Environment(\.dismiss) var dismiss - let postTracker: PostTracker let editModel: PostEditorModel @AppStorage("promptUser.permission.privacy.allowImageUploads") var askedForPermissionToUploadImages: Bool = false @@ -49,14 +48,13 @@ struct PostComposerView: View { @State var isShowingErrorDialog: Bool = false @State var errorDialogMessage: String = "" - @State var uploadTask: Task<(), any Error>? + @State var uploadTask: Task? @Environment(\.layoutDirection) var layoutDirection @FocusState private var focusedField: Field? init(editModel: PostEditorModel) { - self.postTracker = editModel.postTracker self.editModel = editModel self._postTitle = State(initialValue: editModel.editPost?.post.name ?? "") @@ -143,7 +141,6 @@ struct PostComposerView: View { .frame(width: AppConstants.thumbnailSize, height: AppConstants.thumbnailSize) } VStack(alignment: .leading) { - if attachmentModel.imageModel?.state == nil { Text("Attached Image") } else { @@ -236,7 +233,6 @@ struct PostComposerView: View { } } .accessibilityLabel("Toggle NSFW") - } ToolbarItem(placement: .navigationBarTrailing) { LinkUploadOptionsView(model: attachmentModel) { diff --git a/Mlem/Views/Shared/Posts/Expanded Post.swift b/Mlem/Views/Shared/Posts/Expanded Post.swift index a69d752aa..ecf2e7bfb 100644 --- a/Mlem/Views/Shared/Posts/Expanded Post.swift +++ b/Mlem/Views/Shared/Posts/Expanded Post.swift @@ -54,8 +54,8 @@ struct ExpandedPost: View { @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker @StateObject var commentTracker: CommentTracker = .init() - @EnvironmentObject var postTracker: PostTracker - @State var post: PostModel + @EnvironmentObject var postTracker: StandardPostTracker + @StateObject var post: PostModel var community: CommunityModel? @State var commentErrorDetails: ErrorDetails? @@ -81,8 +81,10 @@ struct ExpandedPost: View { .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { toolbarMenu } } - .task { await loadComments() } - .task { await postTracker.markRead(post: post) } + .task { + await loadComments() + await post.markRead(true) + } .refreshable { await refreshComments() } .onChange(of: commentSortingType) { newSortingType in withAnimation(.easeIn(duration: 0.4)) { diff --git a/Mlem/Views/Shared/Posts/ExpandedPostLogic.swift b/Mlem/Views/Shared/Posts/ExpandedPostLogic.swift index 986882b33..b80f49532 100644 --- a/Mlem/Views/Shared/Posts/ExpandedPostLogic.swift +++ b/Mlem/Views/Shared/Posts/ExpandedPostLogic.swift @@ -11,39 +11,15 @@ extension ExpandedPost { // MARK: Interaction callbacks func upvotePost() async { - // ensure post tracker isn't loading--avoids state faking causing flickering when post tracker doesn't upvote - guard !postTracker.isLoading else { return } - - // fake state - let oldPost = post // save this to pass to postTracker - let operation = post.votes.myVote == .upvote ? ScoringOperation.resetVote : .upvote - post = PostModel(from: post, votes: post.votes.applyScoringOperation(operation: operation)) - - // perform upvote--passing in oldPost so that the state-faked upvote of post doesn't result in the opposite vote being passed in - post = await postTracker.voteOnPost(post: oldPost, inputOp: .upvote) + await post.vote(inputOp: .upvote) } - + func downvotePost() async { - // fake state - let oldPost = post - let operation = post.votes.myVote == .downvote ? ScoringOperation.resetVote : .downvote - post = PostModel(from: post, votes: post.votes.applyScoringOperation(operation: operation)) - - // perform downvote - post = await postTracker.voteOnPost(post: oldPost, inputOp: .downvote) + await post.vote(inputOp: .downvote) } func savePost() async { - // fake state - var stateFakedPost = PostModel(from: post, saved: !post.saved) - if upvoteOnSave, !post.saved, stateFakedPost.votes.myVote != .upvote { - stateFakedPost.votes = stateFakedPost.votes.applyScoringOperation(operation: .upvote) - } - let oldPost = post - post = stateFakedPost - - // perform save - post = await postTracker.toggleSave(post: oldPost) + await post.toggleSave(upvoteOnSave: upvoteOnSave) } func replyToPost() { @@ -73,7 +49,7 @@ extension ExpandedPost { do { let response = try await apiClient.blockPerson(id: post.creator.userId, shouldBlock: true) if response.blocked { - postTracker.removeUserPosts(from: post.creator.userId) + await postTracker.applyFilter(.blockedUser(post.creator.userId)) hapticManager.play(haptic: .violentSuccess, priority: .high) await notifier.add(.success("Blocked \(post.creator.name)")) } @@ -157,11 +133,7 @@ extension ExpandedPost { destructiveActionPrompt: nil, enabled: true ) { - editorTracker.openEditor(with: PostEditorModel( - post: post, - postTracker: postTracker, - responseCallback: updatePost - )) + editorTracker.openEditor(with: PostEditorModel(post: post)) }) // delete @@ -172,7 +144,7 @@ extension ExpandedPost { enabled: !post.post.deleted ) { Task(priority: .userInitiated) { - await postTracker.delete(post: post) + await post.delete() } }) } @@ -215,9 +187,10 @@ extension ExpandedPost { isLoading = true do { - // Making this request marks unread comments as read. - post = PostModel(from: try await postRepository.loadPost(postId: post.postId)) - postTracker.update(with: post) + // Making this request should mark unread comments as read, but doesn't appear to so we do it manually + let newPost = try await PostModel(from: postRepository.loadPost(postId: post.postId)) + newPost.unreadCommentCount = 0 + post.reinit(from: newPost) let comments = try await commentRepository.comments(for: post.post.id) let sorted = sortComments(comments, by: commentSortingType) @@ -263,8 +236,4 @@ extension ExpandedPost { return newComment } } - - func updatePost(newPost: PostModel) { - post = newPost - } } diff --git a/Mlem/Views/Shared/Posts/Feed Post.swift b/Mlem/Views/Shared/Posts/Feed Post.swift index 1de13e721..f3a42fbc4 100644 --- a/Mlem/Views/Shared/Posts/Feed Post.swift +++ b/Mlem/Views/Shared/Posts/Feed Post.swift @@ -41,21 +41,18 @@ struct FeedPost: View { @AppStorage("reakMarkStyle") var readMarkStyle: ReadMarkStyle = .bar @AppStorage("readBarThickness") var readBarThickness: Int = 3 + + @AppStorage("upvoteOnSave") var upvoteOnSave: Bool = false - @EnvironmentObject var postTracker: PostTracker + @EnvironmentObject var postTracker: StandardPostTracker @EnvironmentObject var editorTracker: EditorTracker @EnvironmentObject var appState: AppState @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker @Environment(\.horizontalSizeClass) var horizontalSizeClass - @State var dirtyVote: ScoringOperation = .resetVote - @State var dirtyScore: Int = 0 - @State var dirtySaved: Bool = false - @State var dirty: Bool = false - // MARK: Parameters - let post: PostModel + @ObservedObject var postModel: PostModel let community: CommunityModel? let showPostCreator: Bool let showCommunity: Bool @@ -68,7 +65,7 @@ struct FeedPost: View { showCommunity: Bool = true, enableSwipeActions: Bool = true ) { - self.post = post + self.postModel = post self.community = community self.showPostCreator = showPostCreator self.showCommunity = showCommunity @@ -93,36 +90,41 @@ struct FeedPost: View { // MARK: Computed - var barThickness: CGFloat { !post.read && diffWithoutColor && readMarkStyle == .bar ? CGFloat(readBarThickness) : .zero } - var showCheck: Bool { post.read && diffWithoutColor && readMarkStyle == .check } + var barThickness: CGFloat { !postModel.read && diffWithoutColor && readMarkStyle == .bar ? CGFloat(readBarThickness) : .zero } + var showCheck: Bool { postModel.read && diffWithoutColor && readMarkStyle == .check } var body: some View { - VStack(spacing: 0) { - postItem - .border(width: barThickness, edges: [.leading], color: .secondary) - .background(Color.systemBackground) -// .background(horizontalSizeClass == .regular ? Color.secondarySystemBackground : Color.systemBackground) -// .clipShape(RoundedRectangle(cornerRadius: horizontalSizeClass == .regular ? 16 : 0)) -// .padding(.all, horizontalSizeClass == .regular ? nil : 0) - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) - .addSwipeyActions( - leading: [ - enableSwipeActions ? upvoteSwipeAction : nil, - enableSwipeActions ? downvoteSwipeAction : nil - ], - trailing: [ - enableSwipeActions ? saveSwipeAction : nil, - enableSwipeActions ? replySwipeAction : nil - ] - ) - .contextMenu { - ForEach(genMenuFunctions()) { item in - MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) + // this allows post deletion to not require tracker updates + if postModel.post.deleted { + EmptyView() + } else { + VStack(spacing: 0) { + postItem + .border(width: barThickness, edges: [.leading], color: .secondary) + .background(Color.systemBackground) + // .background(horizontalSizeClass == .regular ? Color.secondarySystemBackground : Color.systemBackground) + // .clipShape(RoundedRectangle(cornerRadius: horizontalSizeClass == .regular ? 16 : 0)) + // .padding(.all, horizontalSizeClass == .regular ? nil : 0) + .destructiveConfirmation( + isPresentingConfirmDestructive: $isPresentingConfirmDestructive, + confirmationMenuFunction: confirmationMenuFunction + ) + .addSwipeyActions( + leading: [ + enableSwipeActions ? upvoteSwipeAction : nil, + enableSwipeActions ? downvoteSwipeAction : nil + ], + trailing: [ + enableSwipeActions ? saveSwipeAction : nil, + enableSwipeActions ? replySwipeAction : nil + ] + ) + .contextMenu { + ForEach(genMenuFunctions()) { item in + MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) + } } - } + } } } @@ -146,7 +148,7 @@ struct FeedPost: View { var postItem: some View { if postSize == .compact { CompactPost( - post: post, + post: postModel, showCommunity: showCommunity, menuFunctions: genMenuFunctions() ) @@ -160,7 +162,7 @@ struct FeedPost: View { // } HStack { CommunityLinkView( - community: post.community, + community: postModel.community, serverInstanceLocation: communityServerInstanceLocation ) @@ -174,10 +176,10 @@ struct FeedPost: View { } if postSize == .headline { - HeadlinePost(post: post) + HeadlinePost(post: postModel) } else { LargePost( - post: post, + post: postModel, layoutMode: .constant(.preferredSize) ) } @@ -185,7 +187,7 @@ struct FeedPost: View { // posting user if showPostCreator { UserLinkView( - user: post.creator, + user: postModel.creator, serverInstanceLocation: userServerInstanceLocation, communityContext: community ) @@ -195,19 +197,19 @@ struct FeedPost: View { .padding(.horizontal, AppConstants.postAndCommentSpacing) InteractionBarView( - votes: post.votes, - published: post.published, - updated: post.updated, - commentCount: post.commentCount, - unreadCommentCount: post.unreadCommentCount, - saved: post.saved, + votes: postModel.votes, + published: postModel.published, + updated: postModel.updated, + commentCount: postModel.commentCount, + unreadCommentCount: postModel.unreadCommentCount, + saved: postModel.saved, accessibilityContext: "post", widgets: layoutWidgetTracker.groups.post, upvote: upvotePost, downvote: downvotePost, save: savePost, reply: replyToPost, - shareURL: URL(string: post.post.apId), + shareURL: URL(string: postModel.post.apId), shouldShowScore: shouldShowScoreInPostBar, showDownvotesSeparately: showPostDownvotesSeparately, shouldShowTime: shouldShowTimeInPostBar, @@ -227,22 +229,22 @@ struct FeedPost: View { } func deletePost() async { - await postTracker.delete(post: post) + await postModel.delete() } func blockUser() async { // TODO: migrate to personRepository do { - let response = try await apiClient.blockPerson(id: post.creator.userId, shouldBlock: true) + let response = try await apiClient.blockPerson(id: postModel.creator.userId, shouldBlock: true) if response.blocked { - postTracker.removeUserPosts(from: post.creator.userId) + await postTracker.applyFilter(.blockedUser(postModel.creator.userId)) hapticManager.play(haptic: .violentSuccess, priority: .high) - await notifier.add(.success("Blocked \(post.creator.name)")) + await notifier.add(.success("Blocked \(postModel.creator.name)")) } } catch { errorHandler.handle( .init( - message: "Unable to block \(post.creator.name)", + message: "Unable to block \(postModel.creator.name)", style: .toast, underlyingError: error ) @@ -253,15 +255,15 @@ struct FeedPost: View { func blockCommunity() async { // TODO: migrate to communityRepository do { - let response = try await apiClient.blockCommunity(id: post.community.communityId, shouldBlock: true) + let response = try await apiClient.blockCommunity(id: postModel.community.communityId, shouldBlock: true) if response.blocked { - postTracker.removeCommunityPosts(from: post.community.communityId) - await notifier.add(.success("Blocked \(post.community.name)")) + await postTracker.applyFilter(.blockedCommunity(postModel.community.communityId)) + await notifier.add(.success("Blocked \(postModel.community.name)")) } } catch { errorHandler.handle( .init( - message: "Unable to block \(post.community.name)", + message: "Unable to block \(postModel.community.name)", style: .toast, underlyingError: error ) @@ -271,30 +273,29 @@ struct FeedPost: View { func replyToPost() { editorTracker.openEditor(with: ConcreteEditorModel( - post: post, + post: postModel, operation: PostOperation.replyToPost )) } func editPost() { editorTracker.openEditor(with: PostEditorModel( - post: post, - postTracker: postTracker + post: postModel )) } /// Votes on a post /// - Parameter inputOp: The vote operation to perform func voteOnPost(inputOp: ScoringOperation) async { - await postTracker.voteOnPost(post: post, inputOp: inputOp) + await postModel.vote(inputOp: inputOp) } func savePost() async { - await postTracker.toggleSave(post: post) + await postModel.toggleSave(upvoteOnSave: upvoteOnSave) } func reportPost() { - editorTracker.openEditor(with: ConcreteEditorModel(post: post, operation: PostOperation.reportPost)) + editorTracker.openEditor(with: ConcreteEditorModel(post: postModel, operation: PostOperation.reportPost)) } // swiftlint:disable function_body_length @@ -302,7 +303,7 @@ struct FeedPost: View { var ret: [MenuFunction] = .init() // upvote - let (upvoteText, upvoteImg) = post.votes.myVote == .upvote ? + let (upvoteText, upvoteImg) = postModel.votes.myVote == .upvote ? ("Undo Upvote", Icons.upvoteSquareFill) : ("Upvote", Icons.upvoteSquare) ret.append(MenuFunction.standardMenuFunction( @@ -317,7 +318,7 @@ struct FeedPost: View { }) // downvote - let (downvoteText, downvoteImg) = post.votes.myVote == .downvote ? + let (downvoteText, downvoteImg) = postModel.votes.myVote == .downvote ? ("Undo Downvote", Icons.downvoteSquareFill) : ("Downvote", Icons.downvoteSquare) ret.append(MenuFunction.standardMenuFunction( @@ -332,7 +333,7 @@ struct FeedPost: View { }) // save - let (saveText, saveImg) = post.saved ? ("Unsave", "bookmark.slash") : ("Save", "bookmark") + let (saveText, saveImg) = postModel.saved ? ("Unsave", "bookmark.slash") : ("Save", "bookmark") ret.append(MenuFunction.standardMenuFunction( text: saveText, imageName: saveImg, @@ -354,7 +355,7 @@ struct FeedPost: View { replyToPost() }) - if appState.isCurrentAccountId(post.creator.userId) { + if appState.isCurrentAccountId(postModel.creator.userId) { // edit ret.append(MenuFunction.standardMenuFunction( text: "Edit", @@ -370,7 +371,7 @@ struct FeedPost: View { text: "Delete", imageName: Icons.delete, destructiveActionPrompt: "Are you sure you want to delete this post? This cannot be undone.", - enabled: !post.post.deleted + enabled: !postModel.post.deleted ) { Task(priority: .userInitiated) { await deletePost() @@ -379,7 +380,7 @@ struct FeedPost: View { } // share - if let url = URL(string: post.post.apId) { + if let url = URL(string: postModel.post.apId) { ret.append(MenuFunction.shareMenuFunction(url: url)) } @@ -430,7 +431,7 @@ extension FeedPost { // this may need to wait until we complete https://github.com/mormaer/Mlem/issues/117 var upvoteSwipeAction: SwipeAction { - let (emptySymbolName, fullSymbolName) = post.votes.myVote == .upvote ? + let (emptySymbolName, fullSymbolName) = postModel.votes.myVote == .upvote ? (Icons.resetVoteSquare, Icons.resetVoteSquareFill) : (Icons.upvoteSquare, Icons.upvoteSquareFill) return SwipeAction( @@ -447,7 +448,7 @@ extension FeedPost { var downvoteSwipeAction: SwipeAction? { guard siteInformation.enableDownvotes else { return nil } - let (emptySymbolName, fullSymbolName) = post.votes.myVote == .downvote ? + let (emptySymbolName, fullSymbolName) = postModel.votes.myVote == .downvote ? (Icons.resetVoteSquare, Icons.resetVoteSquareFill) : (Icons.downvoteSquare, Icons.downvoteSquareFill) return SwipeAction( @@ -462,7 +463,7 @@ extension FeedPost { } var saveSwipeAction: SwipeAction { - let (emptySymbolName, fullSymbolName) = post.saved + let (emptySymbolName, fullSymbolName) = postModel.saved ? (Icons.unsave, Icons.unsaveFill) : (Icons.save, Icons.saveFill) return SwipeAction( diff --git a/Mlem/Views/Shared/Posts/Lazy Load Expanded Post.swift b/Mlem/Views/Shared/Posts/Lazy Load Expanded Post.swift index 930b47827..90882625e 100644 --- a/Mlem/Views/Shared/Posts/Lazy Load Expanded Post.swift +++ b/Mlem/Views/Shared/Posts/Lazy Load Expanded Post.swift @@ -13,20 +13,21 @@ import SwiftUI */ struct LazyLoadExpandedPost: View { @Dependency(\.errorHandler) var errorHandler + @Dependency(\.postRepository) var postRepository let post: APIPost let scrollTarget: Int? @State private var loadedPostView: PostModel? - @StateObject private var postTracker: PostTracker // = PostTracker(internetSpeed: .slow) + @StateObject private var postTracker: StandardPostTracker init(post: APIPost, scrollTarget: Int? = nil) { self.post = post self.scrollTarget = scrollTarget @AppStorage("upvoteOnSave") var upvoteOnSave = false - self._postTracker = StateObject(wrappedValue: .init(internetSpeed: .slow, upvoteOnSave: upvoteOnSave)) + self._postTracker = StateObject(wrappedValue: .init(internetSpeed: .slow, sortType: .new, showReadPosts: true, feedType: .all)) } var body: some View { @@ -47,7 +48,7 @@ struct LazyLoadExpandedPost: View { } .task(priority: .background) { do { - loadedPostView = try await postTracker.loadPost(postId: post.id) + loadedPostView = try await postRepository.loadPost(postId: post.id) } catch { // TODO: Some sort of common alert banner? // we can show a toast here by passing a `message` and `style: .toast` by using a `ContextualError` below... diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift index 0f6f8e3ed..3a32aee4e 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift @@ -24,15 +24,13 @@ struct CompactPost: View { @Dependency(\.errorHandler) var errorHandler @Environment(\.accessibilityDifferentiateWithoutColor) var diffWithoutColor: Bool - - @EnvironmentObject var postTracker: PostTracker // constants let thumbnailSize: CGFloat = 60 private let spacing: CGFloat = 10 // constant for readability, ease of modification // arguments - let post: PostModel + @ObservedObject var post: PostModel let community: CommunityModel? let showCommunity: Bool // true to show community name, false to show username let menuFunctions: [MenuFunction] diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift index d3ac1ebd8..e3647f5e3 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift @@ -20,7 +20,7 @@ struct HeadlinePost: View { private let spacing: CGFloat = 10 // constant for readability, ease of modification // arguments - let post: PostModel + @ObservedObject var post: PostModel var body: some View { VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift index f92cf2f63..8420b4f00 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift @@ -43,13 +43,12 @@ struct LargePost: View { @Dependency(\.errorHandler) var errorHandler // global state - @EnvironmentObject var postTracker: PostTracker @EnvironmentObject var appState: AppState @AppStorage("shouldBlurNsfw") var shouldBlurNsfw: Bool = true @AppStorage("limitImageHeightInFeed") var limitImageHeightInFeed: Bool = true // parameters - let post: PostModel + @ObservedObject var post: PostModel @Binding var layoutMode: LayoutMode private var isExpanded: Bool { @@ -258,7 +257,7 @@ struct LargePost: View { /// Synchronous void wrapper for apiClient.markPostAsRead to pass into CachedImage as dismiss callback func markPostAsRead() { Task(priority: .userInitiated) { - await postTracker.markRead(post: post) + await post.markRead(true) } } } diff --git a/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift b/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift deleted file mode 100644 index 2a6d7b979..000000000 --- a/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// Community List View.swift -// Mlem -// -// Created by Jake Shirey on 17.06.2023. -// - -import Dependencies -import SwiftUI - -struct CommunitySection: Identifiable { - let id = UUID() - let viewId: String - let sidebarEntry: any SidebarEntry - let inlineHeaderLabel: String? - let accessibilityLabel: String -} - -struct CommunityListView: View { - @StateObject private var model: CommunityListModel = .init() - - @Binding var selectedCommunity: CommunityLinkWithContext? - - /// Set to `false` on disappear. - @State private var appeared: Bool = false - - init(selectedCommunity: Binding) { - self._selectedCommunity = selectedCommunity - } - - // MARK: - Body - - var body: some View { - ScrollViewReader { scrollProxy in - HStack { - List(selection: $selectedCommunity) { - HomepageFeedRowView(.subscribed) - .padding(.top, 5) - .id("top") // For "scroll to top" sidebar item - HomepageFeedRowView(.local) - HomepageFeedRowView(.all) - - ForEach(model.visibleSections) { section in - Section(header: headerView(for: section)) { - ForEach(model.communities(for: section)) { community in - CommuntiyFeedRowView( - community: community, - subscribed: model.isSubscribed(to: community), - communitySubscriptionChanged: model.updateSubscriptionStatus, - navigationContext: .sidebar - ) - } - } - } - } - .fancyTabScrollCompatible() - .navigationTitle("Communities") - .navigationBarColor() - .listStyle(PlainListStyle()) - .scrollIndicators(.hidden) - .onAppear { - appeared = true - } - .onDisappear { - appeared = false - } - - SectionIndexTitles(proxy: scrollProxy, communitySections: model.allSections()) - } - .reselectAction(tab: .feeds) { - guard appeared else { - return - } - withAnimation { - scrollProxy.scrollTo("top", anchor: .bottom) - } - } - } - .refreshable { - await model.load() - } - .onAppear { - Task(priority: .high) { - await model.load() - } - } - } - - // MARK: - Subviews - - private func headerView(for section: CommunitySection) -> some View { - HStack { - Text(section.inlineHeaderLabel!) - .accessibilityLabel(section.accessibilityLabel) - Spacer() - } - .id(section.viewId) - } -} - -// MARK: - Previews - -struct CommunityListViewPreview: PreviewProvider { - static var previews: some View { - Group { - NavigationStack { - CommunityListView(selectedCommunity: .constant(nil)) - } - .previewDisplayName("Populated") - - NavigationStack { - withDependencies { - // return no subscriptions... - $0.communityRepository.subscriptions = { _ in [] } - } operation: { - CommunityListView(selectedCommunity: .constant(nil)) - } - } - .previewDisplayName("Empty") - - NavigationStack { - withDependencies { - // return an error when calling subscriptions - $0.communityRepository.subscriptions = { _ in - throw APIClientError.response(.init(error: "Borked"), nil) - } - } operation: { - CommunityListView(selectedCommunity: .constant(nil)) - } - } - .previewDisplayName("Error") - } - } -} diff --git a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListSidebarEntry.swift b/Mlem/Views/Tabs/Feeds/Community List/CommunityListSidebarEntry.swift similarity index 99% rename from Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListSidebarEntry.swift rename to Mlem/Views/Tabs/Feeds/Community List/CommunityListSidebarEntry.swift index b146d01c8..f245b059b 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListSidebarEntry.swift +++ b/Mlem/Views/Tabs/Feeds/Community List/CommunityListSidebarEntry.swift @@ -42,7 +42,6 @@ struct RegexCommunityNameSidebarEntry: SidebarEntry { // Filters to favorited communities struct FavoritesSidebarEntry: SidebarEntry { - @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker var sidebarLabel: String? diff --git a/Mlem/Views/Tabs/Feeds/Community List/Components/SectionIndexTitles.swift b/Mlem/Views/Tabs/Feeds/Community List/Components/SectionIndexTitles.swift deleted file mode 100644 index 835e4c01c..000000000 --- a/Mlem/Views/Tabs/Feeds/Community List/Components/SectionIndexTitles.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// SectionIndexTitles.swift -// Mlem -// -// Created by mormaer on 13/08/2023. -// -// - -import Dependencies -import SwiftUI - -// Original article here: https://www.fivestars.blog/code/section-title-index-swiftui.html -struct SectionIndexTitles: View { - - @Dependency(\.hapticManager) var hapticManager - - let proxy: ScrollViewProxy - let communitySections: [CommunitySection] - @GestureState private var dragLocation: CGPoint = .zero - - // Track which sidebar label we picked last to we - // only haptic when selecting a new one - @State var lastSelectedLabel: String = "" - - var body: some View { - VStack { - ForEach(communitySections) { communitySection in - HStack { - if communitySection.sidebarEntry.sidebarIcon != nil { - SectionIndexImage(image: communitySection.sidebarEntry.sidebarIcon!) - .padding(.trailing) - } else if communitySection.sidebarEntry.sidebarLabel != nil { - SectionIndexText(label: communitySection.sidebarEntry.sidebarLabel!) - .padding(.trailing) - } else { - EmptyView() - } - } - .background(dragObserver(viewId: communitySection.viewId)) - } - } - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .updating($dragLocation) { value, state, _ in - state = value.location - } - ) - } - - func dragObserver(viewId: String) -> some View { - GeometryReader { geometry in - dragObserver(geometry: geometry, viewId: viewId) - } - } - - func dragObserver(geometry: GeometryProxy, viewId: String) -> some View { - if geometry.frame(in: .global).contains(dragLocation) { - if viewId != lastSelectedLabel { - DispatchQueue.main.async { - lastSelectedLabel = viewId - proxy.scrollTo(viewId, anchor: .center) - - // Play nice tappy taps - // HapticManager.shared.rigidInfo() - hapticManager.play(haptic: .rigidInfo, priority: .low) - } - } - } - return Rectangle().fill(Color.clear) - } -} - -// Sidebar Label Views -struct SectionIndexText: View { - let label: String - var body: some View { - Text(label).font(.system(size: 12)).bold() - } -} - -struct SectionIndexImage: View { - let image: String - var body: some View { - Image(systemName: image).resizable() - .frame(width: 8, height: 8) - } -} diff --git a/Mlem/Views/Tabs/Feeds/Community List/FavoriteStarButtonStyle.swift b/Mlem/Views/Tabs/Feeds/Community List/FavoriteStarButtonStyle.swift new file mode 100644 index 000000000..45c5c1f25 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Community List/FavoriteStarButtonStyle.swift @@ -0,0 +1,20 @@ +// +// FavoriteStarButtonStyle.swift +// Mlem +// +// Created by Jake Shirley on 6/19/23. +// + +import Dependencies +import SwiftUI + +struct FavoriteStarButtonStyle: ButtonStyle { + let isFavorited: Bool + + func makeBody(configuration: Configuration) -> some View { + Image(systemName: isFavorited ? Icons.favoriteFill : Icons.favorite) + .foregroundColor(.blue) + .opacity(isFavorited ? 1.0 : 0.2) + .accessibilityRepresentation { configuration.label } + } +} diff --git a/Mlem/Views/Tabs/Feeds/Community List/SectionIndexTitles.swift b/Mlem/Views/Tabs/Feeds/Community List/SectionIndexTitles.swift new file mode 100644 index 000000000..ec59ce464 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Community List/SectionIndexTitles.swift @@ -0,0 +1,104 @@ +// +// SectionIndexTitles.swift +// Mlem +// +// Created by mormaer on 13/08/2023. +// +// + +import Dependencies +import SwiftUI + +struct SectionIndexTitles: View { + @Dependency(\.hapticManager) var hapticManager + + let proxy: ScrollViewProxy + let communitySections: [CommunityListSection] + @GestureState private var dragLocation: CGPoint = .zero + + // Track which sidebar label we picked last to we + // only haptic when selecting a new one + @State var lastSelectedLabel: String = "" + + var body: some View { + VStack { + ForEach(communitySections) { communitySection in + sectionTitle(for: communitySection) + .frame(width: 12, height: 6) + } + } + .overlay { + GeometryReader { geo in + // Color.clear doesn't register gestures (presumably because it never gets drawn), so we fake it + Color.black + .opacity(0.00000000001) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .updating($dragLocation) { value, _, _ in + // ignore if out of bounds--actually add a tiny bit of padding to the left side to make it feel right + guard value.location.x > -20.0, value.location.y >= 0.0, value.location.y <= geo.size.height else { + return + } + + // compute which section is currently dragged + // height of one section is communitySections.count / geo.size.height + // drag is thus (value.location.y / (communitySections.count / geo.size.height )) sections up + // then do some algebra to make it prettier and round down to int + let sectionIndex = Int((value.location.y * Double(communitySections.count)) / geo.size.height) + + guard sectionIndex < communitySections.count else { + assertionFailure("Invalid section index! The math must be wrong.") + return + } + + let sectionLabel = communitySections[sectionIndex].viewId + + if sectionLabel != lastSelectedLabel { + DispatchQueue.main.async { + lastSelectedLabel = sectionLabel + proxy.scrollTo(sectionLabel, anchor: .center) + + // Play nice tappy taps + hapticManager.play(haptic: .rigidInfo, priority: .low) + } + } + } + ) + } + } + .padding(.vertical, 6) + .background { + Capsule() + .foregroundStyle(.ultraThinMaterial) + } + } +} + +// Sidebar Label Views +@ViewBuilder +func sectionTitle(for communitySection: CommunityListSection) -> some View { + if let icon = communitySection.sidebarEntry.sidebarIcon { + SectionIndexImage(image: icon) + } else if let label = communitySection.sidebarEntry.sidebarLabel { + SectionIndexText(label: label) + } else { + EmptyView() + } +} + +struct SectionIndexText: View { + let label: String + var body: some View { + Text(label) + .font(.system(size: 11)) + .fontWeight(.semibold) + } +} + +struct SectionIndexImage: View { + let image: String + var body: some View { + Image(systemName: image).resizable() + .frame(width: 8, height: 8) + } +} diff --git a/Mlem/Views/Tabs/Feeds/CommunityView.swift b/Mlem/Views/Tabs/Feeds/CommunityView.swift deleted file mode 100644 index 5d2a1a436..000000000 --- a/Mlem/Views/Tabs/Feeds/CommunityView.swift +++ /dev/null @@ -1,349 +0,0 @@ -// -// CommunityView.swift -// Mlem -// -// Created by Sjmarf on 31/12/2023. -// - -import SwiftUI -import Dependencies - -// swiftlint:disable type_body_length -struct CommunityView: View { - @Dependency(\.hapticManager) var hapticManager - @Dependency(\.errorHandler) var errorHandler - @Dependency(\.communityRepository) var communityRepository - - @AppStorage("shouldShowCommunityHeaders") var shouldShowCommunityHeaders: Bool = true - @AppStorage("shouldShowCommunityIcons") var shouldShowCommunityIcons: Bool = true - - enum Tab: String, Identifiable, CaseIterable { - var id: Self { self } - case posts, about, moderators, statistics - } - - @Environment(\.navigationPathWithRoutes) private var navigationPath - @Environment(\.scrollViewProxy) private var scrollViewProxy - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.colorScheme) var colorScheme - @EnvironmentObject var editorTracker: EditorTracker - - @State var community: CommunityModel - @State var selectedTab: Tab = .posts - - @Binding var rootDetails: CommunityLinkWithContext? - @Binding var splitViewColumnVisibility: NavigationSplitViewVisibility - - // MARK: Feed - - @StateObject var postTracker: PostTracker - @State var postSortType: PostSortType - - // MARK: Scroll to top - - @Namespace var scrollToTop - @State private var scrollToTopAppeared = false - private var scrollToTopId: Int? { - postTracker.items.first?.id - } - - // MARK: Destructive confirmation - - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? - - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } - - init( - community: CommunityModel, - splitViewColumnVisibility: Binding? = nil, - rootDetails: Binding? = nil - ) { - // need to grab some stuff from app storage to initialize post tracker with - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast - @AppStorage("upvoteOnSave") var upvoteOnSave = false - - self._community = State(initialValue: community) - - self._rootDetails = rootDetails ?? .constant(nil) - self._splitViewColumnVisibility = splitViewColumnVisibility ?? .constant(.automatic) - - @AppStorage("defaultPostSorting") var defaultPostSorting: PostSortType = .hot - self._postSortType = .init(wrappedValue: defaultPostSorting) - - self._postTracker = StateObject(wrappedValue: .init( - shouldPerformMergeSorting: false, - internetSpeed: internetSpeed, - upvoteOnSave: upvoteOnSave, - type: .community(community, sortedBy: defaultPostSorting) - )) - } - - var body: some View { - ScrollView { - VStack(spacing: 0) { - VStack(spacing: 0) { - ScrollToView(appeared: $scrollToTopAppeared) - .id(scrollToTop) - headerView - .padding(.top, 5) - } - .background(Color.systemBackground) - switch selectedTab { - case .posts: - if !postTracker.items.isEmpty { - Divider() - .padding(.top, 15) - .background(Color.secondarySystemBackground) - } - PostFeedView(community: community, postTracker: postTracker, postSortType: $postSortType) - .background(Color.secondarySystemBackground) - case .about: - Divider() - .padding(.top, 15) - .background(Color.secondarySystemBackground) - VStack(spacing: AppConstants.postAndCommentSpacing) { - if shouldShowCommunityHeaders, let banner = community.banner { - CachedImage(url: banner, cornerRadius: AppConstants.largeItemCornerRadius) - } - MarkdownView(text: community.description ?? "", isNsfw: false) - } - .padding(AppConstants.postAndCommentSpacing) - case .moderators: - if let moderators = community.moderators { - Divider() - .padding(.top, 15) - .background(Color.secondarySystemBackground) - ForEach(moderators, id: \.id) { user in - UserResultView(user, communityContext: community) - Divider() - } - Color.secondarySystemBackground - .frame(height: 100) - } - case .statistics: - CommunityStatsView(community: community) - .padding(.top, 16) - .background(Color(uiColor: .systemGroupedBackground)) - } - } - } - .refreshable { - await postTracker.refresh() - } - .background { - VStack(spacing: 0) { - Color.systemBackground - .frame(height: 200) - if selectedTab != .about && (selectedTab != .statistics || colorScheme == .light) { - Color.secondarySystemBackground - } else { - Color.systemBackground - } - } - } - .navigationBarTitleDisplayMode(.inline) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .fancyTabScrollCompatible() - .navigationBarColor(visibility: .automatic) - .hoistNavigation { - if navigationPath.isEmpty { - // Need to check `scrollToTopAppeared` because we want to scroll to top before popping back to sidebar. [2023.09] - if scrollToTopAppeared { - if horizontalSizeClass == .regular { - print("show/hide sidebar in regular size class") - splitViewColumnVisibility = splitViewColumnVisibility == .all ? .detailOnly : .all - return true - } else { - print("show/hide sidebar in compact size class") - // This seems a lot more reliable than dismiss action for some reason. [2023.09] - rootDetails = nil - return true - } - } else { - print("scroll to top") - withAnimation { - scrollViewProxy?.scrollTo(scrollToTop, anchor: .top) - } - return true - } - } else { - if scrollToTopAppeared { - print("exhausted auxiliary actions, perform dismiss action instead...") - return false - } else { - withAnimation { - scrollViewProxy?.scrollTo(scrollToTop, anchor: .top) - } - return true - } - } - } - .toolbar { - ToolbarItem(placement: .principal) { - Text(community.name) - .font(.headline) - .opacity(scrollToTopAppeared ? 0 : 1) - .animation(.easeOut(duration: 0.2), value: scrollToTopAppeared) - } - ToolbarItemGroup(placement: .secondaryAction) { - ForEach( - community.menuFunctions({ community = $0 }, - editorTracker: editorTracker, - postTracker: postTracker - ) - ) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: confirmDestructive) - } - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) - } - } - .onAppear { - if community.moderators == nil { - Task(priority: .userInitiated) { - do { - self.community = try await communityRepository.loadDetails(for: community.communityId) - } catch { - errorHandler.handle(error) - } - } - } - } - } - - var availableTabs: [Tab] { - var output: [Tab] = [.posts, .moderators, .statistics] - if community.description != nil { - output.insert(.about, at: 1) - } - return output - } - - @ViewBuilder - var headerView: some View { - Group { - VStack(spacing: 5) { - HStack(alignment: .center, spacing: 10) { - if shouldShowCommunityIcons { - AvatarView(community: community, avatarSize: 44, iconResolution: .unrestricted) - } - Button(action: community.copyFullyQualifiedName) { - VStack(alignment: .leading, spacing: 0) { - Text(community.displayName) - .font(.title2) - .fontWeight(.semibold) - .lineLimit(1) - .minimumScaleFactor(0.01) - if let fullyQualifiedName = community.fullyQualifiedName { - Text(fullyQualifiedName) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .frame(height: 44) - } - .buttonStyle(.plain) - Spacer() - subscribeButton - } - .padding(.horizontal, AppConstants.postAndCommentSpacing) - .padding(.bottom, 3) - Divider() - BubblePicker(availableTabs, selected: $selectedTab) { - Text($0.rawValue.capitalized) - } - } - Divider() - } - } - - var subscribeButtonForegroundColor: Color { - if community.favorited { - return .blue - } else if community.subscribed ?? false { - return .green - } - return .secondary - } - - var subscribeButtonBackgroundColor: Color { - if community.favorited { - return .blue.opacity(0.1) - } else if community.subscribed ?? false { - return .green.opacity(0.1) - } - return .clear - } - - var subscribeButtonIcon: String { - if community.favorited { - return Icons.favoriteFill - } else if community.subscribed ?? false { - return Icons.successCircle - } - return Icons.personFill - } - - @ViewBuilder - var subscribeButton: some View { - let foregroundColor = subscribeButtonForegroundColor - if let subscribed = community.subscribed { - HStack(spacing: 4) { - if let subscriberCount = community.subscriberCount { - Text(abbreviateNumber(subscriberCount)) - } - Image(systemName: subscribeButtonIcon) - .aspectRatio(contentMode: .fit) - } - .foregroundStyle(foregroundColor) - .padding(.vertical, 5) - .padding(.horizontal, 10) - .background( - Capsule() - .strokeBorder(foregroundColor, style: .init(lineWidth: 1)) - .background(Capsule().fill(subscribeButtonBackgroundColor)) - ) - .gesture(TapGesture().onEnded { _ in - hapticManager.play(haptic: .lightSuccess, priority: .low) - Task { - var community = community - do { - if community.favorited { - confirmDestructive(destructiveFunction: community.favoriteMenuFunction { self.community = $0 }) - } else if subscribed { - confirmDestructive(destructiveFunction: try community.subscribeMenuFunction { self.community = $0 }) - } else { - try await community.toggleSubscribe { item in - DispatchQueue.main.async { self.community = item } - } - } - } catch { - errorHandler.handle(error) - } - } - }) - .simultaneousGesture(LongPressGesture().onEnded { _ in - hapticManager.play(haptic: .lightSuccess, priority: .low) - Task { - var community = community - do { - try await community.toggleFavorite { item in - DispatchQueue.main.async { self.community = item } - } - } catch { - errorHandler.handle(error) - } - } - }) - } - } -} - -// swiftlint:enable type_body_length diff --git a/Mlem/Views/Tabs/Feeds/CommunityStatsView.swift b/Mlem/Views/Tabs/Feeds/Components/CommunityStatsView.swift similarity index 98% rename from Mlem/Views/Tabs/Feeds/CommunityStatsView.swift rename to Mlem/Views/Tabs/Feeds/Components/CommunityStatsView.swift index d52def8b2..199d7ae5a 100644 --- a/Mlem/Views/Tabs/Feeds/CommunityStatsView.swift +++ b/Mlem/Views/Tabs/Feeds/Components/CommunityStatsView.swift @@ -18,14 +18,12 @@ struct CommunityStatsView: View { Text("\(community.subscriberCount ?? 0)") .fontWeight(.semibold) .font(.title) - } .padding(.vertical) .frame(maxWidth: .infinity) .background(Color(uiColor: .secondarySystemGroupedBackground)) .cornerRadius(AppConstants.largeItemCornerRadius) HStack(spacing: 16) { - VStack(spacing: 5) { HStack { Text("Posts") @@ -92,7 +90,6 @@ struct CommunityStatsView: View { .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) - } } diff --git a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift b/Mlem/Views/Tabs/Feeds/Components/FeedRowView.swift similarity index 54% rename from Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift rename to Mlem/Views/Tabs/Feeds/Components/FeedRowView.swift index 1055fead3..45028c796 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift +++ b/Mlem/Views/Tabs/Feeds/Components/FeedRowView.swift @@ -1,25 +1,30 @@ // -// CommunityListRowViews.swift +// FeedRowView.swift // Mlem // -// Created by Jake Shirley on 6/19/23. +// Created by Eric Andrews on 2024-01-08. // import Dependencies +import Foundation import SwiftUI -struct FavoriteStarButtonStyle: ButtonStyle { - let isFavorited: Bool - - func makeBody(configuration: Configuration) -> some View { - Image(systemName: isFavorited ? Icons.favoriteFill : Icons.favorite) - .foregroundColor(.blue) - .opacity(isFavorited ? 1.0 : 0.2) - .accessibilityRepresentation { configuration.label } +struct FeedRowView: View { + let feedType: FeedType + + var body: some View { + HStack { + Image(systemName: feedType.iconNameCircle) + .resizable() + .frame(width: 30, height: 30) + .foregroundColor(feedType.color) + + Text(feedType.label) + } } } -struct CommuntiyFeedRowView: View { +struct CommunityFeedRowView: View { @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker @Dependency(\.hapticManager) var hapticManager @Dependency(\.notifier) var notifier @@ -30,32 +35,34 @@ struct CommuntiyFeedRowView: View { let navigationContext: NavigationContext var body: some View { - NavigationLink(value: pathValue) { - HStack { - // NavigationLink with invisible array - communityNameLabel - - Spacer() - Button("Favorite Community") { - hapticManager.play(haptic: .success, priority: .high) - - toggleFavorite() - - }.buttonStyle(FavoriteStarButtonStyle(isFavorited: isFavorited())) - .accessibilityHidden(true) + HStack { + communityNameLabel + + Spacer() + + Button("Favorite Community") { + hapticManager.play(haptic: .success, priority: .high) + toggleFavorite() } + .buttonStyle(FavoriteStarButtonStyle(isFavorited: isFavorited())) + .accessibilityHidden(true) }.swipeActions { if subscribed { - Button("Unsubscribe") { + Button { Task(priority: .userInitiated) { await subscribe(communityId: community.id, shouldSubscribe: false) } - }.tint(.red) // Destructive role seems to remove from list so just make it red + } label: { + Label("Unsubscribe", systemImage: Icons.unsubscribe) + } + .tint(.red) // Destructive role seems to remove from list so just make it red } else { - Button("Subscribe") { + Button { Task(priority: .userInitiated) { await subscribe(communityId: community.id, shouldSubscribe: true) } + } label: { + Label("Subscribe", systemImage: Icons.subscribe) }.tint(.blue) } } @@ -65,20 +72,11 @@ struct CommuntiyFeedRowView: View { .accessibilityElement(children: .combine) .accessibilityLabel(communityLabel) } - - private var pathValue: AnyHashable { - if navigationContext == .sidebar { - return CommunityLinkWithContext(community: CommunityModel(from: community), feedType: .subscribed) - } else { - // Do not use enum route path in sidebar: It doesn't work, and I have no idea why =/ [2023.09] - return AppRoute.communityLinkWithContext(.init(community: CommunityModel(from: community), feedType: .subscribed)) - } - } private var communityNameText: Text { Text(community.name) } - + @ViewBuilder private var communityNameLabel: some View { if let website = community.actorId.host(percentEncoded: false) { @@ -90,21 +88,21 @@ struct CommuntiyFeedRowView: View { communityNameText } } - + private var communityLabel: String { var label = community.name - + if let website = community.actorId.host(percentEncoded: false) { label += "@\(website)" } - + if isFavorited() { label += ", is a favorite" } - + return label } - + private func toggleFavorite() { if isFavorited() { favoriteCommunitiesTracker.unfavorite(community) @@ -120,39 +118,12 @@ struct CommuntiyFeedRowView: View { } } } - + private func isFavorited() -> Bool { favoriteCommunitiesTracker.isFavorited(community) } - + private func subscribe(communityId: Int, shouldSubscribe: Bool) async { communitySubscriptionChanged(community, shouldSubscribe) } } - -struct HomepageFeedRowView: View { - let feedType: FeedType - - init(_ feedType: FeedType) { - self.feedType = feedType - } - - var body: some View { - NavigationLink(value: pathValue) { - HStack { - Image(systemName: feedType.iconNameCircle).resizable() - .frame(width: 36, height: 36).foregroundColor(feedType.color) - VStack(alignment: .leading) { - Text("\(feedType.label) Communities") - Text(feedType.description).font(.caption).foregroundColor(.gray) - } - } - .padding(.bottom, 1) - .accessibilityElement(children: .combine) - } - } - - private var pathValue: AnyHashable { - return CommunityLinkWithContext(community: nil, feedType: feedType) - } -} diff --git a/Mlem/Views/Tabs/Feeds/Components/NoPostsView.swift b/Mlem/Views/Tabs/Feeds/Components/NoPostsView.swift index 9344a17ba..e5449b12f 100644 --- a/Mlem/Views/Tabs/Feeds/Components/NoPostsView.swift +++ b/Mlem/Views/Tabs/Feeds/Components/NoPostsView.swift @@ -8,37 +8,43 @@ import SwiftUI struct NoPostsView: View { - @EnvironmentObject var postTracker: PostTracker + @EnvironmentObject var postTracker: StandardPostTracker - @Binding var isLoading: Bool - @Binding var postSortType: PostSortType + let loadingState: LoadingState + let postSortType: PostSortType @Binding var showReadPosts: Bool + // this isn't the most elegant but passing a nested binding doesn't seem to propagate changes correctly [Eric 2024.01.25] + let switchToHot: () -> Void var body: some View { VStack { - if !isLoading { - VStack(alignment: .center, spacing: AppConstants.postAndCommentSpacing) { - - let unreadItems = postTracker.hiddenItems[.read, default: 0] + if loadingState != .loading { + VStack(alignment: .center, spacing: 0) { + let unreadItems = postTracker.getFilteredCount(for: .read) Image(systemName: Icons.noPosts) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: unreadItems == 0 ? 35 : 50) - .padding(.bottom, unreadItems == 0 ? 8: 12) - Text(title) + .frame(width: 35) + .padding(.vertical, 35) + .padding(.top, 10) // offsets the illusion of whitespace created by lowercase letters below - if unreadItems != 0 { - Text( - "\(unreadItems) read post\(unreadItems == 1 ? " has" : "s have") been hidden." - ) - .foregroundStyle(.tertiary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 20) + VStack(spacing: AppConstants.postAndCommentSpacing) { + Text(title) + + if unreadItems != 0 { + Text( + "\(unreadItems) read post\(unreadItems == 1 ? " has" : "s have") been hidden." + ) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 20) + } + buttons + .padding(.top) } - buttons } .foregroundStyle(.secondary) } @@ -46,7 +52,7 @@ struct NoPostsView: View { } var title: String { - if PostSortType.topTypes.contains(postSortType) && postSortType != .topAll { + if PostSortType.topTypes.contains(postSortType), postSortType != .topAll { return "No posts found from the last \(postSortType.label.lowercased())." } return "No posts found." @@ -57,16 +63,14 @@ struct NoPostsView: View { VStack { if postSortType != .hot { Button { - isLoading = true - postSortType = .hot + switchToHot() } label: { Label("Switch to Hot", systemImage: Icons.hotSort) } } - if postTracker.hiddenItems[.read, default: 0] > 0 { + if postTracker.getFilteredCount(for: .read) > 0 { Button { if !showReadPosts { - isLoading = true showReadPosts = true } } label: { @@ -76,7 +80,5 @@ struct NoPostsView: View { } .foregroundStyle(.secondary) .buttonStyle(.bordered) - .padding(.top) - .padding(.horizontal, 20) } } diff --git a/Mlem/Views/Tabs/Feeds/Components/PostFeedView+Logic.swift b/Mlem/Views/Tabs/Feeds/Components/PostFeedView+Logic.swift new file mode 100644 index 000000000..4b8320442 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Components/PostFeedView+Logic.swift @@ -0,0 +1,21 @@ +// +// PostFeedView+Logic.swift +// Mlem +// +// Created by Eric Andrews on 2024-01-21. +// + +import Dependencies +import SwiftUI + +extension PostFeedView { + func setDefaultSortMode() async { + if let siteVersion = siteInformation.version, versionSafePostSort == nil { + let newPostSort = siteVersion < defaultPostSorting.minimumVersion ? fallbackDefaultPostSorting : defaultPostSorting + + // manually change the tracker sort type here so that view is not redrawn by `task(id: internalPostSortType)` + await postTracker.changeSortType(to: newPostSort) + postSortType = newPostSort + } + } +} diff --git a/Mlem/Views/Tabs/Feeds/PostFeedView+MenuFunctions.swift b/Mlem/Views/Tabs/Feeds/Components/PostFeedView+MenuFunctions.swift similarity index 67% rename from Mlem/Views/Tabs/Feeds/PostFeedView+MenuFunctions.swift rename to Mlem/Views/Tabs/Feeds/Components/PostFeedView+MenuFunctions.swift index 9f40cc5b9..e8e3cdc71 100644 --- a/Mlem/Views/Tabs/Feeds/PostFeedView+MenuFunctions.swift +++ b/Mlem/Views/Tabs/Feeds/Components/PostFeedView+MenuFunctions.swift @@ -38,30 +38,30 @@ extension PostFeedView { } func genEllipsisMenuFunctions() -> [MenuFunction] { - var ret: [MenuFunction] = .init() - - let blurNsfwText = shouldBlurNsfw ? "Unblur NSFW" : "Blur NSFW" - ret.append(MenuFunction.standardMenuFunction( - text: blurNsfwText, - imageName: Icons.blurNsfw, - destructiveActionPrompt: nil, - enabled: true - ) { - shouldBlurNsfw.toggle() - }) - - let showReadPostsText = showReadPosts ? "Hide Read" : "Show Read" - ret.append(MenuFunction.standardMenuFunction( - text: showReadPostsText, - imageName: "book", - destructiveActionPrompt: nil, - enabled: true - ) { - showReadPosts.toggle() - }) - - return ret - } + var ret: [MenuFunction] = .init() + + let blurNsfwText = shouldBlurNsfw ? "Unblur NSFW" : "Blur NSFW" + ret.append(MenuFunction.standardMenuFunction( + text: blurNsfwText, + imageName: Icons.blurNsfw, + destructiveActionPrompt: nil, + enabled: true + ) { + shouldBlurNsfw.toggle() + }) + + let showReadPostsText = showReadPosts ? "Hide Read" : "Show Read" + ret.append(MenuFunction.standardMenuFunction( + text: showReadPostsText, + imageName: "book", + destructiveActionPrompt: nil, + enabled: true + ) { + showReadPosts.toggle() + }) + + return ret + } func genPostSizeSwitchingFunctions() -> [MenuFunction] { PostSize.allCases.map { size in diff --git a/Mlem/Views/Tabs/Feeds/Components/PostFeedView.swift b/Mlem/Views/Tabs/Feeds/Components/PostFeedView.swift new file mode 100644 index 000000000..6c05dd232 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Components/PostFeedView.swift @@ -0,0 +1,172 @@ +// +// PostFeedView.swift +// Mlem +// +// Created by Eric Andrews on 2024-01-13. +// + +import Dependencies +import Foundation +import SwiftUI + +struct PostFeedView: View { + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation + + @AppStorage("shouldShowPostCreator") var shouldShowPostCreator: Bool = true + @AppStorage("showReadPosts") var showReadPosts: Bool = true + @AppStorage("shouldBlurNsfw") var shouldBlurNsfw: Bool = true + @AppStorage("postSize") var postSize: PostSize = .large + @AppStorage("defaultPostSorting") var defaultPostSorting: PostSortType = .hot + @AppStorage("fallbackDefaultPostSorting") var fallbackDefaultPostSorting: PostSortType = .hot + + @EnvironmentObject var postTracker: StandardPostTracker + @EnvironmentObject var appState: AppState + + // used to actually drive post loading; when nil, indicates that the site version is unresolved and it is not safe to load posts + @State var versionSafePostSort: PostSortType? + @Binding var postSortType: PostSortType { + didSet { + versionSafePostSort = postSortType + } + } + + // If versionSafePostSort is defined at init, the post tracker won't detect that and start loading until a fraction of a second after the view draws; if the tracker is also empty, this leads to noPostsView flashing for a fraction of a second. This masks that behavior. + @State var suppressNoPostsView: Bool = true + + let showCommunity: Bool + let communityContext: CommunityModel? + + @State var errorDetails: ErrorDetails? + + init(postSortType: Binding, showCommunity: Bool, communityContext: CommunityModel? = nil) { + @Dependency(\.siteInformation) var siteInformation + + if let siteVersion = siteInformation.version, postSortType.wrappedValue.minimumVersion <= siteVersion { + self._versionSafePostSort = .init(wrappedValue: postSortType.wrappedValue) + } + + self._postSortType = postSortType + self.showCommunity = showCommunity + self.communityContext = communityContext + } + + var body: some View { + content + .animation(.easeOut(duration: 0.2), value: postTracker.items.isEmpty) + .onChange(of: showReadPosts) { newValue in + if newValue { + Task { await postTracker.removeFilter(.read) } + } else { + Task { await postTracker.addFilter(.read) } + } + } + .task(id: siteInformation.version) { + await setDefaultSortMode() + } + .task(id: versionSafePostSort) { + defer { suppressNoPostsView = false } + + if let versionSafePostSort { + await postTracker.changeSortType( + to: versionSafePostSort, + forceRefresh: postTracker.items.isEmpty + ) + } + } + .toolbar { + if versionSafePostSort != nil { + if postTracker.feedType != .saved { + ToolbarItem(placement: .primaryAction) { sortMenu } + } + ToolbarItemGroup(placement: .secondaryAction) { + ForEach(genEllipsisMenuFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, confirmDestructive: nil) + } + Menu { + ForEach(genPostSizeSwitchingFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, confirmDestructive: nil) + } + } label: { + Label("Post Size", systemImage: Icons.postSizeSetting) + } + } + } + } + } + + var content: some View { + LazyVStack(spacing: 0) { + if postTracker.items.isEmpty || versionSafePostSort == nil { + noPostsView() + } else { + ForEach(postTracker.items, id: \.uid) { feedPost(for: $0) } + EndOfFeedView(loadingState: postTracker.loadingState, viewType: .hobbit) + } + } + } + + @ViewBuilder + private func feedPost(for post: PostModel) -> some View { + VStack(spacing: 0) { + // TODO: reenable nav + NavigationLink(.postLinkWithContext(.init(post: post, community: nil, postTracker: postTracker))) { + FeedPost( + post: post, + community: communityContext, + showPostCreator: shouldShowPostCreator, + showCommunity: showCommunity + ) + } + + Divider() + } + .onAppear { postTracker.loadIfThreshold(post) } + .buttonStyle(EmptyButtonStyle()) // Make it so that the link doesn't mess with the styling + } + + @ViewBuilder + private func noPostsView() -> some View { + VStack { + // don't show posts until site information loads to avoid jarring redraw + if postTracker.loadingState == .loading || versionSafePostSort == nil || suppressNoPostsView { + LoadingView(whatIsLoading: .posts) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + } else if let errorDetails { + ErrorView(errorDetails) + .frame(maxWidth: .infinity) + } else { + NoPostsView(loadingState: postTracker.loadingState, postSortType: postSortType, showReadPosts: $showReadPosts) { + suppressNoPostsView = true + postSortType = .hot + } + .transition(.scale(scale: 0.9).combined(with: .opacity)) + } + } + .animation(.easeOut(duration: 0.1), value: postTracker.loadingState) + .animation(.easeOut(duration: 0.1), value: suppressNoPostsView) + } + + @ViewBuilder + private var sortMenu: some View { + Menu { + ForEach(genOuterSortMenuFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, confirmDestructive: nil) // no destructive sorts + } + + Menu { + ForEach(genTopSortMenuFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, confirmDestructive: nil) // no destructive sorts + } + } label: { + Label("Top...", systemImage: Icons.topSort) + } + } label: { + Label( + "Selected sorting by \(postSortType.description)", + systemImage: postSortType.iconName + ) + } + } +} diff --git a/Mlem/Views/Tabs/Feeds/Components/PostSortMenu.swift b/Mlem/Views/Tabs/Feeds/Components/PostSortMenu.swift deleted file mode 100644 index 2addb68f8..000000000 --- a/Mlem/Views/Tabs/Feeds/Components/PostSortMenu.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// Sorting Menu.swift -// Mlem -// -// Created by David Bureš on 02.06.2023. -// - -import SwiftUI - -struct PostSortMenu: View { - @Binding var selectedSortingOption: PostSortType - var shortLabel: Bool = false - - var body: some View { - Menu { - ForEach(PostSortType.outerTypes, id: \.self) { type in - OptionButton( - title: type.label, - imageName: type.iconName, - option: type, - selectedOption: $selectedSortingOption - ) - } - - Menu { - ForEach(PostSortType.topTypes, id: \.self) { type in - OptionButton( - title: type.label, - imageName: type.iconName, - option: type, - selectedOption: $selectedSortingOption - ) - } - - } label: { - Label("Top…", systemImage: Icons.topSortMenu) - } - } label: { - if shortLabel { - HStack { - Spacer() - Image(systemName: selectedSortingOption.iconName) - .tint(.pink) - Text(selectedSortingOption.label) - .tint(.pink) - } - .frame(maxWidth: .infinity) - } else { - Label("Selected sorting by \"\(selectedSortingOption.description)\"", systemImage: selectedSortingOption.iconName) - } - } - } -} - -private struct OptionButton: View { - let title: String - let imageName: String - let option: Option - @Binding var selectedOption: Option - - var body: some View { - Button { - selectedOption = option - } label: { - Label(title, systemImage: imageName) - } - .disabled(option == selectedOption) - } -} diff --git a/Mlem/Views/Tabs/Feeds/Feed Root.swift b/Mlem/Views/Tabs/Feeds/Feed Root.swift deleted file mode 100644 index 0261ccae4..000000000 --- a/Mlem/Views/Tabs/Feeds/Feed Root.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// Feed Root.swift -// Mlem -// -// Created by tht7 on 30/06/2023. -// - -import Dependencies -import SwiftUI - -struct FeedRoot: View { - @EnvironmentObject var appState: AppState - @Environment(\.scenePhase) var phase - @Environment(\.tabSelectionHashValue) private var selectedTagHashValue - - @AppStorage("defaultFeed") var defaultFeed: FeedType = .subscribed - - @StateObject private var feedTabNavigation: AnyNavigationPath = .init() - @StateObject private var navigation: Navigation = .init() - - @State var rootDetails: CommunityLinkWithContext? - @State private var columnVisibility: NavigationSplitViewVisibility = .automatic - - var body: some View { - /* - Implementation Note: - - The conditional content in `detail` column must be inside the `NavigationStack`. To be clear, the root view for `detail` column must be `NavigationStack`, otherwise navigation may break in odd ways. [2023.09] - - For tab bar navigation (scroll to top) to work, ScrollViewReader must wrap the entire `NavigationSplitView`. Furthermore, the proxy must be passed into the environment on the split view. Attempting to do so on a column view doesn't work. [2023.09] - */ - ScrollViewReader { scrollProxy in - NavigationSplitView(columnVisibility: $columnVisibility) { - CommunityListView(selectedCommunity: $rootDetails) - } detail: { - NavigationStack(path: $feedTabNavigation.path) { - if let rootDetails { - FeedParentView( - community: rootDetails.community, - feedType: rootDetails.feedType - ) - .environmentObject(appState) - .environmentObject(feedTabNavigation) - .tabBarNavigationEnabled(.feeds, navigation) - .handleLemmyViews() - } else { - Text("Please select a community") - } - } - .id(rootDetails?.id ?? 0) - } - .environment(\.scrollViewProxy, scrollProxy) - } - .handleLemmyLinkResolution( - navigationPath: .constant(feedTabNavigation) - ) - .environment(\.navigationPathWithRoutes, $feedTabNavigation.path) - .environment(\.navigation, navigation) - .environmentObject(feedTabNavigation) - .environmentObject(appState) - .onAppear { - if rootDetails == nil || shortcutItemToProcess != nil { - let feedType = FeedType(rawValue: - shortcutItemToProcess?.type ?? - "nothing to see here" - ) ?? defaultFeed - rootDetails = CommunityLinkWithContext(community: nil, feedType: feedType) - shortcutItemToProcess = nil - } - } - .onOpenURL { url in - DispatchQueue.main.asyncAfter(deadline: .now()) { - if rootDetails == nil { - rootDetails = CommunityLinkWithContext(community: nil, feedType: defaultFeed) - } - - _ = HandleLemmyLinkResolution(navigationPath: .constant(feedTabNavigation)) - .didReceiveURL(url) - } - } - .onChange(of: phase) { newPhase in - if newPhase == .active { - if let shortcutItem = FeedType(rawValue: - shortcutItemToProcess?.type ?? - "nothing to see here" - ) { - rootDetails = CommunityLinkWithContext(community: nil, feedType: shortcutItem) - - shortcutItemToProcess = nil - } - } - } - } -} - -struct FeedRootPreview: PreviewProvider { - static var previews: some View { - FeedRoot() - } -} diff --git a/Mlem/Views/Tabs/Feeds/FeedView+Logic.swift b/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView+Logic.swift similarity index 53% rename from Mlem/Views/Tabs/Feeds/FeedView+Logic.swift rename to Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView+Logic.swift index 023402c32..162bdcc4a 100644 --- a/Mlem/Views/Tabs/Feeds/FeedView+Logic.swift +++ b/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView+Logic.swift @@ -1,17 +1,17 @@ // -// FeedView+Logic.swift +// AggregateFeedView+Logic.swift // Mlem // -// Created by Sjmarf on 31/12/2023. +// Created by Eric Andrews on 2024-01-20. // -import SwiftUI +import Foundation -extension FeedView { +extension AggregateFeedView { func genFeedSwitchingFunctions() -> [MenuFunction] { var ret: [MenuFunction] = .init() - FeedType.allCases.forEach { type in - let (imageName, enabled) = type != feedType + FeedType.allAggregateFeedCases.forEach { type in + let (imageName, enabled) = type != postTracker.feedType ? (type.iconName, true) : (type.iconNameFill, false) ret.append(MenuFunction.standardMenuFunction( @@ -19,7 +19,11 @@ extension FeedView { imageName: imageName, destructiveActionPrompt: nil, enabled: enabled, - callback: { feedType = type } + callback: { + Task { + await postTracker.changeFeedType(to: type) + } + } )) } return ret diff --git a/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView.swift b/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView.swift new file mode 100644 index 000000000..8d4c504fc --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView.swift @@ -0,0 +1,169 @@ +// +// AggregateFeedView.swift +// Mlem +// +// Created by Eric Andrews on 2024-01-11. +// + +import Dependencies +import Foundation +import SwiftUI + +/// View for post feeds aggregating multiple communities (all, local, subscribed, saved) +struct AggregateFeedView: View { + @Dependency(\.errorHandler) var errorHandler + + @EnvironmentObject var appState: AppState + + @StateObject var postTracker: StandardPostTracker + + @State var postSortType: PostSortType + + @Namespace var scrollToTop + @State private var scrollToTopAppeared = false + private var scrollToTopId: Int? { + postTracker.items.first?.id + } + + init(feedType: FeedType) { + // need to grab some stuff from app storage to initialize with + @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast + @AppStorage("upvoteOnSave") var upvoteOnSave = false + @AppStorage("showReadPosts") var showReadPosts = true + @AppStorage("defaultPostSorting") var defaultPostSorting: PostSortType = .hot + + self._postSortType = .init(wrappedValue: defaultPostSorting) + self._postTracker = .init(wrappedValue: .init( + internetSpeed: internetSpeed, + sortType: defaultPostSorting, + showReadPosts: showReadPosts, + feedType: feedType + )) + } + + var subtitle: String { + switch postTracker.feedType { + case .all: + return "Posts from all federated instances" + case .local: + return "Posts from \(appState.currentActiveAccount?.instanceLink.host() ?? "your instance's") communities" + case .subscribed: + return "Posts from all subscribed communities" + case .saved: + return "Your saved posts" + default: + assertionFailure("We shouldn't be here...") + return "" + } + } + + var body: some View { + content + .environment(\.feedType, postTracker.feedType) + .environmentObject(postTracker) + .refreshable { + await Task { + do { + _ = try await postTracker.refresh(clearBeforeRefresh: false) + } catch { + errorHandler.handle(error) + } + }.value + } + .background { + Color.systemBackground + } + .fancyTabScrollCompatible() + .toolbar { + ToolbarItem(placement: .principal) { + navBarTitle + .opacity(scrollToTopAppeared ? 0 : 1) + .animation(.easeOut(duration: 0.2), value: scrollToTopAppeared) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarColor(visibility: .automatic) + } + + @ViewBuilder + var content: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + VStack(spacing: 0) { + ScrollToView(appeared: $scrollToTopAppeared) + .id(scrollToTop) + headerView + .padding(.top, -1) + } + + PostFeedView(postSortType: $postSortType, showCommunity: true) + .environmentObject(postTracker) + } + } + } + } + + @ViewBuilder + var headerView: some View { + Menu { + ForEach(genFeedSwitchingFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, confirmDestructive: nil) + } + } label: { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: AppConstants.postAndCommentSpacing) { + Image(systemName: postTracker.feedType.iconNameCircle) + .resizable() + .frame(width: 44, height: 44) + .foregroundStyle(postTracker.feedType.color ?? .primary) + .padding(.leading, AppConstants.postAndCommentSpacing) + + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 5) { + Text(postTracker.feedType.label) + .lineLimit(1) + .minimumScaleFactor(0.01) + .fontWeight(.semibold) + Image(systemName: Icons.dropdown) + .foregroundStyle(.secondary) + } + .font(.title2) + + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(height: 44) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 5) + .padding(.bottom, 3) + Divider() + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + var navBarTitle: some View { + Menu { + ForEach(genFeedSwitchingFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, confirmDestructive: nil) + } + } label: { + HStack(alignment: .center, spacing: 0) { + Text(postTracker.feedType.label) + .font(.headline) + Image(systemName: Icons.dropdown) + .scaleEffect(0.7) + .fontWeight(.semibold) + } + .foregroundColor(.primary) + .accessibilityElement(children: .combine) + .accessibilityHint("Activate to change feeds.") + // this disables the implicit animation on the header view... + .transaction { $0.animation = nil } + } + } +} diff --git a/Mlem/Views/Tabs/Feeds/Feed Types/CommunityFeedView.swift b/Mlem/Views/Tabs/Feeds/Feed Types/CommunityFeedView.swift new file mode 100644 index 000000000..f1f8b7964 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Feed Types/CommunityFeedView.swift @@ -0,0 +1,334 @@ +// +// CommunityFeedView.swift +// Mlem +// +// Created by Eric Andrews on 2024-01-19. +// + +import Dependencies +import Foundation +import SwiftUI + +// swiftlint:disable type_body_length + +/// View for a single community +struct CommunityFeedView: View { + enum Tab: String, Identifiable, CaseIterable { + var id: Self { self } + case posts, about, moderators, statistics + } + + @AppStorage("shouldShowCommunityHeaders") var shouldShowCommunityHeaders: Bool = true + @AppStorage("shouldShowCommunityIcons") var shouldShowCommunityIcons: Bool = true + + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.hapticManager) var hapticManager + @Dependency(\.communityRepository) var communityRepository + + @EnvironmentObject var editorTracker: EditorTracker + + @Environment(\.colorScheme) var colorScheme + + @StateObject var postTracker: StandardPostTracker + + @State var postSortType: PostSortType + @State var selectedTab: Tab = .posts + + @State var communityModel: CommunityModel + + // destructive confirmation + @State private var isPresentingConfirmDestructive: Bool = false + @State private var confirmationMenuFunction: StandardMenuFunction? + + func confirmDestructive(destructiveFunction: StandardMenuFunction) { + confirmationMenuFunction = destructiveFunction + isPresentingConfirmDestructive = true + } + + // scroll to top + @Namespace var scrollToTop + @State private var scrollToTopAppeared = false + private var scrollToTopId: Int? { + postTracker.items.first?.id + } + + var availableTabs: [Tab] { + var output: [Tab] = [.posts, .moderators, .statistics] + if communityModel.description != nil { + output.insert(.about, at: 1) + } + return output + } + + init(communityModel: CommunityModel) { + // need to grab some stuff from app storage to initialize post tracker with + @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast + @AppStorage("upvoteOnSave") var upvoteOnSave = false + @AppStorage("showReadPosts") var showReadPosts = true + @AppStorage("defaultPostSorting") var defaultPostSorting: PostSortType = .hot + + self._communityModel = .init(wrappedValue: communityModel) + self._postSortType = .init(wrappedValue: defaultPostSorting) + self._postTracker = .init(wrappedValue: .init( + internetSpeed: internetSpeed, + sortType: defaultPostSorting, + showReadPosts: showReadPosts, + feedType: .community(communityModel) + )) + } + + var body: some View { + content + .onAppear { + if communityModel.moderators == nil { + Task(priority: .userInitiated) { + do { + communityModel = try await communityRepository.loadDetails(for: communityModel.communityId) + } catch { + errorHandler.handle(error) + } + } + } + } + .refreshable { + await Task { + do { + _ = try await postTracker.refresh(clearBeforeRefresh: false) + } catch { + errorHandler.handle(error) + } + }.value + } + .destructiveConfirmation( + isPresentingConfirmDestructive: $isPresentingConfirmDestructive, + confirmationMenuFunction: confirmationMenuFunction + ) + .fancyTabScrollCompatible() + .toolbar { + ToolbarItem(placement: .principal) { + Text(communityModel.name) + .font(.headline) + .opacity(scrollToTopAppeared ? 0 : 1) + .animation(.easeOut(duration: 0.2), value: scrollToTopAppeared) + } + + ToolbarItemGroup(placement: .secondaryAction) { + ForEach( + communityModel.menuFunctions( + editorTracker: editorTracker, + postTracker: postTracker + ) { communityModel = $0 } + ) { menuFunction in + MenuButton(menuFunction: menuFunction, confirmDestructive: confirmDestructive) + } + .destructiveConfirmation( + isPresentingConfirmDestructive: $isPresentingConfirmDestructive, + confirmationMenuFunction: confirmationMenuFunction + ) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarColor(visibility: .automatic) + } + + @ViewBuilder + var content: some View { + ScrollView { + VStack(spacing: 0) { + ScrollToView(appeared: $scrollToTopAppeared) + .id(scrollToTop) + headerView + .padding(.top, 5) + .background(Color.systemBackground) + + switch selectedTab { + case .posts: posts() + case .about: about() + case .moderators: moderators() + case .statistics: statistics() + } + } + } + .background { + VStack(spacing: 0) { + Color.systemBackground + .frame(height: 200) + + if selectedTab == .statistics || selectedTab == .moderators { + Color(uiColor: .systemGroupedBackground) + } + } + } + } + + func posts() -> some View { + PostFeedView(postSortType: $postSortType, showCommunity: false, communityContext: communityModel) + .environmentObject(postTracker) + } + + func about() -> some View { + VStack(spacing: AppConstants.postAndCommentSpacing) { + if shouldShowCommunityHeaders, let banner = communityModel.banner { + CachedImage(url: banner, cornerRadius: AppConstants.largeItemCornerRadius) + } + MarkdownView(text: communityModel.description ?? "", isNsfw: false) + } + .padding(AppConstants.postAndCommentSpacing) + } + + @ViewBuilder + func moderators() -> some View { + if let moderators = communityModel.moderators { + Divider() + .padding(.top, 15) + .background(Color.secondarySystemBackground) + ForEach(moderators, id: \.id) { user in + UserResultView(user, communityContext: communityModel) + Divider() + } + Color.secondarySystemBackground + .frame(height: 100) + } + } + + func statistics() -> some View { + VStack(spacing: 0) { + CommunityStatsView(community: communityModel) + .padding(.top, AppConstants.postAndCommentSpacing) + .background(Color(uiColor: .systemGroupedBackground)) + + Color(uiColor: .systemGroupedBackground) + .frame(maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + } + + // MARK: Header + + @ViewBuilder + var headerView: some View { + Group { + VStack(spacing: 5) { + HStack(alignment: .center, spacing: 10) { + if shouldShowCommunityIcons { + AvatarView(community: communityModel, avatarSize: 44, iconResolution: .unrestricted) + } + Button(action: communityModel.copyFullyQualifiedName) { + VStack(alignment: .leading, spacing: 0) { + Text(communityModel.displayName) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(1) + .minimumScaleFactor(0.01) + if let fullyQualifiedName = communityModel.fullyQualifiedName { + Text(fullyQualifiedName) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(height: 44) + } + .buttonStyle(.plain) + Spacer() + subscribeButton + } + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.bottom, 3) + Divider() + BubblePicker(availableTabs, selected: $selectedTab) { + Text($0.rawValue.capitalized) + } + } + Divider() + } + } + + var subscribeButtonForegroundColor: Color { + if communityModel.favorited { + return .blue + } else if communityModel.subscribed ?? false { + return .green + } + return .secondary + } + + var subscribeButtonBackgroundColor: Color { + if communityModel.favorited { + return .blue.opacity(0.1) + } else if communityModel.subscribed ?? false { + return .green.opacity(0.1) + } + return .clear + } + + var subscribeButtonIcon: String { + if communityModel.favorited { + return Icons.favoriteFill + } else if communityModel.subscribed ?? false { + return Icons.successCircle + } + return Icons.personFill + } + + @ViewBuilder + var subscribeButton: some View { + let foregroundColor = subscribeButtonForegroundColor + if let subscribed = communityModel.subscribed { + HStack(spacing: 4) { + if let subscriberCount = communityModel.subscriberCount { + Text(abbreviateNumber(subscriberCount)) + } + Image(systemName: subscribeButtonIcon) + .aspectRatio(contentMode: .fit) + } + .foregroundStyle(foregroundColor) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .background( + Capsule() + .strokeBorder(foregroundColor, style: .init(lineWidth: 1)) + .background(Capsule().fill(subscribeButtonBackgroundColor)) + ) + .gesture(TapGesture().onEnded { _ in + hapticManager.play(haptic: .lightSuccess, priority: .low) + print("tapped subscribe") + Task { + var community = communityModel + do { + if communityModel.favorited { + print("favorited") + confirmDestructive(destructiveFunction: communityModel.favoriteMenuFunction { communityModel = $0 }) + } else if subscribed { + print("subscribed") + try confirmDestructive(destructiveFunction: communityModel.subscribeMenuFunction { communityModel = $0 }) + } else { + print("not subscribed") + try await community.toggleSubscribe { item in + DispatchQueue.main.async { communityModel = item } + } + } + } catch { + errorHandler.handle(error) + } + } + }) + .simultaneousGesture(LongPressGesture().onEnded { _ in + hapticManager.play(haptic: .lightSuccess, priority: .low) + Task { + var community = communityModel + do { + // TODO: this doesn't update view state when favoriting, but it does when unfavoriting + try await communityModel.toggleFavorite { item in + DispatchQueue.main.async { communityModel = item } + } + } catch { + errorHandler.handle(error) + } + } + }) + } + } +} + +// swiftlint:enable type_body_length diff --git a/Mlem/Views/Tabs/Feeds/Feed Types/SavedFeedView.swift b/Mlem/Views/Tabs/Feeds/Feed Types/SavedFeedView.swift new file mode 100644 index 000000000..83d2aa742 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Feed Types/SavedFeedView.swift @@ -0,0 +1,49 @@ +// +// SavedFeedView.swift +// Mlem +// +// Created by Eric Andrews on 2024-01-21. +// + +import Dependencies +import Foundation +import SwiftUI + +struct SavedFeedView: View { + // TODO: ERIC this whole view needs its own PR--needs its own tracker to handle loading user content, needs a different type of feed to handle mixed posts and comments, and needs a good way of determining the current user ID + + @Dependency(\.siteInformation) var siteInformation + @Dependency(\.errorHandler) var errorHandler + + // ugly little hack to deal with the fact that dependencies don't propagate state changes nicely but we need to listen for siteInformation.myUserInfo to resolve + @State var siteInformationLoaded: Bool + + init() { + @Dependency(\.siteInformation) var siteInformation + + _siteInformationLoaded = .init(wrappedValue: siteInformation.myUserInfo != nil) + } + + var body: some View { + // note to reviewers: this is super ugly but exists just to get the app in a stable running state pending the aforementioned PR to make this view nice + if !siteInformationLoaded { + LoadingView(whatIsLoading: .posts) + .task { + for _ in 0 ..< 5 { + if siteInformation.myUserInfo != nil { + siteInformationLoaded = true + break + } + + do { + try await Task.sleep(nanoseconds: 1_000_000_000) + } catch { + errorHandler.handle(error) + } + } + } + } else { + AggregateFeedView(feedType: .saved) + } + } +} diff --git a/Mlem/Views/Tabs/Feeds/FeedParentView.swift b/Mlem/Views/Tabs/Feeds/FeedParentView.swift deleted file mode 100644 index 86389cf43..000000000 --- a/Mlem/Views/Tabs/Feeds/FeedParentView.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// FeedDecider.swift -// Mlem -// -// Created by Sjmarf on 31/12/2023. -// - -import SwiftUI - -// This is messy I know, but I couldn't work out another way of doing it, thanks to NavigationSplitView's weirdness with NavigationLinks across columns. Sjmarf [2023.12] -struct FeedParentView: View { - - let community: CommunityModel? - let feedType: FeedType? - - @Binding var rootDetails: CommunityLinkWithContext? - @Binding var splitViewColumnVisibility: NavigationSplitViewVisibility - - init( - community: CommunityModel?, - feedType: FeedType?, - splitViewColumnVisibility: Binding? = nil, - rootDetails: Binding? = nil - ) { - self.community = community - self.feedType = feedType - self._splitViewColumnVisibility = splitViewColumnVisibility ?? .constant(.automatic) - self._rootDetails = rootDetails ?? .constant(nil) - } - - var body: some View { - Group { - if let community { - CommunityView( - community: community, - splitViewColumnVisibility: $splitViewColumnVisibility, - rootDetails: $rootDetails - ) - } else if let feedType { - FeedView(feedType: feedType) - } - } - } -} diff --git a/Mlem/Views/Tabs/Feeds/FeedView.swift b/Mlem/Views/Tabs/Feeds/FeedView.swift deleted file mode 100644 index 58d83b03d..000000000 --- a/Mlem/Views/Tabs/Feeds/FeedView.swift +++ /dev/null @@ -1,227 +0,0 @@ -// -// FeedView.swift -// Mlem -// -// Created by Sjmarf on 31/12/2023. -// - -import SwiftUI -import Dependencies - -struct FeedView: View { - @Dependency(\.hapticManager) var hapticManager - @Dependency(\.errorHandler) var errorHandler - @Dependency(\.siteInformation) var siteInformation - - @Environment(\.navigationPathWithRoutes) private var navigationPath - @Environment(\.scrollViewProxy) private var scrollViewProxy - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @EnvironmentObject var appState: AppState - - @State var feedType: FeedType - - // MARK: Feed - - @StateObject var postTracker: PostTracker - @State var postSortType: PostSortType - - @Binding var rootDetails: CommunityLinkWithContext? - @Binding var splitViewColumnVisibility: NavigationSplitViewVisibility - - // MARK: Scroll to top - - @Namespace var scrollToTop - @State private var scrollToTopAppeared = false - private var scrollToTopId: Int? { - postTracker.items.first?.id - } - - // MARK: Destructive confirmation - - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? - - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } - - init( - feedType: FeedType, - splitViewColumnVisibility: Binding? = nil, - rootDetails: Binding? = nil - ) { - // need to grab some stuff from app storage to initialize post tracker with - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast - @AppStorage("upvoteOnSave") var upvoteOnSave = false - - self._feedType = State(initialValue: feedType) - - self._splitViewColumnVisibility = splitViewColumnVisibility ?? .constant(.automatic) - self._rootDetails = rootDetails ?? .constant(nil) - - @AppStorage("defaultPostSorting") var defaultPostSorting: PostSortType = .hot - self._postSortType = .init(wrappedValue: defaultPostSorting) - - self._postTracker = StateObject(wrappedValue: .init( - shouldPerformMergeSorting: false, - internetSpeed: internetSpeed, - upvoteOnSave: upvoteOnSave, - type: .feed(feedType, sortedBy: defaultPostSorting) - )) - } - - var body: some View { - ScrollView { - VStack(spacing: 0) { - VStack(spacing: 0) { - ScrollToView(appeared: $scrollToTopAppeared) - .id(scrollToTop) - headerView - .padding(.top, 5) - } - .background(Color.systemBackground) - if !postTracker.items.isEmpty { - Divider() - } - PostFeedView(postTracker: postTracker, postSortType: $postSortType) - .background(Color.secondarySystemBackground) - } - } - .refreshable { - await Task { - _ = await postTracker.refresh(clearBeforeFetch: false) - }.value - } - .background { - VStack(spacing: 0) { - Color.systemBackground - Color.secondarySystemBackground - } - } - .frame(maxWidth: .infinity) - .navigationBarTitleDisplayMode(.inline) - .navigationBarColor(visibility: .automatic) - .hoistNavigation { - if navigationPath.isEmpty { - // Need to check `scrollToTopAppeared` because we want to scroll to top before popping back to sidebar. [2023.09] - if scrollToTopAppeared { - if horizontalSizeClass == .regular { - print("show/hide sidebar in regular size class") - splitViewColumnVisibility = splitViewColumnVisibility == .all ? .detailOnly : .all - return true - } else { - print("show/hide sidebar in compact size class") - // This seems a lot more reliable than dismiss action for some reason. [2023.09] - rootDetails = nil - return true - } - } else { - print("scroll to top") - withAnimation { - scrollViewProxy?.scrollTo(scrollToTop, anchor: .top) - } - return true - } - } else { - if scrollToTopAppeared { - print("exhausted auxiliary actions, perform dismiss action instead...") - return false - } else { - withAnimation { - scrollViewProxy?.scrollTo(scrollToTop, anchor: .top) - } - return true - } - } - } - .onChange(of: feedType) { newValue in - postTracker.type = .feed(newValue, sortedBy: postSortType) - scrollViewProxy?.scrollTo(scrollToTop, anchor: .top) - } - .fancyTabScrollCompatible() - .toolbar { - ToolbarItem(placement: .principal) { - navBarTitle - .opacity(scrollToTopAppeared ? 0 : 1) - .animation(.easeOut(duration: 0.2), value: scrollToTopAppeared) - } - } - } - - var subtitle: String { - switch feedType { - case .all: - return "Posts from all federated instances" - case .local: - return "Posts from \(appState.currentActiveAccount?.instanceLink.host() ?? "your instance's") communities" - case .subscribed: - return "Posts from all subscribed communities" - } - } - - @ViewBuilder - var headerView: some View { - Group { - VStack(spacing: 5) { - HStack(alignment: .center, spacing: 10) { - Image(systemName: feedType.iconNameCircle) - .resizable() - .frame(width: 44, height: 44) - .foregroundStyle(feedType.color ?? .primary) - VStack(alignment: .leading, spacing: 0) { - Menu { - ForEach(genFeedSwitchingFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) - } - } label: { - HStack(spacing: 5) { - Text(feedType.label) - .lineLimit(1) - .minimumScaleFactor(0.01) - .fontWeight(.semibold) - Image(systemName: Icons.dropdown) - .foregroundStyle(.secondary) - } - .font(.title2) - } - .buttonStyle(.plain) - Text(subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - } - .frame(height: 44) - Spacer() - } - .padding(.horizontal, AppConstants.postAndCommentSpacing) - .padding(.bottom, 3) - } - Divider() - .padding(.bottom, 15) - .frame(maxWidth: .infinity) - .background(Color.secondarySystemBackground) - } - } - - @ViewBuilder - var navBarTitle: some View { - Menu { - ForEach(genFeedSwitchingFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) - } - } label: { - HStack(alignment: .center, spacing: 0) { - Text(feedType.label) - .font(.headline) - Image(systemName: Icons.dropdown) - .scaleEffect(0.7) - .fontWeight(.semibold) - } - .foregroundColor(.primary) - .accessibilityElement(children: .combine) - .accessibilityHint("Activate to change feeds.") - // this disables the implicit animation on the header view... - .transaction { $0.animation = nil } - } - } -} diff --git a/Mlem/Views/Tabs/Feeds/FeedsView.swift b/Mlem/Views/Tabs/Feeds/FeedsView.swift new file mode 100644 index 000000000..55da4a1d0 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/FeedsView.swift @@ -0,0 +1,115 @@ +// +// FeedsView.swift +// Mlem +// +// Created by Eric Andrews on 2024-01-07. +// + +import Foundation +import SwiftUI + +struct FeedsView: View { + @AppStorage("defaultFeed") var defaultFeed: DefaultFeedType = .subscribed + + @Environment(\.scenePhase) var scenePhase + + @EnvironmentObject var appState: AppState + + @State private var selectedFeed: FeedType? + @State var appeared: Bool = false // tracks whether this is the view's first appearance + + @StateObject private var communityListModel: CommunityListModel = .init() + + @StateObject private var feedTabNavigation: AnyNavigationPath = .init() + @StateObject private var navigation: Navigation = .init() + + var body: some View { + content + .onAppear { + // on first appearance, immediately navigate to defaultFeed + if !appeared { + appeared = true + selectedFeed = defaultFeed.toFeedType + } + + Task(priority: .high) { + await communityListModel.load() + } + } + .onChange(of: scenePhase) { newPhase in + if newPhase == .active, let shortcutItem = FeedType.fromShortcutString(shortcut: shortcutItemToProcess?.type) { + selectedFeed = shortcutItem + } + } + .handleLemmyLinkResolution(navigationPath: .constant(feedTabNavigation)) + } + + var content: some View { + ScrollViewReader { scrollProxy in + NavigationSplitView { + // Note that NavigationLinks in here update selectedFeed and are handled by the detail switch, not the general navigation handler + ZStack(alignment: .trailing) { + List(selection: $selectedFeed) { + ForEach([FeedType.all, FeedType.local, FeedType.subscribed, FeedType.saved]) { feedType in + NavigationLink(value: feedType) { + FeedRowView(feedType: feedType) + } + } + .padding(.trailing, 10) + + ForEach(communityListModel.visibleSections) { section in + Section(header: communitySectionHeaderView(for: section)) { + ForEach(communityListModel.communities(for: section)) { community in + NavigationLink(value: FeedType.community(.init(from: community, subscribed: true))) { + CommunityFeedRowView( + community: community, + subscribed: communityListModel.isSubscribed(to: community), + communitySubscriptionChanged: communityListModel.updateSubscriptionStatus, + navigationContext: .sidebar + ) + } + } + } + } + .padding(.trailing, 10) + } + .scrollIndicators(.hidden) + .navigationTitle("Feeds") + .listStyle(PlainListStyle()) + .fancyTabScrollCompatible() + + SectionIndexTitles(proxy: scrollProxy, communitySections: communityListModel.allSections()) + } + } detail: { + NavigationStack(path: $feedTabNavigation.path) { + Group { + switch selectedFeed { + case .all: + AggregateFeedView(feedType: .all) + case .local: + AggregateFeedView(feedType: .local) + case .subscribed: + AggregateFeedView(feedType: .subscribed) + case .saved: + SavedFeedView() + case let .community(communityModel): + CommunityFeedView(communityModel: communityModel) + case .none: + Text("Please select a feed") + } + } + .handleLemmyViews() + } + } + } + } + + private func communitySectionHeaderView(for section: CommunityListSection) -> some View { + HStack { + Text(section.inlineHeaderLabel!) + .accessibilityLabel(section.accessibilityLabel) + Spacer() + } + .id(section.viewId) + } +} diff --git a/Mlem/Views/Tabs/Feeds/PostFeedView+Logic.swift b/Mlem/Views/Tabs/Feeds/PostFeedView+Logic.swift deleted file mode 100644 index de46f5025..000000000 --- a/Mlem/Views/Tabs/Feeds/PostFeedView+Logic.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// PostFeedView+Logic.swift -// Mlem -// -// Created by Sjmarf on 31/12/2023. -// - -import SwiftUI -import Dependencies - -extension PostFeedView { - - func setDefaultSortMode() { - @AppStorage("defaultPostSorting") var defaultPostSorting: PostSortType = .hot - @AppStorage("fallbackDefaultPostSorting") var fallbackDefaultPostSorting: PostSortType = .hot - @Dependency(\.siteInformation) var siteInformation - if let siteVersion = siteInformation.version, siteVersion < defaultPostSorting.minimumVersion { - postSortType = fallbackDefaultPostSorting - } else { - postSortType = defaultPostSorting - } - } - - func filter(postView: PostModel) -> PostFilterReason? { - guard !postView.post.name.lowercased().contains(filtersTracker.filteredKeywords) else { return .keyword } - guard showReadPosts || !postView.read else { return .read } - return nil - } - - func handle(_ error: Error) { - switch error { - case APIClientError.networking: - guard postTracker.items.isEmpty else { - return - } - errorDetails = .init(title: "Unable to connect to Lemmy", error: error, refresh: { return await postTracker.refresh() }) - return - case APIClientError.decoding(let data, _): - // Checks if it's an "unknown sort type" error - if let str = String(data: data, encoding: .utf8), str.starts(with: "Query deserialize error: unknown variant") { - Task { - print("Unknown sort type: reloading feed") - @AppStorage("fallbackDefaultPostSorting") var fallbackDefaultPostSorting: PostSortType = .hot - postSortType = fallbackDefaultPostSorting - await postTracker.loadNextPage() - } - return - } - default: - break - } - errorDetails = .init(error: error, refresh: { return await postTracker.refresh() }) - } -} diff --git a/Mlem/Views/Tabs/Feeds/PostFeedView.swift b/Mlem/Views/Tabs/Feeds/PostFeedView.swift deleted file mode 100644 index c0ff94f84..000000000 --- a/Mlem/Views/Tabs/Feeds/PostFeedView.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// PostFeedView.swift -// Mlem -// -// Created by Sjmarf on 31/12/2023. -// - -import SwiftUI -import Dependencies - -struct PostFeedView: View { - @Dependency(\.errorHandler) var errorHandler - @Dependency(\.siteInformation) var siteInformation - - @AppStorage("shouldShowPostCreator") var shouldShowPostCreator: Bool = true - @AppStorage("showReadPosts") var showReadPosts: Bool = true - @AppStorage("shouldBlurNsfw") var shouldBlurNsfw: Bool = true - @AppStorage("postSize") var postSize: PostSize = .large - - @EnvironmentObject var filtersTracker: FiltersTracker - @EnvironmentObject var appState: AppState - @ObservedObject var postTracker: PostTracker - - var community: CommunityModel? - - @Binding var postSortType: PostSortType - - @State var shouldLoad: Bool = false - @State var errorDetails: ErrorDetails? - - init( - community: CommunityModel? = nil, - postTracker: PostTracker, - postSortType: Binding - ) { - self.community = community - self._postTracker = .init(wrappedValue: postTracker) - self._postSortType = postSortType - } - - var body: some View { - LazyVStack(spacing: 0) { - if postTracker.items.isEmpty { - noPostsView() - .padding(.top) - .frame(maxWidth: .infinity) - .frame(height: 400) - } else { - Group { - ForEach(postTracker.items, id: \.uid) { post in - feedPost(for: post) - } - // TODO: update to use proper LoadingState - EndOfFeedView(loadingState: postTracker.showLoadingIcon && postTracker.page > 1 ? .loading : .done, viewType: .hobbit) - } - .transition(.opacity) - } - } - .environmentObject(postTracker) - .animation(.easeOut(duration: 0.2), value: postTracker.items.isEmpty) - .toolbar { - ToolbarItem(placement: .primaryAction) { sortMenu } - ToolbarItemGroup(placement: .secondaryAction) { - ForEach(genEllipsisMenuFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) - } - Menu { - ForEach(genPostSizeSwitchingFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) - } - } label: { - Label("Post Size", systemImage: Icons.postSizeSetting) - } - } - } - .onAppear { - if postTracker.showLoadingIcon { - Task(priority: .userInitiated) { - postTracker.handleError = self.handle - postTracker.filter = self.filter - await postTracker.initFeed() - } - } - } - .onChange(of: postTracker.items) { newValue in - if !newValue.isEmpty { - errorDetails = nil - } - } - .onChange(of: postTracker.type) { _ in - Task(priority: .userInitiated) { - await postTracker.refresh(clearBeforeFetch: true) - } - } - .onChange(of: postSortType) { newValue in - Task(priority: .userInitiated) { - switch postTracker.type { - case .feed(let feedType, _): - postTracker.type = .feed(feedType, sortedBy: newValue) - case .community(let community, _): - postTracker.type = .community(community, sortedBy: newValue) - case nil: - break - } - } - } - .onChange(of: appState.currentActiveAccount) { _ in - Task(priority: .userInitiated) { - setDefaultSortMode() - await postTracker.refresh(clearBeforeFetch: true) - } - } - .onChange(of: showReadPosts) { _ in - Task(priority: .userInitiated) { - postTracker.filter = self.filter - await postTracker.refresh(clearBeforeFetch: true) - } - } - .onChange(of: shouldLoad) { value in - if value { - print("should load more posts...") - Task(priority: .medium) { await postTracker.loadNextPage() } - shouldLoad = false - } - } - } - - @ViewBuilder - private func feedPost(for post: PostModel) -> some View { - VStack(spacing: 0) { - // TODO: reenable nav - NavigationLink(.postLinkWithContext(.init(post: post, community: community, postTracker: postTracker))) { - FeedPost( - post: post, - community: community, - showPostCreator: shouldShowPostCreator, - showCommunity: community == nil - ) - } - Divider() - } - .buttonStyle(EmptyButtonStyle()) // Make it so that the link doesn't mess with the styling - .onAppear { - // on appear, flag whether new content should be loaded. Actual loading is attached to the feed view itself so that it doesn't get cancelled by view derenders - if postTracker.shouldLoadContentAfter(after: post) { - shouldLoad = true - } - } - } - - @ViewBuilder - private func noPostsView() -> some View { - VStack { - if postTracker.showLoadingIcon { // don't show posts until site information loads to avoid jarring redraw - LoadingView(whatIsLoading: .posts) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .transition(.opacity) - } else if let errorDetails { - ErrorView(errorDetails) - .frame(maxWidth: .infinity) - } else { - NoPostsView(isLoading: $postTracker.showLoadingIcon, postSortType: $postSortType, showReadPosts: $showReadPosts) - .transition(.scale(scale: 0.9).combined(with: .opacity)) - .padding(.top, 25) - } - } - .animation(.easeOut(duration: 0.1), value: postTracker.showLoadingIcon) - } - - @ViewBuilder - private var sortMenu: some View { - Menu { - ForEach(genOuterSortMenuFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) // no destructive sorts - } - - Menu { - ForEach(genTopSortMenuFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) // no destructive sorts - } - } label: { - Label("Top...", systemImage: Icons.topSort) - } - } label: { - Label( - "Selected sorting by \(postSortType.description)", - systemImage: postSortType.iconName - ) - } - } -} diff --git a/Mlem/Views/Tabs/Inbox/Inbox View.swift b/Mlem/Views/Tabs/Inbox/Inbox View.swift index b7ee44a75..e83d6e335 100644 --- a/Mlem/Views/Tabs/Inbox/Inbox View.swift +++ b/Mlem/Views/Tabs/Inbox/Inbox View.swift @@ -62,7 +62,6 @@ struct InboxView: View { @StateObject var replyTracker: ReplyTracker @StateObject var mentionTracker: MentionTracker @StateObject var messageTracker: MessageTracker - @StateObject var dummyPostTracker: PostTracker // used for navigation init() { // TODO: once the post tracker is changed we won't need this here... @@ -92,8 +91,6 @@ struct InboxView: View { self._replyTracker = StateObject(wrappedValue: newReplyTracker) self._mentionTracker = StateObject(wrappedValue: newMentionTracker) self._messageTracker = StateObject(wrappedValue: newMessageTracker) - - self._dummyPostTracker = StateObject(wrappedValue: .init(internetSpeed: internetSpeed, upvoteOnSave: upvoteOnSave)) } // input state handling @@ -120,17 +117,17 @@ struct InboxView: View { .handleLemmyViews() .environmentObject(inboxTabNavigation) .environmentObject(inboxTracker) - .onChange(of: shouldFilterRead) { newValue in - Task(priority: .userInitiated) { - await handleShouldFilterReadChange(newShouldFilterRead: newValue) - } - } } .handleLemmyLinkResolution(navigationPath: .constant(inboxTabNavigation)) .environment(\.navigationPathWithRoutes, $inboxTabNavigation.path) .environment(\.navigation, navigation) .environment(\.scrollViewProxy, scrollProxy) } + .onChange(of: shouldFilterRead) { newValue in + Task(priority: .userInitiated) { + await handleShouldFilterReadChange(newShouldFilterRead: newValue) + } + } } @ViewBuilder private func contentView(scrollProxy: ScrollViewProxy) -> some View { diff --git a/Mlem/Views/Tabs/Profile/UserFeedView.swift b/Mlem/Views/Tabs/Profile/UserFeedView.swift index 1bf7303c3..e4df9cb95 100644 --- a/Mlem/Views/Tabs/Profile/UserFeedView.swift +++ b/Mlem/Views/Tabs/Profile/UserFeedView.swift @@ -5,15 +5,17 @@ // Created by Sjmarf on 25/08/2023. // -import SwiftUI import Dependencies +import SwiftUI struct UserFeedView: View { @Dependency(\.siteInformation) var siteInformation @EnvironmentObject var editorTracker: EditorTracker var user: UserModel - @ObservedObject var privatePostTracker: PostTracker + + // TODO: this private post tracker feels super ugly + @ObservedObject var privatePostTracker: StandardPostTracker @ObservedObject var privateCommentTracker: CommentTracker @ObservedObject var communityTracker: ContentTracker @@ -33,11 +35,10 @@ struct UserFeedView: View { } var isOwnProfile: Bool { - return siteInformation.myUserInfo?.localUserView.person.id == user.userId + siteInformation.myUserInfo?.localUserView.person.id == user.userId } var body: some View { - LazyVStack(spacing: 0) { switch selectedTab { case .communities: @@ -79,9 +80,7 @@ struct UserFeedView: View { let feed: [FeedItem] switch selectedTab { case .overview: - feed = generateMixedFeed(savedItems: false) - case .saved: - feed = generateMixedFeed(savedItems: true) + feed = generateOverviewFeed() case .comments: feed = generateCommentFeed() case .posts: @@ -145,13 +144,7 @@ struct UserFeedView: View { privateCommentTracker.comments // Matched saved state .filter { - if savedItems { - return $0.commentView.saved - } else { - // If we unfavorited something while - // here we don't want it showing up in our feed - return $0.commentView.creator.id == user.userId - } + $0.commentView.creator.id == user.userId } // Create Feed Items @@ -166,17 +159,10 @@ struct UserFeedView: View { } } - private func generatePostFeed(savedItems: Bool = false) -> [FeedItem] { + private func generatePostFeed() -> [FeedItem] { privatePostTracker.items - // Matched saved state .filter { - if savedItems { - return $0.saved - } else { - // If we unfavorited something while - // here we don't want it showing up in our feed - return $0.creator.userId == user.userId - } + $0.creator.userId == user.userId } // Create Feed Items @@ -191,11 +177,11 @@ struct UserFeedView: View { } } - private func generateMixedFeed(savedItems: Bool) -> [FeedItem] { + private func generateOverviewFeed() -> [FeedItem] { var result: [FeedItem] = [] - result.append(contentsOf: generatePostFeed(savedItems: savedItems)) - result.append(contentsOf: generateCommentFeed(savedItems: savedItems)) + result.append(contentsOf: generatePostFeed()) + result.append(contentsOf: generateCommentFeed()) return result } diff --git a/Mlem/Views/Tabs/Profile/UserView+Logic.swift b/Mlem/Views/Tabs/Profile/UserView+Logic.swift index 21b6adc09..7437bee1f 100644 --- a/Mlem/Views/Tabs/Profile/UserView+Logic.swift +++ b/Mlem/Views/Tabs/Profile/UserView+Logic.swift @@ -12,12 +12,11 @@ extension UserView { var tabs: [UserViewTab] { var tabs: [UserViewTab] = [.overview, .posts, .comments] - if isOwnProfile { - tabs.append(.saved) - } + if !(user.moderatedCommunities?.isEmpty ?? true) { tabs.append(.communities) } + return tabs } @@ -42,8 +41,8 @@ extension UserView { func tryReloadUser() async { do { let authoredContent = try await personRepository.loadUserDetails(for: user.userId, limit: internetSpeed.pageSize) - self.user = UserModel(from: authoredContent) - self.communityTracker.replaceAll(with: user.moderatedCommunities ?? []) + user = UserModel(from: authoredContent) + communityTracker.replaceAll(with: user.moderatedCommunities ?? []) var savedContentData: GetPersonDetailsResponse? if isOwnProfile { @@ -72,18 +71,18 @@ extension UserView { } privateCommentTracker.comments = newComments - privatePostTracker.reset(with: newPosts) + await privatePostTracker.reset(with: newPosts) - self.isLoadingContent = false + isLoadingContent = false } catch { - errorHandler.handle( - .init( - title: "Couldn't load user info", - message: "There was an error while loading user information.\nTry again later.", - underlyingError: error - ) + errorHandler.handle( + .init( + title: "Couldn't load user info", + message: "There was an error while loading user information.\nTry again later.", + underlyingError: error ) - } + ) + } } } diff --git a/Mlem/Views/Tabs/Profile/UserView.swift b/Mlem/Views/Tabs/Profile/UserView.swift index 889364f42..356845a1d 100644 --- a/Mlem/Views/Tabs/Profile/UserView.swift +++ b/Mlem/Views/Tabs/Profile/UserView.swift @@ -28,7 +28,7 @@ struct UserView: View { @State var isPresentingAccountSwitcher: Bool = false - @StateObject var privatePostTracker: PostTracker + @StateObject var privatePostTracker: StandardPostTracker @StateObject var privateCommentTracker: CommentTracker = .init() @StateObject var communityTracker: ContentTracker = .init() @@ -49,11 +49,12 @@ struct UserView: View { @AppStorage("upvoteOnSave") var upvoteOnSave = false self.internetSpeed = internetSpeed - - self._privatePostTracker = StateObject(wrappedValue: .init( - shouldPerformMergeSorting: false, + + self._privatePostTracker = .init(wrappedValue: .init( internetSpeed: internetSpeed, - upvoteOnSave: upvoteOnSave + sortType: .new, + showReadPosts: true, + feedType: .all )) self._user = State(wrappedValue: user) diff --git a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift b/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift index 6891f59e3..460b99b07 100644 --- a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift +++ b/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift @@ -5,8 +5,8 @@ // Created by Sjmarf on 18/09/2023. // -import SwiftUI import Dependencies +import SwiftUI struct CommunityResultView: View { @Dependency(\.apiClient) private var apiClient @@ -139,8 +139,8 @@ struct CommunityResultView: View { .contextMenu { ForEach( community.menuFunctions( - trackerCallback, - editorTracker: editorTracker + editorTracker: editorTracker, + trackerCallback ) ) { item in MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Account/MatrixLinkView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Account/MatrixLinkView.swift index dec3f9ad4..ec69e578c 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Account/MatrixLinkView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Account/MatrixLinkView.swift @@ -5,8 +5,8 @@ // Created by Sjmarf on 30/11/2023. // -import SwiftUI import Dependencies +import SwiftUI struct MatrixLinkView: View { @Dependency(\.siteInformation) var siteInformation: SiteInformationTracker @@ -48,7 +48,6 @@ struct MatrixLinkView: View { } .frame(maxWidth: .infinity) .listRowBackground(Color(.systemGroupedBackground)) - } Section { TextField(text: $matrixUserId) { diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Account/ProfileSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Account/ProfileSettingsView.swift index c1c62e890..d31ad8d5a 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Account/ProfileSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Account/ProfileSettingsView.swift @@ -5,8 +5,8 @@ // Created by Sjmarf on 23/11/2023. // -import SwiftUI import Dependencies +import SwiftUI enum UserSettingsEditState { case unedited, edited, updating @@ -153,7 +153,7 @@ struct ProfileSettingsView: View { hasEdited = .edited } } - } + } NavigationLink(.settings(.linkMatrixAccount)) { let user = siteInformation.myUserInfo?.localUserView if let user, let matrixId = user.person.matrixUserId { diff --git a/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift index 8ab539622..b5a6d5df2 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift @@ -17,7 +17,7 @@ struct GeneralSettingsView: View { @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast @AppStorage("tapCommentToCollapse") var tapCommentToCollapse: Bool = true - @AppStorage("defaultFeed") var defaultFeed: FeedType = .subscribed + @AppStorage("defaultFeed") var defaultFeed: DefaultFeedType = .subscribed @AppStorage("hapticLevel") var hapticLevel: HapticPriority = .low @AppStorage("upvoteOnSave") var upvoteOnSave: Bool = false @@ -53,8 +53,6 @@ struct GeneralSettingsView: View { settingName: "Upvote on Save", isTicked: $upvoteOnSave ) - } footer: { - Text("You may need to restart the app for Upvote on Save changes to take effect.") } Section { @@ -70,7 +68,6 @@ struct GeneralSettingsView: View { // TODO: 0.17 deprecation remove this check if (siteInformation.version ?? .zero) >= .init("0.18.0") { - NavigationLink(.settings(.accountGeneral)) { HStack(spacing: 3) { Text("Account Settings") @@ -80,7 +77,6 @@ struct GeneralSettingsView: View { } .font(.footnote) } - } } } @@ -100,7 +96,7 @@ struct GeneralSettingsView: View { settingIconSystemName: defaultFeed.settingsIconName, settingName: "Default Feed", currentValue: $defaultFeed, - options: FeedType.allCases + options: DefaultFeedType.allCases ) } footer: { Text("The feed to show by default when you open the app.") diff --git a/MlemTests/Navigation/RoutableTests.swift b/MlemTests/Navigation/RoutableTests.swift index 7b7224043..ca505592c 100644 --- a/MlemTests/Navigation/RoutableTests.swift +++ b/MlemTests/Navigation/RoutableTests.swift @@ -9,7 +9,6 @@ import XCTest final class RoutableTests: XCTestCase { - private enum MockRoute: Routable { case routeA case routeB(Int) @@ -51,22 +50,4 @@ final class RoutableTests: XCTestCase { let data = "Mock Unsupported Value" XCTAssertThrowsError(try MockRoute.routeC(.makeRoute(data))) } - - // MARK: - AppRoutes - - /// Passing in raw data value should return a valid route. - /// Assert `(Data) –> Route`. - func testNavigationRouteHandlesDataValue() throws { - let value = CommunityLinkWithContext(community: nil, feedType: .all) - let route = try AppRoute.makeRoute(value) - XCTAssert(route == .communityLinkWithContext(value)) - } - - /// Passing in a route enum with an associated value should return the passed in value. - func testNavigationRouteHandlesNonNestedAssociatedValueEnumCase() throws { - let data = CommunityLinkWithContext(community: nil, feedType: .all) - let value = AppRoute.communityLinkWithContext(data) - let route = try AppRoute.makeRoute(value) - XCTAssert(route == value) - } }