diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index bf07df03b..88d415163 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -26,6 +26,13 @@ 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 */; }; @@ -72,6 +79,10 @@ 03C897F82ABF652D005F3403 /* SearchRoot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C897F72ABF652D005F3403 /* SearchRoot.swift */; }; 03C898012AC04EF9005F3403 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C898002AC04EF9005F3403 /* SearchResultsView.swift */; }; 03C898032AC04F61005F3403 /* RecentSearchesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C898022AC04F61005F3403 /* RecentSearchesView.swift */; }; + 03C905C82B3C70E200B9082F /* UserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C905C72B3C70E200B9082F /* UserView.swift */; }; + 03C905CA2B3C834C00B9082F /* AvatarBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C905C92B3C834C00B9082F /* AvatarBannerView.swift */; }; + 03C905CC2B3C88F700B9082F /* SearchTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C905CB2B3C88F700B9082F /* SearchTab.swift */; }; + 03C905CE2B3C8DC400B9082F /* UserView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C905CD2B3C8DC400B9082F /* UserView+Logic.swift */; }; 03CB329E2A6D8E910021EF27 /* PostComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CB329D2A6D8E910021EF27 /* PostComposerView.swift */; }; 03E0B9C82A61F0F400FED265 /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C72A61F0F400FED265 /* AdvancedSettingsView.swift */; }; 03E0B9CA2A62B4A400FED265 /* ContributorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C92A62B4A400FED265 /* ContributorsView.swift */; }; @@ -83,8 +94,9 @@ 03EC92972AC069CE007BBE7E /* SearchResultListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC92962AC069CE007BBE7E /* SearchResultListView.swift */; }; 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */; }; 03EEEAF32AB8DCDF0087F8D8 /* CommunityResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF22AB8DCDF0087F8D8 /* CommunityResultView.swift */; }; - 03EEEAF72AB8ED3C0087F8D8 /* SearchTabPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF62AB8ED3C0087F8D8 /* SearchTabPicker.swift */; }; + 03EEEAF72AB8ED3C0087F8D8 /* BubblePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF62AB8ED3C0087F8D8 /* BubblePicker.swift */; }; 03EEEAF92ABB985D0087F8D8 /* CommunityModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF82ABB985D0087F8D8 /* CommunityModel.swift */; }; + 03EF1D0C2B434CB10056175C /* CommunityStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EF1D0B2B434CB10056175C /* CommunityStatsView.swift */; }; 03F4DC9D2B193F4C00556C67 /* MatrixLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F4DC9C2B193F4C00556C67 /* MatrixLinkView.swift */; }; 03F4DC9F2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F4DC9E2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift */; }; 03F4DCA32B1A8B0400556C67 /* AccountGeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F4DCA22B1A8B0400556C67 /* AccountGeneralSettingsView.swift */; }; @@ -161,15 +173,9 @@ 50F2851C2A5C5C1500CF8865 /* TokenRefreshView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50F2851B2A5C5C1500CF8865 /* TokenRefreshView.swift */; }; 50F830F82A4C92BF00D67099 /* FeedTrackerItemProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50F830F72A4C92BF00D67099 /* FeedTrackerItemProviding.swift */; }; 50F830FA2A4C935C00D67099 /* FeedTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50F830F92A4C935C00D67099 /* FeedTracker.swift */; }; - 6307378D2A1CEB7C00039852 /* My Vote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6307378C2A1CEB7C00039852 /* My Vote.swift */; }; - 6314C9EB2A18D9C500B08405 /* Reply Editor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6314C9EA2A18D9C500B08405 /* Reply Editor.swift */; }; - 6314C9EE2A18EF3A00B08405 /* Keyboard Accessories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6314C9ED2A18EF3A00B08405 /* Keyboard Accessories.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 */; }; - 6318EDC727EE4E1500BFCAE8 /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6318EDC627EE4E1500BFCAE8 /* Post.swift */; }; - 6318EDC927EE4E1C00BFCAE8 /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6318EDC827EE4E1C00BFCAE8 /* Comment.swift */; }; - 6318EDCB27EE4E2200BFCAE8 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6318EDCA27EE4E2200BFCAE8 /* User.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 */; }; @@ -179,16 +185,12 @@ 6332FDBD27EFAF7C0009A98A /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 6332FDBC27EFAF7B0009A98A /* Settings.bundle */; }; 6332FDC027EFB05F0009A98A /* Settings Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6332FDBF27EFB05F0009A98A /* Settings Item.swift */; }; 6332FDC327EFCB5F0009A98A /* Color+Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6332FDC227EFCB5F0009A98A /* Color+Colors.swift */; }; - 63344C4D2A07ABEE001BC616 /* Community.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C4C2A07ABEE001BC616 /* Community.swift */; }; 63344C4F2A07BD2A001BC616 /* Filters Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C4E2A07BD2A001BC616 /* Filters Tracker.swift */; }; 63344C542A07D193001BC616 /* FiltersSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C532A07D193001BC616 /* FiltersSettingsView.swift */; }; 63344C562A07D81D001BC616 /* Array+Prepend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C552A07D81D001BC616 /* Array+Prepend.swift */; }; 63344C582A07DB9A001BC616 /* Array+MoveElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C572A07DB9A001BC616 /* Array+MoveElements.swift */; }; - 63344C5D2A08070B001BC616 /* Critical Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C5C2A08070B001BC616 /* Critical Errors.swift */; }; - 63344C602A080CA1001BC616 /* Outlined Web Complex Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C5F2A080CA1001BC616 /* Outlined Web Complex Style.swift */; }; 63344C622A08460D001BC616 /* View+Border.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C612A08460D001BC616 /* View+Border.swift */; }; 63344C672A08D4E3001BC616 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C662A08D4E3001BC616 /* AppearanceSettingsView.swift */; }; - 63344C712A098060001BC616 /* Sidebar View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63344C702A098060001BC616 /* Sidebar View.swift */; }; 6354F30A2A2E20040074C08D /* View+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6354F3092A2E20040074C08D /* View+Alert.swift */; }; 636250DC2A18111400FC59B4 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 636250DB2A18111400FC59B4 /* KeychainAccess */; }; 6363D5C527EE196700E34822 /* MlemApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6363D5C427EE196700E34822 /* MlemApp.swift */; }; @@ -261,8 +263,6 @@ 6386E03A2A0455BC006B3C1D /* String+Contains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6386E0392A0455BC006B3C1D /* String+Contains.swift */; }; 6386E0402A045723006B3C1D /* Website Icon Complex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6386E03F2A045723006B3C1D /* Website Icon Complex.swift */; }; 63A09B69285F53E9004F0032 /* Error View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A09B68285F53E9004F0032 /* Error View.swift */; }; - 63ABCE0E27F894060047A7D0 /* User View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63ABCE0D27F894060047A7D0 /* User View.swift */; }; - 63CE4E732A06F5A100405271 /* Access Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CE4E722A06F5A100405271 /* Access Token.swift */; }; 63D24ED92A169A5F005CCA81 /* UIApplication+FirstKeyWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63D24ED82A169A5F005CCA81 /* UIApplication+FirstKeyWindow.swift */; }; 63D24EDC2A169F12005CCA81 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 63D24EDB2A169F12005CCA81 /* MarkdownUI */; }; 63D24EDE2A169F2A005CCA81 /* Markdown View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63D24EDD2A169F2A005CCA81 /* Markdown View.swift */; }; @@ -277,9 +277,6 @@ 63F0C7BF2A058EDE00A18C5D /* Get Correct URL to Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F0C7BE2A058EDE00A18C5D /* Get Correct URL to Endpoint.swift */; }; 6D15D74C2A44DC240061B5CB /* Date+RelativeTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D15D74B2A44DC240061B5CB /* Date+RelativeTime.swift */; }; 6D405AFF2A43E66600C65F9C /* UserLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D405AFE2A43E66600C65F9C /* UserLabelView.swift */; }; - 6D405B012A43E79400C65F9C /* Sidebar Header Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D405B002A43E79400C65F9C /* Sidebar Header Avatar.swift */; }; - 6D405B032A43E7DB00C65F9C /* Sidebar Header Label.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D405B022A43E7DB00C65F9C /* Sidebar Header Label.swift */; }; - 6D405B052A43E82300C65F9C /* Sidebar Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D405B042A43E82300C65F9C /* Sidebar Header.swift */; }; 6D693A3E2A5113DF009E2D76 /* CreatePostReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D693A3D2A5113DF009E2D76 /* CreatePostReport.swift */; }; 6D693A402A51147E009E2D76 /* APIPostReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D693A3F2A51147E009E2D76 /* APIPostReportView.swift */; }; 6D693A422A5114DF009E2D76 /* APIPostReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D693A412A5114DF009E2D76 /* APIPostReport.swift */; }; @@ -298,11 +295,9 @@ 6DA61F892A575DF1001EA633 /* URL+WithIconSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA61F882A575DF1001EA633 /* URL+WithIconSize.swift */; }; 6DA7E9A22A50764E0095AB68 /* UserViewTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA7E9A12A50764E0095AB68 /* UserViewTab.swift */; }; 6DCE71292A53C26600CFEB5E /* ServerInstanceLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.swift */; }; - 6DD8677A2A5083A200BEB00F /* Community Sidebar Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD867792A5083A200BEB00F /* Community Sidebar Link.swift */; }; 6DE118392A4A20D600810C7E /* Lazy Load Post Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE118382A4A20D600810C7E /* Lazy Load Post Link.swift */; }; 6DE1183C2A4A217400810C7E /* Profile View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE1183B2A4A217400810C7E /* Profile View.swift */; }; 6DEB0FFB2A4F87BF007CAB99 /* User Moderator View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB0FFA2A4F87BF007CAB99 /* User Moderator View.swift */; }; - 6DEB0FFD2A4F891B007CAB99 /* User Moderator Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB0FFC2A4F891B007CAB99 /* User Moderator Link.swift */; }; 6DFF50432A48DED3001E648D /* Inbox View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFF50422A48DED3001E648D /* Inbox View.swift */; }; 6DFF50452A48E373001E648D /* GetPrivateMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFF50442A48E373001E648D /* GetPrivateMessages.swift */; }; 88B165B82A8643F4007C9115 /* View+NavigationBarColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B165B72A8643F4007C9115 /* View+NavigationBarColor.swift */; }; @@ -503,8 +498,6 @@ 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 */; }; - CDF1EF122A6B672C003594B6 /* Feed View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF1EF112A6B672C003594B6 /* Feed View.swift */; }; - CDF1EF142A6B6D6E003594B6 /* Feed View Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF1EF132A6B6D6E003594B6 /* Feed View 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 */; }; @@ -577,6 +570,13 @@ 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 = ""; }; @@ -623,6 +623,10 @@ 03C897F72ABF652D005F3403 /* SearchRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRoot.swift; sourceTree = ""; }; 03C898002AC04EF9005F3403 /* SearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = ""; }; 03C898022AC04F61005F3403 /* RecentSearchesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSearchesView.swift; sourceTree = ""; }; + 03C905C72B3C70E200B9082F /* UserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserView.swift; sourceTree = ""; }; + 03C905C92B3C834C00B9082F /* AvatarBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarBannerView.swift; sourceTree = ""; }; + 03C905CB2B3C88F700B9082F /* SearchTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTab.swift; sourceTree = ""; }; + 03C905CD2B3C8DC400B9082F /* UserView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserView+Logic.swift"; sourceTree = ""; }; 03CB329D2A6D8E910021EF27 /* PostComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostComposerView.swift; sourceTree = ""; }; 03E0B9C72A61F0F400FED265 /* AdvancedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettingsView.swift; sourceTree = ""; }; 03E0B9C92A62B4A400FED265 /* ContributorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributorsView.swift; sourceTree = ""; }; @@ -634,8 +638,9 @@ 03EC92962AC069CE007BBE7E /* SearchResultListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultListView.swift; sourceTree = ""; }; 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient+Pictrs.swift"; sourceTree = ""; }; 03EEEAF22AB8DCDF0087F8D8 /* CommunityResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityResultView.swift; sourceTree = ""; }; - 03EEEAF62AB8ED3C0087F8D8 /* SearchTabPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabPicker.swift; sourceTree = ""; }; + 03EEEAF62AB8ED3C0087F8D8 /* BubblePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubblePicker.swift; sourceTree = ""; }; 03EEEAF82ABB985D0087F8D8 /* CommunityModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityModel.swift; sourceTree = ""; }; + 03EF1D0B2B434CB10056175C /* CommunityStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityStatsView.swift; sourceTree = ""; }; 03F4DC9C2B193F4C00556C67 /* MatrixLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixLinkView.swift; sourceTree = ""; }; 03F4DC9E2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInAndSecuritySettingsView.swift; sourceTree = ""; }; 03F4DCA22B1A8B0400556C67 /* AccountGeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountGeneralSettingsView.swift; sourceTree = ""; }; @@ -711,16 +716,10 @@ 50F2851B2A5C5C1500CF8865 /* TokenRefreshView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenRefreshView.swift; sourceTree = ""; }; 50F830F72A4C92BF00D67099 /* FeedTrackerItemProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedTrackerItemProviding.swift; sourceTree = ""; }; 50F830F92A4C935C00D67099 /* FeedTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedTracker.swift; sourceTree = ""; }; - 6307378C2A1CEB7C00039852 /* My Vote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "My Vote.swift"; sourceTree = ""; }; 630D753C27F65E44006E60C9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 6314C9EA2A18D9C500B08405 /* Reply Editor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Reply Editor.swift"; sourceTree = ""; }; - 6314C9ED2A18EF3A00B08405 /* Keyboard Accessories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Keyboard Accessories.swift"; 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 = ""; }; - 6318EDC627EE4E1500BFCAE8 /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; - 6318EDC827EE4E1C00BFCAE8 /* Comment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; - 6318EDCA27EE4E2200BFCAE8 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.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 = ""; }; @@ -730,16 +729,12 @@ 6332FDBC27EFAF7B0009A98A /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; 6332FDBF27EFB05F0009A98A /* Settings Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Settings Item.swift"; sourceTree = ""; }; 6332FDC227EFCB5F0009A98A /* Color+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Colors.swift"; sourceTree = ""; }; - 63344C4C2A07ABEE001BC616 /* Community.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Community.swift; sourceTree = ""; }; 63344C4E2A07BD2A001BC616 /* Filters Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Filters Tracker.swift"; sourceTree = ""; }; 63344C532A07D193001BC616 /* FiltersSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersSettingsView.swift; sourceTree = ""; }; 63344C552A07D81D001BC616 /* Array+Prepend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Prepend.swift"; sourceTree = ""; }; 63344C572A07DB9A001BC616 /* Array+MoveElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+MoveElements.swift"; sourceTree = ""; }; - 63344C5C2A08070B001BC616 /* Critical Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Critical Errors.swift"; sourceTree = ""; }; - 63344C5F2A080CA1001BC616 /* Outlined Web Complex Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Outlined Web Complex Style.swift"; sourceTree = ""; }; 63344C612A08460D001BC616 /* View+Border.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Border.swift"; sourceTree = ""; }; 63344C662A08D4E3001BC616 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; - 63344C702A098060001BC616 /* Sidebar View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar View.swift"; sourceTree = ""; }; 6354F3092A2E20040074C08D /* View+Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Alert.swift"; sourceTree = ""; }; 6363D5C127EE196700E34822 /* Mlem.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mlem.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6363D5C427EE196700E34822 /* MlemApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MlemApp.swift; sourceTree = ""; }; @@ -814,8 +809,6 @@ 6386E0392A0455BC006B3C1D /* String+Contains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Contains.swift"; sourceTree = ""; }; 6386E03F2A045723006B3C1D /* Website Icon Complex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Website Icon Complex.swift"; sourceTree = ""; }; 63A09B68285F53E9004F0032 /* Error View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error View.swift"; sourceTree = ""; }; - 63ABCE0D27F894060047A7D0 /* User View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "User View.swift"; sourceTree = ""; }; - 63CE4E722A06F5A100405271 /* Access Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Access Token.swift"; sourceTree = ""; }; 63D24ED82A169A5F005CCA81 /* UIApplication+FirstKeyWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+FirstKeyWindow.swift"; sourceTree = ""; }; 63D24EDD2A169F2A005CCA81 /* Markdown View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Markdown View.swift"; sourceTree = ""; }; 63DF71F02A02999C002AC14E /* App Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App Constants.swift"; sourceTree = ""; }; @@ -829,9 +822,6 @@ 63F0C7BE2A058EDE00A18C5D /* Get Correct URL to Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Get Correct URL to Endpoint.swift"; sourceTree = ""; }; 6D15D74B2A44DC240061B5CB /* Date+RelativeTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+RelativeTime.swift"; sourceTree = ""; }; 6D405AFE2A43E66600C65F9C /* UserLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLabelView.swift; sourceTree = ""; }; - 6D405B002A43E79400C65F9C /* Sidebar Header Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar Header Avatar.swift"; sourceTree = ""; }; - 6D405B022A43E7DB00C65F9C /* Sidebar Header Label.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar Header Label.swift"; sourceTree = ""; }; - 6D405B042A43E82300C65F9C /* Sidebar Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar Header.swift"; sourceTree = ""; }; 6D693A3D2A5113DF009E2D76 /* CreatePostReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePostReport.swift; sourceTree = ""; }; 6D693A3F2A51147E009E2D76 /* APIPostReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPostReportView.swift; sourceTree = ""; }; 6D693A412A5114DF009E2D76 /* APIPostReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPostReport.swift; sourceTree = ""; }; @@ -850,11 +840,9 @@ 6DA61F882A575DF1001EA633 /* URL+WithIconSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+WithIconSize.swift"; sourceTree = ""; }; 6DA7E9A12A50764E0095AB68 /* UserViewTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserViewTab.swift; sourceTree = ""; }; 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInstanceLocation.swift; sourceTree = ""; }; - 6DD867792A5083A200BEB00F /* Community Sidebar Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Community Sidebar Link.swift"; sourceTree = ""; }; 6DE118382A4A20D600810C7E /* Lazy Load Post Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Lazy Load Post Link.swift"; sourceTree = ""; }; 6DE1183B2A4A217400810C7E /* Profile View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile View.swift"; sourceTree = ""; }; 6DEB0FFA2A4F87BF007CAB99 /* User Moderator View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "User Moderator View.swift"; sourceTree = ""; }; - 6DEB0FFC2A4F891B007CAB99 /* User Moderator Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "User Moderator Link.swift"; sourceTree = ""; }; 6DFF50422A48DED3001E648D /* Inbox View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Inbox View.swift"; sourceTree = ""; }; 6DFF50442A48E373001E648D /* GetPrivateMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPrivateMessages.swift; sourceTree = ""; }; 88B165B72A8643F4007C9115 /* View+NavigationBarColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+NavigationBarColor.swift"; sourceTree = ""; }; @@ -1051,8 +1039,6 @@ 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 = ""; }; - CDF1EF112A6B672C003594B6 /* Feed View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feed View.swift"; sourceTree = ""; }; - CDF1EF132A6B6D6E003594B6 /* Feed View Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feed View 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 = ""; }; @@ -1522,18 +1508,6 @@ path = Feed; sourceTree = ""; }; - 630049E927EF38EB00D5105B /* User-Interactable */ = { - isa = PBXGroup; - children = ( - 6318EDC627EE4E1500BFCAE8 /* Post.swift */, - 6318EDC827EE4E1C00BFCAE8 /* Comment.swift */, - 6318EDCA27EE4E2200BFCAE8 /* User.swift */, - 63344C4C2A07ABEE001BC616 /* Community.swift */, - CD05E77E2A4F263B0081D102 /* Menu Function.swift */, - ); - path = "User-Interactable"; - sourceTree = ""; - }; 630049EB27EF390900D5105B /* Networking */ = { isa = PBXGroup; children = ( @@ -1544,15 +1518,6 @@ path = Networking; sourceTree = ""; }; - 6314C9EC2A18EF2D00B08405 /* Reply Editor */ = { - isa = PBXGroup; - children = ( - 6314C9EA2A18D9C500B08405 /* Reply Editor.swift */, - 6314C9ED2A18EF3A00B08405 /* Keyboard Accessories.swift */, - ); - path = "Reply Editor"; - sourceTree = ""; - }; 6318DE5227FB956D00CC2AD6 /* Components */ = { isa = PBXGroup; children = ( @@ -1570,10 +1535,9 @@ B14E93BE2A45CA1A00D6DA93 /* Navigation Contexts */, CD2E18282A3B706300224F8A /* Settings */, 6386E0282A03D0B8006B3C1D /* Trackers */, - 630049E927EF38EB00D5105B /* User-Interactable */, 6386E0352A042C59006B3C1D /* Contributor.swift */, + CD05E77E2A4F263B0081D102 /* Menu Function.swift */, 63F0C7A52A05225100A18C5D /* Saved Account.swift */, - 63CE4E722A06F5A100405271 /* Access Token.swift */, 030E863E2AC6C5E9000283A6 /* PictrsImageModel.swift */, 030D00842AD1B94F00953B1D /* UserFlair.swift */, 63E5D3932A13CF3600EC1FBD /* Favorite Community.swift */, @@ -1699,18 +1663,9 @@ path = Views; sourceTree = ""; }; - 63344C592A080688001BC616 /* Errors */ = { - isa = PBXGroup; - children = ( - 63344C5C2A08070B001BC616 /* Critical Errors.swift */, - ); - path = Errors; - sourceTree = ""; - }; 63344C5E2A080C5F001BC616 /* Styles */ = { isa = PBXGroup; children = ( - 63344C5F2A080CA1001BC616 /* Outlined Web Complex Style.swift */, CD04D5DE2A361585008EF95B /* Empty Button Style.swift */, ); path = Styles; @@ -1720,11 +1675,7 @@ isa = PBXGroup; children = ( 632578172A29F83C00446A66 /* PostSortMenu.swift */, - 63344C702A098060001BC616 /* Sidebar View.swift */, 03A40DAC2AD5EA11005F019F /* NoPostsView.swift */, - 6D405B002A43E79400C65F9C /* Sidebar Header Avatar.swift */, - 6D405B022A43E7DB00C65F9C /* Sidebar Header Label.swift */, - 6D405B042A43E82300C65F9C /* Sidebar Header.swift */, ); path = Components; sourceTree = ""; @@ -2078,7 +2029,6 @@ ADF266932A4E89F800EBA648 /* Composer */, CD525F662A4B892900BCA794 /* Links */, E453A1CE2A81C1F20004BB8A /* Quick Look */, - 6314C9EC2A18EF2D00B08405 /* Reply Editor */, 63A09B68285F53E9004F0032 /* Error View.swift */, 6322A5CA27F77A4D00135D4F /* Loading View.swift */, 6386E03F2A045723006B3C1D /* Website Icon Complex.swift */, @@ -2110,10 +2060,9 @@ children = ( 6DA7E9A02A50763B0095AB68 /* User */, CD64832B2A38CE4200EE6CA3 /* Settings */, - 63344C592A080688001BC616 /* Errors */, 6317ABCA2A37292700603D76 /* FeedType.swift */, - 6307378C2A1CEB7C00039852 /* My Vote.swift */, CD6483352A39F20800EE6CA3 /* Post Type.swift */, + 03C905CB2B3C88F700B9082F /* SearchTab.swift */, 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.swift */, CDDCF6522A677F45003DA3AC /* TabSelection.swift */, CDE9CE4E2A7B0B1B002B97DD /* Haptic.swift */, @@ -2145,7 +2094,7 @@ 03C898022AC04F61005F3403 /* RecentSearchesView.swift */, 03EC92942AC064AE007BBE7E /* SearchHomeView.swift */, 036ED3BB2ABF1058009664BC /* SearchModel.swift */, - 03EEEAF62AB8ED3C0087F8D8 /* SearchTabPicker.swift */, + 03EEEAF62AB8ED3C0087F8D8 /* BubblePicker.swift */, 030D00832AD0842900953B1D /* Results */, ); path = Search; @@ -2163,7 +2112,9 @@ isa = PBXGroup; children = ( 6DE1183B2A4A217400810C7E /* Profile View.swift */, - 63ABCE0D27F894060047A7D0 /* User View.swift */, + 03C905C72B3C70E200B9082F /* UserView.swift */, + 03C905CD2B3C8DC400B9082F /* UserView+Logic.swift */, + 03C905C92B3C834C00B9082F /* AvatarBannerView.swift */, 6DEB0FFA2A4F87BF007CAB99 /* User Moderator View.swift */, 039439902A98FA6100463032 /* UserFeedView.swift */, ); @@ -2196,8 +2147,6 @@ B14E93BF2A45CA3400D6DA93 /* Post Link.swift */, B14E93C12A45D3B300D6DA93 /* Community Link.swift */, 6DE118382A4A20D600810C7E /* Lazy Load Post Link.swift */, - 6DEB0FFC2A4F891B007CAB99 /* User Moderator Link.swift */, - 6DD867792A5083A200BEB00F /* Community Sidebar Link.swift */, ); path = "Navigation Contexts"; sourceTree = ""; @@ -2405,8 +2354,14 @@ CD2E14782A6B283D004198DE /* Feeds */ = { isa = PBXGroup; children = ( - CDF1EF112A6B672C003594B6 /* Feed View.swift */, - CDF1EF132A6B6D6E003594B6 /* Feed View Logic.swift */, + 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 */, @@ -3035,7 +2990,9 @@ 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 */, 03EC92972AC069CE007BBE7E /* SearchResultListView.swift in Sources */, 637218472A3A2AAD008C4816 /* APICommentView.swift in Sources */, @@ -3069,7 +3026,6 @@ 03EEEAF32AB8DCDF0087F8D8 /* CommunityResultView.swift in Sources */, E47478132AAC350E001CB1AC /* NavigationLink+Helpers.swift in Sources */, 5064D0432A6E645D00B22EE3 /* Notifiable.swift in Sources */, - 6318EDCB27EE4E2200BFCAE8 /* User.swift in Sources */, 039439932A99098900463032 /* InternetConnectionManager.swift in Sources */, CD82A2592A71775E00111034 /* UnreadTracker.swift in Sources */, CDEBC32E2A9A583900518D9D /* Post Tracker.swift in Sources */, @@ -3096,7 +3052,6 @@ 637218702A3A2AAD008C4816 /* ResolveObject.swift in Sources */, CDE6A8162A490AE00062D161 /* InboxMessageBodyView.swift in Sources */, CD04D5DD2A361564008EF95B /* ReplyButtonView.swift in Sources */, - 6314C9EB2A18D9C500B08405 /* Reply Editor.swift in Sources */, CD4368D22AE2460100BD8BD1 /* ReplyTracker.swift in Sources */, CD9A49D32B045B81001E18A0 /* ZoomableImageView.swift in Sources */, 6DFF50452A48E373001E648D /* GetPrivateMessages.swift in Sources */, @@ -3129,15 +3084,16 @@ CDB0117F2A6F70A000D043EB /* Editor Tracker.swift in Sources */, 030E86482AC6FD1D000283A6 /* _assignIfNotEqual.swift in Sources */, 6354F30A2A2E20040074C08D /* View+Alert.swift in Sources */, - 6318EDC727EE4E1500BFCAE8 /* Post.swift in Sources */, + 0315E9F52B41C3EB00E3BA88 /* CommunityView.swift in Sources */, 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */, + 03C905C82B3C70E200B9082F /* UserView.swift in Sources */, 6372186C2A3A2AAD008C4816 /* SaveComment.swift in Sources */, CD4368B62AE23F4700BD8BD1 /* ParentTracker.swift in Sources */, - 6DD8677A2A5083A200BEB00F /* Community Sidebar Link.swift in Sources */, 03C898032AC04F61005F3403 /* RecentSearchesView.swift in Sources */, 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 */, @@ -3149,6 +3105,7 @@ 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 */, @@ -3162,9 +3119,8 @@ B11D72832A49FAA7009DC22F /* Cached Image.swift in Sources */, 637218752A3A2AAD008C4816 /* GetCommunity.swift in Sources */, 03A2767B2AFE560000C0D66B /* CommunityModel+SwipeActions.swift in Sources */, - CDF1EF142A6B6D6E003594B6 /* Feed View Logic.swift in Sources */, 6DFF50432A48DED3001E648D /* Inbox View.swift in Sources */, - 03EEEAF72AB8ED3C0087F8D8 /* SearchTabPicker.swift in Sources */, + 03EEEAF72AB8ED3C0087F8D8 /* BubblePicker.swift in Sources */, CD2053102AC878B50000AA38 /* UpdatedTimestampView.swift in Sources */, CD1446232A5B336900610EF1 /* LicensesView.swift in Sources */, CD4368D02AE245F400BD8BD1 /* MessageTracker.swift in Sources */, @@ -3184,7 +3140,6 @@ CDDCF6512A677E1B003DA3AC /* FancyTabItemPreferenceKeys.swift in Sources */, CDEBC32A2A9A580B00518D9D /* Post Model.swift in Sources */, CDC6A8CA2A6F1C8D00CC11AC /* AssociatedIconProtocol.swift in Sources */, - 6D405B032A43E7DB00C65F9C /* Sidebar Header Label.swift in Sources */, 030E86412AC6F692000283A6 /* SearchBar.swift in Sources */, 50785F762A9A684300117245 /* SavedAccountTracker+Dependency.swift in Sources */, 632578182A29F83C00446A66 /* PostSortMenu.swift in Sources */, @@ -3229,7 +3184,6 @@ 50CC4A742A9CB10B0074C845 /* TimestampedValue.swift in Sources */, 505240E72A88D36D00EA4558 /* SectionIndexTitles.swift in Sources */, 5064D0452A71549C00B22EE3 /* NotificationMessage.swift in Sources */, - 63344C4D2A07ABEE001BC616 /* Community.swift in Sources */, E4F0B56F2ABD00A000BC3E4A /* View+PresentationBackgroundInteraction.swift in Sources */, 6D693A4C2A51B99E009E2D76 /* APICommentReport.swift in Sources */, 030E863B2AC6C3B1000283A6 /* PictrsRespository.swift in Sources */, @@ -3249,7 +3203,6 @@ 5064D03F2A6DE0DB00B22EE3 /* Notifier+Dependency.swift in Sources */, 6D8003792A45FD1300363206 /* Bundle+VersionNumbers.swift in Sources */, CDB45C642AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift in Sources */, - 63344C712A098060001BC616 /* Sidebar View.swift in Sources */, 6DE118392A4A20D600810C7E /* Lazy Load Post Link.swift in Sources */, CDF8426B2A4A2AB600723DA0 /* InboxItem.swift in Sources */, 637218572A3A2AAD008C4816 /* APISiteView.swift in Sources */, @@ -3257,7 +3210,6 @@ CD2BD6782A79F55800ECFF89 /* ImageSize.swift in Sources */, 50785F712A98C4F600117245 /* SiteInformationTracker.swift in Sources */, CD46C1F82B0D0A8A00065953 /* View+ReselectAction.swift in Sources */, - 6DEB0FFD2A4F891B007CAB99 /* User Moderator Link.swift in Sources */, 6D405AFF2A43E66600C65F9C /* UserLabelView.swift in Sources */, CD29ED3B2B2E8624006937CE /* String+IsNotEmpty.swift in Sources */, CD391F9A2A537EF900E213B5 /* CommentBodyView.swift in Sources */, @@ -3273,6 +3225,7 @@ 038A16E92A7A9C640087987E /* LayoutWidget.swift in Sources */, CD9A03C82B389F7000C16276 /* EnvironmentValues+FeedType.swift in Sources */, 50811B2E2A92046D006BA3F2 /* URL+Mock.swift in Sources */, + 03C905CC2B3C88F700B9082F /* SearchTab.swift in Sources */, E409E16E2AFEFB8C0026FDC2 /* ImageDetailSheetState.swift in Sources */, CD3FBCE52A4A89B900B2063F /* Mentions Feed View.swift in Sources */, CD391F962A535F5400E213B5 /* ResponseEditorView.swift in Sources */, @@ -3304,7 +3257,6 @@ 637218522A3A2AAD008C4816 /* APIFederatedInstances.swift in Sources */, CD59E8A52A72C943005757F4 /* MarkAllAsReadRequest.swift in Sources */, 03A1B3F22A83F33900AB0DE0 /* DownvoteCounterView.swift in Sources */, - 6D405B012A43E79400C65F9C /* Sidebar Header Avatar.swift in Sources */, CDF8425C2A49E4C000723DA0 /* APIPersonMentionView.swift in Sources */, 6DCE71292A53C26600CFEB5E /* ServerInstanceLocation.swift in Sources */, 03E0B9CA2A62B4A400FED265 /* ContributorsView.swift in Sources */, @@ -3325,7 +3277,6 @@ 6372184B2A3A2AAD008C4816 /* APIPostAggregates.swift in Sources */, 50A8812C2A72D727003E3661 /* CommunityRepository+Dependency.swift in Sources */, 0394398F2A98EB2300463032 /* APIComment+Mock.swift in Sources */, - 63CE4E732A06F5A100405271 /* Access Token.swift in Sources */, E453477E2A9DE37300D1B46F /* Array+SafeIndexing.swift in Sources */, CD4368BE2AE23FA600BD8BD1 /* LoadingState.swift in Sources */, CD9DD8852A62302A0044EA8E /* ConcreteEditorModel.swift in Sources */, @@ -3353,7 +3304,6 @@ 03E90FB12B3703ED00E5A802 /* AccountSortMode.swift in Sources */, CDC1C93C2A7AA76000072E3D /* InternetSpeed.swift in Sources */, 50EC39B22A346DDC00E014C2 /* URLHandler.swift in Sources */, - 63344C602A080CA1001BC616 /* Outlined Web Complex Style.swift in Sources */, 63F0C7BF2A058EDE00A18C5D /* Get Correct URL to Endpoint.swift in Sources */, 632E8EE827EE63DB007E8D75 /* DownvoteButtonView.swift in Sources */, 50D61E5B2AA32B9400A926EC /* APISession.swift in Sources */, @@ -3381,14 +3331,12 @@ 50A881282A71D66B003E3661 /* APIClient+Community.swift in Sources */, 039C8DB72B35A32D0096BAAF /* AccountSwitcherSettingsView.swift in Sources */, CD29ED472B2E8785006937CE /* EnvironmentValues+NavigationPath.swift in Sources */, - 6307378D2A1CEB7C00039852 /* My Vote.swift in Sources */, 50F830FA2A4C935C00D67099 /* FeedTracker.swift in Sources */, CD2053142ACBAF150000AA38 /* AvatarType.swift in Sources */, CD69F55D2A400DF50028D4F7 /* UIUserInterfaceStyle+SettingsOptions.swift in Sources */, CDF1EF182A6C40C9003594B6 /* Menu Button.swift in Sources */, 6D91D4552A415994006B8F9A /* CommunityListSidebarEntry.swift in Sources */, 50A8812A2A72D6BD003E3661 /* CommunityRepository.swift in Sources */, - 6318EDC927EE4E1C00BFCAE8 /* Comment.swift in Sources */, 038A16E72A7A9C430087987E /* LayoutWidgetCollection.swift in Sources */, 6322A5D227F88CFD00135D4F /* Time Parser.swift in Sources */, AD1B0D352A5F63F60006F554 /* AboutView.swift in Sources */, @@ -3412,6 +3360,7 @@ 5064D0412A6E63E000B22EE3 /* Task+Notifiable.swift in Sources */, 63F0C7BD2A058CD200A18C5D /* Check if Endpoint Exists.swift in Sources */, E4D4DBA02A7C7B9D00C4F3DE /* Comments.swift in Sources */, + 03EF1D0C2B434CB10056175C /* CommunityStatsView.swift in Sources */, 6363D5C727EE196700E34822 /* ContentView.swift in Sources */, 03F4DC9D2B193F4C00556C67 /* MatrixLinkView.swift in Sources */, 6D8F08FF2A4029AE003EB4FD /* Community List View.swift in Sources */, @@ -3427,7 +3376,6 @@ CDF842642A49EAFA00723DA0 /* GetPersonMentions.swift in Sources */, CD6A2A792B1A553500003E23 /* SuccessResponse.swift in Sources */, 031A61802B1CEA7300ABF23B /* ChangePassword.swift in Sources */, - 6D405B052A43E82300C65F9C /* Sidebar Header.swift in Sources */, CD4368B42AE23F3500BD8BD1 /* ChildTrackerProtocol.swift in Sources */, CD4368D92AE2478300BD8BD1 /* MentionModel+InboxItem.swift in Sources */, CDB45C5A2AF0AEFE00A1FF08 /* AlternativeIconLabel.swift in Sources */, @@ -3451,6 +3399,7 @@ 03E79F3F2AE3E7100006700D /* SortingSettingsView.swift in Sources */, 039C8DB92B35A81C0096BAAF /* AccountIconStack.swift in Sources */, CDCBD7262A8D69A200387A2C /* Instance Picker View.swift in Sources */, + 03C905CE2B3C8DC400B9082F /* UserView+Logic.swift in Sources */, 6372185B2A3A2AAD008C4816 /* APICommunityView.swift in Sources */, 030E86442AC6F6D5000283A6 /* SearchBar+NavigationView.swift in Sources */, 637218552A3A2AAD008C4816 /* APITagline.swift in Sources */, @@ -3471,8 +3420,8 @@ 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 */, - 63ABCE0E27F894060047A7D0 /* User View.swift in Sources */, CD6483322A38D3A600EE6CA3 /* ScoreCounterView.swift in Sources */, 50CC4A7A2A9CC45D0074C845 /* InstanceMetadata+Mock.swift in Sources */, 6318DE5427FB958800CC2AD6 /* Stickied Tag.swift in Sources */, @@ -3488,12 +3437,13 @@ 03B7AAEF2ABCB9DC00068B23 /* ContentTracker.swift in Sources */, 50811B2C2A920443006BA3F2 /* Date+Mock.swift in Sources */, CD391FA02A545F8600E213B5 /* Compact Post.swift in Sources */, - 6314C9EE2A18EF3A00B08405 /* Keyboard Accessories.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 */, @@ -3515,11 +3465,9 @@ 03F4DC9F2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift in Sources */, 03F76FA62B2F5F4700E2B54A /* LinkUploadOptionsView.swift in Sources */, 6D15D74C2A44DC240061B5CB /* Date+RelativeTime.swift in Sources */, - CDF1EF122A6B672C003594B6 /* Feed View.swift in Sources */, CDA217E62A63016A00BDA173 /* ReportMessage.swift in Sources */, CD9DD8832A622A6C0044EA8E /* ReportCommentReply.swift in Sources */, CD3FBCE12A4A836000B2063F /* AllItemsFeedView.swift in Sources */, - 63344C5D2A08070B001BC616 /* Critical Errors.swift in Sources */, 6D91D4582A4159D8006B8F9A /* CommunityListRowViews.swift in Sources */, 63F0C7B92A0533C700A18C5D /* Add Account View.swift in Sources */, 63E5D3922A13CF2300EC1FBD /* Favorite Community Tracker.swift in Sources */, diff --git a/Mlem/API/Models/Community/APISubscribedStatus.swift b/Mlem/API/Models/Community/APISubscribedStatus.swift index 33f5e6bc6..5abb8befa 100644 --- a/Mlem/API/Models/Community/APISubscribedStatus.swift +++ b/Mlem/API/Models/Community/APISubscribedStatus.swift @@ -15,7 +15,7 @@ enum APISubscribedStatus: String, Decodable { var isSubscribed: Bool { switch self { - case .subscribed: + case .subscribed, .pending: true default: false diff --git a/Mlem/API/Models/Person/APIPersonAggregates.swift b/Mlem/API/Models/Person/APIPersonAggregates.swift index 25e38f0c1..356f2bd1c 100644 --- a/Mlem/API/Models/Person/APIPersonAggregates.swift +++ b/Mlem/API/Models/Person/APIPersonAggregates.swift @@ -12,7 +12,7 @@ struct APIPersonAggregates: Decodable { let id: Int? // TODO: 0.18 Deprecation remove this field let personId: Int let postCount: Int - let postScore: Int? + let postScore: Int? // TODO: 0.18 Deprecation remove this field let commentCount: Int - let commentScore: Int? + let commentScore: Int? // TODO: 0.18 Deprecation remove this field } diff --git a/Mlem/ContentView.swift b/Mlem/ContentView.swift index 44b8932de..564d22fcb 100644 --- a/Mlem/ContentView.swift +++ b/Mlem/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { @Dependency(\.errorHandler) var errorHandler @Dependency(\.personRepository) var personRepository @Dependency(\.hapticManager) var hapticManager + @Dependency(\.siteInformation) var siteInformation @Dependency(\.accountsTracker) var accountsTracker @Environment(\.setAppFlow) private var setFlow @@ -41,6 +42,14 @@ struct ContentView: View { var accessibilityFont: Bool { UIApplication.shared.preferredContentSizeCategory.isAccessibilityCategory } + var myUser: UserModel? { + if let person = siteInformation.myUserInfo?.localUserView.person { + return UserModel(from: person) + } else { + return nil + } + } + var body: some View { FancyTabBar(selection: $tabSelection, navigationSelection: $tabNavigation, dragUpGestureCallback: showAccountSwitcherDragCallback) { Group { @@ -64,20 +73,20 @@ struct ContentView: View { badgeCount: showInboxUnreadBadge ? unreadTracker.total : 0 ) } + } - ProfileView(userID: account.id) - .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) - } + 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 + ) + ) + .simultaneousGesture(accountSwitchLongPress) } SearchRoot() diff --git a/Mlem/Enums/Errors/Critical Errors.swift b/Mlem/Enums/Errors/Critical Errors.swift deleted file mode 100644 index 4aa6c3a96..000000000 --- a/Mlem/Enums/Errors/Critical Errors.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Critical Errors.swift -// Mlem -// -// Created by David Bureš on 07.05.2023. -// - -import Foundation - -enum CriticalError { - case shittyInternet -} diff --git a/Mlem/Enums/FeedType.swift b/Mlem/Enums/FeedType.swift index ef8e5dc67..dc7a3dab0 100644 --- a/Mlem/Enums/FeedType.swift +++ b/Mlem/Enums/FeedType.swift @@ -5,22 +5,40 @@ // Created by Jonathan de Jong on 12.06.2023. // -import Foundation +import SwiftUI -enum FeedType: String, Encodable, SettingsOptions { +enum FeedType: String, Encodable, SettingsOptions, AssociatedColor { var id: Self { self } var label: String { + return rawValue + } + + var description: String { switch self { - case .all: return rawValue - case .local: return rawValue - case .subscribed: return rawValue + 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" + var color: Color? { + switch self { + case .all: + return .blue + case .local: + return .green + case .subscribed: + return .red + } + } + case subscribed = "Subscribed" + case local = "Local" + case all = "All" } extension FeedType: AssociatedIcon { @@ -34,12 +52,20 @@ extension FeedType: AssociatedIcon { var iconNameFill: String { switch self { - case .all: return Icons.federatedFeed + case .all: return Icons.federatedFeedFill case .local: return Icons.localFeedFill case .subscribed: return Icons.subscribedFeedFill } } + var iconNameCircle: String { + switch self { + case .all: return Icons.federatedFeedCircle + case .local: return Icons.localFeedCircle + case .subscribed: return Icons.subscribedFeedCircle + } + } + /// Icon to use in system settings. This should be removed when the "unified symbol handling" is closed var settingsIconName: String { switch self { diff --git a/Mlem/Enums/My Vote.swift b/Mlem/Enums/My Vote.swift deleted file mode 100644 index 9d6986720..000000000 --- a/Mlem/Enums/My Vote.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// My Vote.swift -// Mlem -// -// Created by David Bureš on 23.05.2023. -// - -import Foundation - -enum MyVote: Codable, Hashable { - case upvoted, downvoted, none -} diff --git a/Mlem/Enums/SearchTab.swift b/Mlem/Enums/SearchTab.swift new file mode 100644 index 000000000..3cb9678d6 --- /dev/null +++ b/Mlem/Enums/SearchTab.swift @@ -0,0 +1,23 @@ +// +// SearchTab.swift +// Mlem +// +// Created by Sjmarf on 27/12/2023. +// + +enum SearchTab: String, CaseIterable, Identifiable { + case topResults, communities, users + + var id: Self { self } + + var label: String { + switch self { + case .topResults: + return "Top Results" + default: + return rawValue.capitalized + } + } + + static var homePageCases: [SearchTab] = [.communities, .users] +} diff --git a/Mlem/Enums/Settings/PostSortType.swift b/Mlem/Enums/Settings/PostSortType.swift index 9f0fb6824..a8aa7fae1 100644 --- a/Mlem/Enums/Settings/PostSortType.swift +++ b/Mlem/Enums/Settings/PostSortType.swift @@ -158,15 +158,15 @@ extension PostSortType: SettingsOptions { var label: String { switch self { case .newComments: - return "New comments" + return "New Comments" case .mostComments: - return "Most comments" + return "Most Comments" case .topHour: return "Hour" case .topSixHour: - return "Six hours" + return "Six Hours" case .topTwelveHour: - return "Twelve hours" + return "Twelve Hours" case .topDay: return "Day" case .topWeek: @@ -182,7 +182,7 @@ extension PostSortType: SettingsOptions { case .topYear: return "Year" case .topAll: - return "All time" + return "All Time" default: return rawValue } diff --git a/Mlem/Enums/User/UserViewTab.swift b/Mlem/Enums/User/UserViewTab.swift index 09865f548..b17fb9979 100644 --- a/Mlem/Enums/User/UserViewTab.swift +++ b/Mlem/Enums/User/UserViewTab.swift @@ -8,15 +8,11 @@ import Foundation enum UserViewTab: String, CaseIterable, Identifiable { - case overview, comments, posts, saved + case overview, comments, posts, communities, saved var id: Self { self } var label: String { rawValue.capitalized } - - var onlyShowInOwnProfile: Bool { - self == UserViewTab.saved - } } diff --git a/Mlem/Extensions/Date/Date+RelativeTime.swift b/Mlem/Extensions/Date/Date+RelativeTime.swift index e46e07256..3561d0765 100644 --- a/Mlem/Extensions/Date/Date+RelativeTime.swift +++ b/Mlem/Extensions/Date/Date+RelativeTime.swift @@ -9,9 +9,9 @@ import SwiftUI extension Date { // Returns strings like "3 seconds ago" and "10 days ago" - func getRelativeTime(date: Date) -> String { + func getRelativeTime(date: Date, unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .full) -> String { let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full + formatter.unitsStyle = unitsStyle return formatter.localizedString(for: self, relativeTo: date) } diff --git a/Mlem/Extensions/Mocks/APICommunityAggregates+Mock.swift b/Mlem/Extensions/Mocks/APICommunityAggregates+Mock.swift index b7e0241bf..7be6d9f64 100644 --- a/Mlem/Extensions/Mocks/APICommunityAggregates+Mock.swift +++ b/Mlem/Extensions/Mocks/APICommunityAggregates+Mock.swift @@ -12,14 +12,14 @@ extension APICommunityAggregates { static func mock( id: Int = 0, communityId: Int = 0, - subscribers: Int = 0, - posts: Int = 0, - comments: Int = 0, + subscribers: Int = 42349, + posts: Int = 300, + comments: Int = 5000, published: Date = .mock, - usersActiveDay: Int = 0, - usersActiveWeek: Int = 0, - usersActiveMonth: Int = 0, - usersActiveHalfYear: Int = 0 + usersActiveDay: Int = 3040, + usersActiveWeek: Int = 20044, + usersActiveMonth: Int = 50403, + usersActiveHalfYear: Int = 73032 ) -> APICommunityAggregates { .init( id: id, diff --git a/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift b/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift index 034ad2c81..39b2217be 100644 --- a/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift +++ b/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift @@ -29,20 +29,15 @@ struct HandleLemmyLinksDisplay: ViewModifier { .navigationDestination(for: AppRoute.self) { route in switch route { case let .community(community): - FeedView(community: community, feedType: .all) + CommunityView(community: community) .environmentObject(appState) .environmentObject(filtersTracker) .environmentObject(quickLookState) case let .communityLinkWithContext(context): - FeedView(community: context.community, feedType: context.feedType) + FeedParentView(community: context.community, feedType: context.feedType) .environmentObject(appState) .environmentObject(filtersTracker) .environmentObject(quickLookState) - case let .communitySidebarLinkWithContext(context): - CommunitySidebarView( - community: context.community - ) - .environmentObject(filtersTracker) case let .apiPostView(post): let postModel = PostModel(from: post) let postTracker = PostTracker( @@ -61,14 +56,15 @@ struct HandleLemmyLinksDisplay: ViewModifier { LazyLoadExpandedPost(post: post) .environmentObject(quickLookState) case let .apiPerson(user): - UserView(userID: user.id) + UserView(user: UserModel(from: user)) + .environmentObject(appState) .environmentObject(quickLookState) - case let .userProfile(user): - UserView(userID: user.userId) + case let .userProfile(user, communityContext): + UserView(user: user, communityContext: communityContext) .environmentObject(appState) .environmentObject(quickLookState) case let .postLinkWithContext(post): - ExpandedPost(post: post.post, scrollTarget: post.scrollTarget) + ExpandedPost(post: post.post, community: post.community, scrollTarget: post.scrollTarget) .environmentObject(post.postTracker) .environmentObject(appState) .environmentObject(quickLookState) @@ -76,10 +72,6 @@ struct HandleLemmyLinksDisplay: ViewModifier { case let .lazyLoadPostLinkWithContext(post): LazyLoadExpandedPost(post: post.post, scrollTarget: post.scrollTarget) .environmentObject(quickLookState) - case let .userModeratorLink(user): - UserModeratorView(userDetails: user.user, moderatedCommunities: user.moderatedCommunities) - .environmentObject(appState) - .environmentObject(quickLookState) case let .settings(page): settingsDestination(for: page) case let .aboutSettings(page): diff --git a/Mlem/Extensions/View Modifiers/View+NavigationBarColor.swift b/Mlem/Extensions/View Modifiers/View+NavigationBarColor.swift index 3aae5e814..196b899cb 100644 --- a/Mlem/Extensions/View Modifiers/View+NavigationBarColor.swift +++ b/Mlem/Extensions/View Modifiers/View+NavigationBarColor.swift @@ -20,7 +20,7 @@ struct NavigationBarColorModifier: ViewModifier { } else { content .toolbarBackground(Color.systemBackground, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) + .toolbarBackground(visibility, for: .navigationBar) } } } diff --git a/Mlem/Icons.swift b/Mlem/Icons.swift index bab63791a..7459b350d 100644 --- a/Mlem/Icons.swift +++ b/Mlem/Icons.swift @@ -63,12 +63,18 @@ struct Icons { static let largePostFill: String = "text.below.photo.fill" // feeds - static let federatedFeed: String = "circle.hexagongrid.circle" - static let federatedFeedFill: String = "circle.hexagongrid.circle.fill" - static let localFeed: String = "house.circle" - static let localFeedFill: String = "house.circle.fill" - static let subscribedFeed: String = "newspaper.circle" - static let subscribedFeedFill: String = "newspaper.circle.fill" + static let federatedFeed: String = "circle.hexagongrid" + static let federatedFeedFill: String = "circle.hexagongrid.fill" + static let federatedFeedCircle: String = "circle.hexagongrid.circle.fill" + static let localFeed: String = "house" + static let localFeedFill: String = "house.fill" + static let localFeedCircle: String = "house.circle.fill" + static let subscribedFeed: String = "newspaper" + static let subscribedFeedFill: String = "newspaper.fill" + static let subscribedFeedCircle: String = "newspaper.circle.fill" + static let savedFeed: String = "bookmark" + static let savedFeedFill: String = "bookmark.fill" + static let savedFeedCircle: String = "bookmark.circle.fill" // sort types static let activeSort: String = "popcorn" @@ -134,8 +140,11 @@ struct Icons { static let updated: String = "clock.arrow.2.circlepath" static let favorite: String = "star" static let favoriteFill: String = "star.fill" + static let unfavorite: String = "star.slash" + static let unfavoriteFill: String = "star.slash.fill" static let personFill: String = "person.fill" static let close: String = "multiply" + static let cakeDay: String = "birthday.cake" // end of feed static let endOfFeedHobbit: String = "figure.climbing" @@ -153,10 +162,12 @@ struct Icons { static let filter: String = "line.3.horizontal.decrease.circle" static let filterFill: String = "line.3.horizontal.decrease.circle.fill" static let menu: String = "ellipsis" + static let menuCircle: String = "ellipsis.circle" static let `import`: String = "square.and.arrow.down" static let attachment: String = "paperclip" static let edit: String = "pencil" static let delete: String = "trash" + static let copy: String = "doc.on.doc" // settings static let upvoteOnSave: String = "arrow.up.heart" diff --git a/Mlem/MlemApp.swift b/Mlem/MlemApp.swift index fce3d4a03..921b71e03 100644 --- a/Mlem/MlemApp.swift +++ b/Mlem/MlemApp.swift @@ -75,32 +75,32 @@ struct MlemApp: App { guard accountsTracker.savedAccounts.first != nil else { return } // Subscribed Feed - let homeIcon = UIApplicationShortcutIcon(systemImageName: "house") + let subscribedIcon = UIApplicationShortcutIcon(systemImageName: Icons.subscribedFeed) let subscribedFeedItem = UIApplicationShortcutItem( type: FeedType.subscribed.rawValue, localizedTitle: "Subscribed", localizedSubtitle: nil, - icon: homeIcon, + icon: subscribedIcon, userInfo: nil ) // Local Feed - let officeIcon = UIApplicationShortcutIcon(systemImageName: "building.2") + let localIcon = UIApplicationShortcutIcon(systemImageName: Icons.localFeed) let localFeedItem = UIApplicationShortcutItem( type: FeedType.local.rawValue, localizedTitle: "Local", localizedSubtitle: nil, - icon: officeIcon, + icon: localIcon, userInfo: nil ) // All Feed - let cloudIcon = UIApplicationShortcutIcon(systemImageName: "cloud") + let allIcon = UIApplicationShortcutIcon(systemImageName: Icons.federatedFeed) let allFeedItem = UIApplicationShortcutItem( type: FeedType.all.rawValue, localizedTitle: "All", localizedSubtitle: nil, - icon: cloudIcon, + icon: allIcon, userInfo: nil ) diff --git a/Mlem/Models/Access Token.swift b/Mlem/Models/Access Token.swift deleted file mode 100644 index e88a77392..000000000 --- a/Mlem/Models/Access Token.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Access Token.swift -// Mlem -// -// Created by David Bureš on 06.05.2023. -// - -import Foundation - -class AccessTokenTracker: ObservableObject { - @Published var token: String = "" -} diff --git a/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift b/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift index 44e2fe260..91c34e799 100644 --- a/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift +++ b/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift @@ -9,14 +9,28 @@ import Foundation import SwiftUI extension CommunityModel { - func subscribeMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) throws -> MenuFunction { + 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 subscribeMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) throws -> StandardMenuFunction { guard let subscribed else { throw CommunityError.noData } - return .standardMenuFunction( + return .init( text: subscribed ? "Unsubscribe" : "Subscribe", imageName: subscribed ? Icons.unsubscribe : Icons.subscribe, - destructiveActionPrompt: subscribed ? "Are you sure you want to unsubscribe from \(name)?" : nil, + destructiveActionPrompt: subscribed ? "Are you sure you want to unsubscribe from \(name!)?" : nil, enabled: true, callback: { Task { @@ -31,6 +45,24 @@ extension CommunityModel { ) } + func favoriteMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> StandardMenuFunction { + return .init( + text: favorited ? "Unfavorite" : "Favorite", + imageName: favorited ? Icons.unfavorite : Icons.favorite, + destructiveActionPrompt: favorited ? "Really unfavorite \(community.name)?" : nil, + enabled: true + ) { + Task { + do { + var new = self + try await new.toggleFavorite(callback) + } catch { + errorHandler.handle(error) + } + } + } + } + func blockMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) throws -> MenuFunction { guard let blocked else { throw CommunityError.noData @@ -53,11 +85,28 @@ extension CommunityModel { ) } - func menuFunctions(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> [MenuFunction] { + func menuFunctions( + _ callback: @escaping (_ item: Self) -> Void = { _ in }, + editorTracker: EditorTracker? = nil, + postTracker: PostTracker? = nil + ) -> [MenuFunction] { var functions: [MenuFunction] = .init() + if let editorTracker { + functions.append(newPostMenuFunction(editorTracker: editorTracker, postTracker: postTracker)) + } if let function = try? subscribeMenuFunction(callback) { - functions.append(function) + functions.append(.standard(function)) } + functions.append(.standard(favoriteMenuFunction(callback))) + functions.append( + .standardMenuFunction( + text: "Copy Name", + imageName: Icons.copy, + destructiveActionPrompt: nil, + enabled: true, + callback: copyFullyQualifiedName + ) + ) functions.append(.shareMenuFunction(url: communityUrl)) if let function = try? blockMenuFunction(callback) { functions.append(function) diff --git a/Mlem/Models/Content/Community/CommunityModel+SwipeActions.swift b/Mlem/Models/Content/Community/CommunityModel+SwipeActions.swift index 03c69ddd2..e0785ea83 100644 --- a/Mlem/Models/Content/Community/CommunityModel+SwipeActions.swift +++ b/Mlem/Models/Content/Community/CommunityModel+SwipeActions.swift @@ -27,7 +27,7 @@ extension CommunityModel { hapticManager.play(haptic: .lightSuccess, priority: .low) if subscribed, let confirmDestructive { - if case .standard(let function) = try? subscribeMenuFunction(callback) { + if let function = try? subscribeMenuFunction(callback) { confirmDestructive(function) } } else { @@ -43,14 +43,44 @@ extension CommunityModel { ) } + func favoriteSwipeAction( + _ callback: @escaping (_ item: Self) -> Void = { _ in }, + confirmDestructive: ((StandardMenuFunction) -> Void)? = nil + ) -> SwipeAction { + let (emptySymbolName, fullSymbolName) = (favorited) + ? (Icons.unfavorite, Icons.unfavoriteFill) + : (Icons.favorite, Icons.favoriteFill) + return SwipeAction( + symbol: .init(emptyName: emptySymbolName, fillName: fullSymbolName), + color: favorited ? .red : .blue, + action: { + Task { + hapticManager.play(haptic: .lightSuccess, priority: .low) + + if favorited, let confirmDestructive { + confirmDestructive(favoriteMenuFunction(callback)) + } else { + var new = self + try await new.toggleFavorite(callback) + } + } + } + ) + } + func swipeActions( _ callback: @escaping (_ item: Self) -> Void = { _ in }, confirmDestructive: ((StandardMenuFunction) -> Void)? = nil ) -> SwipeConfiguration { var trailingActions: [SwipeAction] = [] - if let action = try? subscribeSwipeAction(callback, confirmDestructive: confirmDestructive) { - trailingActions.append(action) + let subscribeAction = try? subscribeSwipeAction(callback, confirmDestructive: confirmDestructive) + let favoriteAction = favoriteSwipeAction(callback, confirmDestructive: confirmDestructive) + + if let subscribeAction { + trailingActions.append(subscribeAction) } + trailingActions.append(favoriteAction) + return SwipeConfiguration(leadingActions: [], trailingActions: trailingActions) } } diff --git a/Mlem/Models/Content/Community/CommunityModel.swift b/Mlem/Models/Content/Community/CommunityModel.swift index 9f4fd9b27..256e2fd73 100644 --- a/Mlem/Models/Content/Community/CommunityModel.swift +++ b/Mlem/Models/Content/Community/CommunityModel.swift @@ -6,81 +6,122 @@ // import Dependencies -import Foundation +import SwiftUI struct CommunityModel { @Dependency(\.apiClient) private var apiClient @Dependency(\.errorHandler) var errorHandler @Dependency(\.hapticManager) var hapticManager @Dependency(\.communityRepository) var communityRepository + @Dependency(\.notifier) var notifier + @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker enum CommunityError: Error { case noData } + struct ActiveUserCount { + let sixMonths: Int + let month: Int + let week: Int + let day: Int + } + @available(*, deprecated, message: "Use attributes of the CommunityModel directly instead.") - var community: APICommunity + var community: APICommunity! // Ids - let communityId: Int - let instanceId: Int + var communityId: Int! + var instanceId: Int! // Text - let name: String - let displayName: String - let description: String? + var name: String! + var displayName: String! + var description: String? // Images - let avatar: URL? - let banner: URL? + var avatar: URL? + var banner: URL? // State - var nsfw: Bool - var local: Bool - var removed: Bool - var deleted: Bool - var hidden: Bool - var postingRestrictedToMods: Bool + var nsfw: Bool! + var local: Bool! + var removed: Bool! + var deleted: Bool! + var hidden: Bool! + var postingRestrictedToMods: Bool! + var favorited: Bool! + + // Dates + var creationDate: Date! + var updatedDate: Date? + + // URLs + var communityUrl: URL! // From APICommunityView var blocked: Bool? var subscribed: Bool? var subscriberCount: Int? + var postCount: Int? + var commentCount: Int? + var activeUserCount: ActiveUserCount? - // Dates - let creationDate: Date - let updatedDate: Date? - - // URLs - let communityUrl: URL - - // These values are only available via GetCommunityResponse + // From GetCommunityResponse var site: APISite? - var moderators: [APICommunityModeratorView]? + var moderators: [UserModel]? var discussionLanguages: [Int]? var defaultPostLanguage: Int? init(from response: GetCommunityResponse) { - self.init(from: response.communityView) - self.site = response.site - self.moderators = response.moderators - self.discussionLanguages = response.discussionLanguages - self.defaultPostLanguage = response.defaultPostLanguage + self.update(with: response) } init(from response: CommunityResponse) { - self.init(from: response.communityView) - self.discussionLanguages = response.discussionLanguages + self.update(with: response) } init(from communityView: APICommunityView) { - self.init(from: communityView.community) - self.subscriberCount = communityView.counts.subscribers + self.update(with: communityView) + } + + init(from community: APICommunity, subscribed: Bool? = nil) { + self.update(with: community) + if let subscribed { + self.subscribed = subscribed + } + } + + mutating func update(with response: CommunityResponse) { + self.discussionLanguages = response.discussionLanguages + self.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) + } + + mutating func update(with communityView: APICommunityView) { self.subscribed = communityView.subscribed.isSubscribed self.blocked = communityView.blocked + + self.subscriberCount = communityView.counts.subscribers + self.postCount = communityView.counts.posts + self.commentCount = communityView.counts.comments + self.activeUserCount = .init( + sixMonths: communityView.counts.usersActiveHalfYear, + month: communityView.counts.usersActiveMonth, + week: communityView.counts.usersActiveWeek, + day: communityView.counts.usersActiveDay + ) + self.update(with: communityView.community) } - init(from community: APICommunity, subscribed: Bool? = nil) { + mutating func update(with community: APICommunity) { self.community = community self.communityId = community.id @@ -105,41 +146,72 @@ struct CommunityModel { self.communityUrl = community.actorId - self.subscribed = subscribed + @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker + self.favorited = favoriteCommunitiesTracker.isFavorited(community) } - mutating func toggleSubscribe(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async throws { + func toggleSubscribe(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async throws { + var new = self guard let subscribed, let subscriberCount else { throw CommunityError.noData } - self.subscribed = !subscribed + new.subscribed = !subscribed if subscribed { - self.subscriberCount = subscriberCount + 1 + new.subscriberCount = subscriberCount - 1 + if new.favorited { + favoriteCommunitiesTracker.unfavorite(community) + } } else { - self.subscriberCount = subscriberCount - 1 + new.subscriberCount = subscriberCount + 1 } - RunLoop.main.perform { [self] in - callback(self) + RunLoop.main.perform { [new] in + callback(new) } do { let response = try await apiClient.followCommunity(id: communityId, shouldFollow: !subscribed) - RunLoop.main.perform { - callback(CommunityModel(from: response)) + new.update(with: response) + RunLoop.main.perform { [new] in + callback(new) } } catch { hapticManager.play(haptic: .failure, priority: .high) - let phrase = (self.subscribed ?? false) ? "unsubscribe from" : "subscribe to" + let phrase = (new.subscribed ?? false) ? "unsubscribe from" : "subscribe to" errorHandler.handle( .init(title: "Failed to \(phrase) community", style: .toast, underlyingError: error) ) } } - mutating func toggleBlock(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async throws { + func toggleFavorite(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async throws { + var new = self + new.favorited.toggle() + if favorited { + favoriteCommunitiesTracker.unfavorite(community) + } else { + favoriteCommunitiesTracker.favorite(community) + if let subscribed, !subscribed { + try await self.toggleSubscribe { [self] community in + var community = community + if !(community.subscribed ?? true) { + print("Subscribe failed, unfavoriting...") + community.favorited = false + favoriteCommunitiesTracker.unfavorite(self.community) + } + callback(new) + } + } + } + RunLoop.main.perform { [new] in + callback(new) + } + } + + func toggleBlock(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async throws { + var new = self guard let blocked else { throw CommunityError.noData } - self.blocked = !blocked + new.blocked = !blocked RunLoop.main.perform { [self] in callback(self) } @@ -150,8 +222,9 @@ struct CommunityModel { } else { response = try await communityRepository.unblockCommunity(id: communityId) } - RunLoop.main.perform { - callback(CommunityModel(from: response.communityView)) + new.update(with: response.communityView) + RunLoop.main.perform { [new] in + callback(new) } } catch { hapticManager.play(haptic: .failure, priority: .high) @@ -162,6 +235,31 @@ struct CommunityModel { ) } } + + var fullyQualifiedName: String? { + if let host = self.communityUrl.host() { + return "\(name!)@\(host)" + } + return nil + } + + func copyFullyQualifiedName() { + let pasteboard = UIPasteboard.general + if let fullyQualifiedName { + pasteboard.string = "!\(fullyQualifiedName)" + Task { + await notifier.add(.success("Community Name Copied")) + } + } else { + Task { + await notifier.add(.failure("Failed to copy")) + } + } + } + + static func mock() -> CommunityModel { + return .init(from: GetCommunityResponse.mock()) + } } extension CommunityModel: Identifiable { @@ -177,8 +275,9 @@ extension CommunityModel: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(uid) hasher.combine(subscribed) + hasher.combine(favorited) hasher.combine(subscriberCount) hasher.combine(blocked) - hasher.combine(moderators?.map(\.moderator.id) ?? []) + hasher.combine(moderators?.map(\.id) ?? []) } } diff --git a/Mlem/Models/Content/User/UserModel+MenuFunctions.swift b/Mlem/Models/Content/User/UserModel+MenuFunctions.swift index 34b6ed3ec..315bcecdc 100644 --- a/Mlem/Models/Content/User/UserModel+MenuFunctions.swift +++ b/Mlem/Models/Content/User/UserModel+MenuFunctions.swift @@ -25,8 +25,19 @@ extension UserModel { func menuFunctions(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> [MenuFunction] { var functions: [MenuFunction] = .init() + functions.append( + .standardMenuFunction( + text: "Copy Username", + imageName: Icons.copy, + destructiveActionPrompt: nil, + enabled: true, + callback: copyFullyQualifiedUsername + ) + ) functions.append(.shareMenuFunction(url: profileUrl)) - functions.append(blockMenuFunction(callback)) + if siteInformation.myUserInfo?.localUserView.person.id != userId { + functions.append(blockMenuFunction(callback)) + } return functions } } diff --git a/Mlem/Models/Content/User/UserModel.swift b/Mlem/Models/Content/User/UserModel.swift index 2630b86b5..74bccc689 100644 --- a/Mlem/Models/Content/User/UserModel.swift +++ b/Mlem/Models/Content/User/UserModel.swift @@ -12,7 +12,9 @@ import SwiftUI struct UserModel { @Dependency(\.personRepository) var personRepository @Dependency(\.hapticManager) var hapticManager + @Dependency(\.siteInformation) var siteInformation @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier @available(*, deprecated, message: "Use attributes of the UserModel directly instead.") var person: APIPerson @@ -36,8 +38,8 @@ struct UserModel { let local: Bool let deleted: Bool let isBot: Bool - let isAdmin: Bool - + var blocked: Bool + // Dates let creationDate: Date let updatedDate: Date? @@ -47,11 +49,13 @@ struct UserModel { let profileUrl: URL let sharedInboxUrl: URL? - // These values are nil if the UserModel was created from an APIPerson and not an APIPersonView + // From APIPersonView + var isAdmin: Bool? var postCount: Int? var commentCount: Int? - var blocked: Bool + // From GetPersonDetailsResponse + var moderatedCommunities: [CommunityModel]? static let developerNames = [ "https://lemmy.tespia.org/u/navi", @@ -62,12 +66,26 @@ struct UserModel { "https://lemmy.ml/u/sjmarf" ] + /// Creates a UserModel from an GetPersonDetailsResponse + /// - Parameter response: GetPersonDetailsResponse to create a UserModel representation of + init(from response: GetPersonDetailsResponse) { + self.init(from: response.personView) + self.moderatedCommunities = response.moderates.map { CommunityModel(from: $0.community) } + } + /// Creates a UserModel from an APIPersonView /// - Parameter apiPersonView: APIPersonView to create a UserModel representation of init(from personView: APIPersonView) { self.init(from: personView.person) + self.postCount = personView.counts.postCount self.commentCount = personView.counts.commentCount + + // TODO: 0.18 Deprecation + @Dependency(\.siteInformation) var siteInformation + if (siteInformation.version ?? .infinity) > .init("0.19.0") { + self.isAdmin = personView.isAdmin + } } /// Creates a UserModel from an APIPerson. Note that using this initialiser nullifies count values, since @@ -88,7 +106,8 @@ struct UserModel { self.local = person.local self.deleted = person.deleted self.isBot = person.botAccount - self.isAdmin = person.admin ?? false // is nil on Beehaw + + self.isAdmin = person.admin self.creationDate = person.published self.updatedDate = person.updated @@ -115,7 +134,7 @@ struct UserModel { if let post = postContext, post.creatorId == self.userId { ret.append(.op) } - if isAdmin { + if isAdmin ?? false { ret.append(.admin) } if UserModel.developerNames.contains(profileUrl.absoluteString) { @@ -125,7 +144,7 @@ struct UserModel { ret.append(.moderator) } else if let community = communityContext, let moderators = community.moderators, - moderators.contains(where: { $0.moderator.id == userId }) { + moderators.contains(where: { $0.userId == userId }) { ret.append(.moderator) } if isBot { @@ -153,6 +172,31 @@ struct UserModel { errorHandler.handle(error) } } + + static func mock() -> UserModel { + return self.init(from: APIPerson.mock()) + } + + var fullyQualifiedUsername: String? { + if let host = self.profileUrl.host() { + return "\(name)@\(host)" + } + return nil + } + + func copyFullyQualifiedUsername() { + let pasteboard = UIPasteboard.general + if let fullyQualifiedUsername { + pasteboard.string = "@\(fullyQualifiedUsername)" + Task { + await notifier.add(.success("Username Copied")) + } + } else { + Task { + await notifier.add(.failure("Failed to copy")) + } + } + } } extension UserModel: Identifiable { @@ -168,5 +212,7 @@ extension UserModel: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(uid) hasher.combine(blocked) + hasher.combine(postCount) + hasher.combine(commentCount) } } diff --git a/Mlem/Models/User-Interactable/Menu Function.swift b/Mlem/Models/Menu Function.swift similarity index 100% rename from Mlem/Models/User-Interactable/Menu Function.swift rename to Mlem/Models/Menu Function.swift diff --git a/Mlem/Models/Navigation Contexts/Community Sidebar Link.swift b/Mlem/Models/Navigation Contexts/Community Sidebar Link.swift deleted file mode 100644 index 6b4b056e9..000000000 --- a/Mlem/Models/Navigation Contexts/Community Sidebar Link.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Community Sidebar Link.swift -// Mlem -// -// Created by Jake Shirley on 7/1/23. -// - -import Foundation -import SwiftUI - -struct CommunitySidebarLinkWithContext: Equatable, Identifiable, Hashable { - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - var id: String { community.communityId.description } - - let community: CommunityModel -} diff --git a/Mlem/Models/Navigation Contexts/Post Link.swift b/Mlem/Models/Navigation Contexts/Post Link.swift index 0cca31972..50494b866 100644 --- a/Mlem/Models/Navigation Contexts/Post Link.swift +++ b/Mlem/Models/Navigation Contexts/Post Link.swift @@ -20,6 +20,7 @@ struct PostLinkWithContext: Equatable, Identifiable, Hashable { var id: Int { post.postId } let post: PostModel + var community: CommunityModel? let postTracker: PostTracker var scrollTarget: Int? } diff --git a/Mlem/Models/Navigation Contexts/User Moderator Link.swift b/Mlem/Models/Navigation Contexts/User Moderator Link.swift deleted file mode 100644 index dedc89278..000000000 --- a/Mlem/Models/Navigation Contexts/User Moderator Link.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Moderator User Link.swift -// Mlem -// -// Created by Jake Shirley on 6/30/23. -// - -import Foundation -import SwiftUI - -struct UserModeratorLink: Equatable, Identifiable, Hashable { - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - var id: Int { user.person.id } - - let user: APIPersonView - let moderatedCommunities: [APICommunityModeratorView] -} diff --git a/Mlem/Models/Trackers/ContentTracker.swift b/Mlem/Models/Trackers/ContentTracker.swift index ae3855996..d69a45891 100644 --- a/Mlem/Models/Trackers/ContentTracker.swift +++ b/Mlem/Models/Trackers/ContentTracker.swift @@ -11,7 +11,6 @@ import Nuke class ContentTracker: ObservableObject { // dependencies - @Dependency(\.apiClient) var apiClient @Dependency(\.errorHandler) var errorHandler @Dependency(\.hapticManager) var hapticManager diff --git a/Mlem/Models/Trackers/Post Tracker.swift b/Mlem/Models/Trackers/Post Tracker.swift index 77293588a..cc4ec6f91 100644 --- a/Mlem/Models/Trackers/Post Tracker.swift +++ b/Mlem/Models/Trackers/Post Tracker.swift @@ -21,8 +21,17 @@ class PostTracker: ObservableObject { // dependencies @Dependency(\.postRepository) var postRepository @Dependency(\.apiClient) var apiClient - @Dependency(\.errorHandler) var errorHandler @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 @@ -31,6 +40,8 @@ class PostTracker: ObservableObject { // 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) @@ -41,6 +52,9 @@ class PostTracker: ObservableObject { private var hasReachedEnd: Bool = false + var filter: (PostModel) -> PostFilterReason? + var handleError: ((Error) -> Void)! + // prefetching private let prefetcher = ImagePrefetcher( pipeline: ImagePipeline.shared, @@ -52,66 +66,93 @@ class PostTracker: ObservableObject { shouldPerformMergeSorting: Bool = true, internetSpeed: InternetSpeed, initialItems: [PostModel] = .init(), - upvoteOnSave: Bool + 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 loadNextPage( - communityId: Int?, - sort: PostSortType?, - type: FeedType, - filtering: @escaping (_: PostModel) -> PostFilterReason? = { _ in nil } - ) async throws { - 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 postRepository.loadPage( - communityId: communityId, + 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: sort, - type: type, + 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 - newPosts = posts + // 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 - if newPosts.isEmpty { - hasReachedEnd = true - } else if let currentCursor, cursor == currentCursor { - hasReachedEnd = true - } else { - await add(newPosts, filtering: filtering) - page += 1 - currentCursor = cursor + // 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() } - } 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) } - - // don't preload filtered images - preloadImages(filterItems(items: newPosts, with: filtering)) } /// Loads a single post and adds it to the tracker @@ -124,13 +165,10 @@ class PostTracker: ObservableObject { return newPost } - func refresh( - communityId: Int?, - sort: PostSortType?, - feedType: FeedType, - clearBeforeFetch: Bool = false, - filtering: @escaping (_: PostModel) -> PostFilterReason? = { _ in nil } - ) async throws { + @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() } @@ -138,31 +176,39 @@ class PostTracker: ObservableObject { page = 1 currentCursor = nil - let (newPosts, cursor) = try await postRepository.loadPage( - communityId: communityId, - page: page, - cursor: currentCursor, - sort: sort, - type: feedType, - limit: internetSpeed.pageSize - ) - - currentCursor = cursor - await reset(with: newPosts, cursor: cursor, filteredWith: filtering) + 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 - /// - filtering: filter to apply before adding items /// - preload: true if the new post's image should be preloaded func add( _ newItems: [PostModel], - filtering: @escaping (_: PostModel) -> PostFilterReason? = { _ in nil }, preload: Bool = false ) { - let accepted = dedupedItems(from: filterItems(items: newItems, with: filtering)) + let accepted = dedupedItems(from: filterItems(items: newItems)) if preload { preloadImages(newItems) } @@ -182,8 +228,7 @@ class PostTracker: ObservableObject { @MainActor func reset( with newItems: [PostModel] = .init(), - cursor: String? = nil, - filteredWith filter: @escaping (_: PostModel) -> PostFilterReason? = { _ in nil } + cursor: String? = nil ) { hasReachedEnd = false page = newItems.isEmpty ? 1 : 2 @@ -192,7 +237,7 @@ class PostTracker: ObservableObject { hiddenItems.removeAll() } ids = .init(minimumCapacity: 1000) - items = dedupedItems(from: filterItems(items: newItems, with: filter)) + items = dedupedItems(from: filterItems(items: newItems)) } /// Determines whether the tracker should load more items @@ -480,11 +525,10 @@ class PostTracker: ObservableObject { } private func filterItems( - items: [PostModel], - with filtering: @escaping (_: PostModel) -> PostFilterReason? = { _ in nil } + items: [PostModel] ) -> [PostModel] { items.filter { item in - if let reason = filtering(item) { + if let reason = self.filter(item) { self.hiddenItems[reason] = self.hiddenItems[reason, default: 0] + 1 return false } diff --git a/Mlem/Models/Trackers/SiteInformationTracker.swift b/Mlem/Models/Trackers/SiteInformationTracker.swift index 33b07bfd4..0fd58be25 100644 --- a/Mlem/Models/Trackers/SiteInformationTracker.swift +++ b/Mlem/Models/Trackers/SiteInformationTracker.swift @@ -23,12 +23,15 @@ class SiteInformationTracker: ObservableObject { version = account.siteVersion Task { do { + let response = try await apiClient.loadSiteInformation() enableDownvotes = response.siteView.localSite.enableDownvotes version = SiteVersion(response.version) if version != account.siteVersion { let avatarUrl = response.myUser?.localUserView.person.avatarUrl - accountsTracker.update(with: .init(from: account, avatarUrl: avatarUrl, siteVersion: version)) + DispatchQueue.main.async { + self.accountsTracker.update(with: .init(from: account, avatarUrl: avatarUrl, siteVersion: self.version)) + } } myUserInfo = response.myUser allLanguages = response.allLanguages diff --git a/Mlem/Models/User-Interactable/Comment.swift b/Mlem/Models/User-Interactable/Comment.swift deleted file mode 100644 index 5f7ec745a..000000000 --- a/Mlem/Models/User-Interactable/Comment.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// Comment.swift -// Mlem -// -// Created by David Bureš on 25.03.2022. -// - -import Foundation -import SwiftUI - -struct Comment: Codable, Identifiable, Hashable { - let id: Int - let postID: Int - let creatorID: Int - // let postName: String - let parentID: Int? - var content: String - var removed: Bool - // let read: Bool - let published: Date - var deleted: Bool? - let updated: String? - let apID: URL - let local: Bool - let communityID: Int - let communityLocal: Bool - let communityName: String - let communityIcon: URL? - let communityHideFromAll: Bool - // let creatorBannedFromCommunity: Bool? - let creatorPublished: String - // let creatorTags: CreatorTags_Comment? - // let creatorCommunityTags: JSONNull? - var score: Int - var upvotes: Int - var downvotes: Int - var myVote: MyVote - // let hotRank: Int - // let hotRankActive: Int? - let saved: Bool? - // let subscribed: Bool? - // let userID, myVote: JSONNull? - - let author: User - - let childCount: Int? - var children: [Comment] - - func insertReply(_ reply: Comment) -> Comment { - if id == reply.parentID { - var result = self - result.children.append(reply) - return result - } else if children.isEmpty { - return self - } else { - var result = self - result.children = children.map { $0.insertReply(reply) } - return result - } - } - - /// Locate the reply with the matching ID in the Comment tree - /// and replace it with the specified reply. Note that this - /// cannot change the parent of the reply! - func replaceReply(_ reply: Comment) -> Comment { - if id == reply.id { - assert(parentID == reply.parentID) - return reply - } else if children.isEmpty { - return self - } else { - var result = self - result.children = children.map { $0.replaceReply(reply) } - return result - } - } - - /// Remove the reply with the specified ID from the Comment tree, - /// along with all of its descendents. - func removeReply(id: Int) -> Comment? { - if self.id == id { - return nil - } else if children.isEmpty { - return self - } else { - var result = self - result.children = children.compactMap { $0.removeReply(id: id) } - return result - } - } -} diff --git a/Mlem/Models/User-Interactable/Community.swift b/Mlem/Models/User-Interactable/Community.swift deleted file mode 100644 index a95ca447a..000000000 --- a/Mlem/Models/User-Interactable/Community.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Community.swift -// Mlem -// -// Created by David Bureš on 07.05.2023. -// - -import Foundation - -struct Community: Identifiable, Codable, Equatable, Hashable { - let id: Int - - let name: String - let title: String? - let description: String? - let icon: URL? - let banner: URL? - - let createdAt: String? - let updatedAt: String? - - let actorID: URL - - let local: Bool - - let deleted: Bool - let nsfw: Bool - - var details: CommunityDetails? -} - -struct CommunityDetails: Codable, Equatable, Hashable { - var isSubscribed: Bool - - let numberOfSubscribers: Int - let numberOfPosts: Int - let numberOfActiveUsersOverall: Int? - - let moderators: [User] -} diff --git a/Mlem/Models/User-Interactable/Post.swift b/Mlem/Models/User-Interactable/Post.swift deleted file mode 100644 index ddd5aaf72..000000000 --- a/Mlem/Models/User-Interactable/Post.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// Post.swift -// Mlem -// -// Created by David Bureš on 25.03.2022. -// - -import Foundation -import SwiftUI - -struct Post: Codable, Identifiable, Equatable, Hashable { - // This is here to make Post equatable - static func == (lhs: Post, rhs: Post) -> Bool { - lhs.hashValue == rhs.hashValue - } - - let id: Int - var name: String - var url: URL? - let body: String? - let removed, locked: Bool? - let published: Date - let updated: String? - let deleted, nsfw, stickied: Bool - let embedTitle, embedDescription, embedHTML: String? - let thumbnailURL: URL? - let apID: String - let local: Bool - // let creatorActorID: String - // let creatorLocal: Bool - let postedAt: String - // let creatorTags: CreatorTags? - // let creatorCommunityTags: JSONNull? - // let banned, bannedFromCommunity: Bool - var numberOfComments, score, upvotes, downvotes: Int - var myVote: MyVote - let hotRank, hotRankActive: Int? - let newestActivityTime: String? - // let userID: Int? - // let subscribed: Bool? - // let read: Bool? - - var saved: Bool - var read: Bool - - var unreadComments: Int - - let author: User - - let community: Community - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(updated) - hasher.combine(deleted) - hasher.combine(postedAt) - hasher.combine(myVote) - hasher.combine(saved) - hasher.combine(read) - hasher.combine(unreadComments) - } -} diff --git a/Mlem/Models/User-Interactable/User.swift b/Mlem/Models/User-Interactable/User.swift deleted file mode 100644 index e18807044..000000000 --- a/Mlem/Models/User-Interactable/User.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// User.swift -// Mlem -// -// Created by David Bureš on 25.03.2022. -// - -import Foundation -import SwiftUI - -struct User: Codable, Identifiable, Hashable { - let id: Int - - let name: String - let displayName: String? - - let avatarLink: URL? - let bannerLink: URL? - let inboxLink: URL? - - let bio: String? - - let banned: Bool - - let actorID: URL - - let local: Bool - let deleted: Bool - let admin: Bool - - let bot: Bool - - let onInstanceID: Int - - var details: UserDetails? -} - -struct UserDetails: Codable, Hashable { - let commentScore: Int - let postScore: Int - - let commentNumber: Int - let postNumber: Int -} diff --git a/Mlem/Navigation/Routes/AppRoutes.swift b/Mlem/Navigation/Routes/AppRoutes.swift index ca983937f..23543b59d 100644 --- a/Mlem/Navigation/Routes/AppRoutes.swift +++ b/Mlem/Navigation/Routes/AppRoutes.swift @@ -10,10 +10,10 @@ import Foundation /// Possible routes for navigation links in `Mlem.app`. /// /// 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 communitySidebarLinkWithContext(CommunitySidebarLinkWithContext) - + case apiPostView(APIPostView) case apiPost(APIPost) @@ -21,11 +21,10 @@ enum AppRoute: Routable { @available(*, deprecated, message: "Use .userProfile instead.") case apiPerson(APIPerson) - case userProfile(UserModel) + case userProfile(UserModel, communityContext: CommunityModel? = nil) case postLinkWithContext(PostLinkWithContext) case lazyLoadPostLinkWithContext(LazyLoadPostLinkWithContext) - case userModeratorLink(UserModeratorLink) // MARK: - Settings case settings(SettingsPage) @@ -40,8 +39,6 @@ enum AppRoute: Routable { switch value { case let value as CommunityLinkWithContext: return .communityLinkWithContext(value) - case let value as CommunitySidebarLinkWithContext: - return .communitySidebarLinkWithContext(value) case let value as APIPostView: return .apiPostView(value) case let value as APIPost: @@ -56,8 +53,6 @@ enum AppRoute: Routable { return .postLinkWithContext(value) case let value as LazyLoadPostLinkWithContext: return .lazyLoadPostLinkWithContext(value) - case let value as UserModeratorLink: - return .userModeratorLink(value) case let value as SettingsPage: return .settings(value) case let value as AboutSettingsPage: diff --git a/Mlem/Repositories/CommunityRepository.swift b/Mlem/Repositories/CommunityRepository.swift index 5ea8094a4..fa3aef757 100644 --- a/Mlem/Repositories/CommunityRepository.swift +++ b/Mlem/Repositories/CommunityRepository.swift @@ -78,7 +78,7 @@ struct CommunityRepository { } func loadDetails(for id: Int) async throws -> CommunityModel { - CommunityModel(from: try await details(apiClient, id).communityView) + CommunityModel(from: try await details(apiClient, id)) } @discardableResult diff --git a/Mlem/Styles/Outlined Web Complex Style.swift b/Mlem/Styles/Outlined Web Complex Style.swift deleted file mode 100644 index dbcb31133..000000000 --- a/Mlem/Styles/Outlined Web Complex Style.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Outlined Web Complex Style.swift -// Mlem -// -// Created by David Bureš on 07.05.2023. -// - -import Foundation -import SwiftUI - -struct OutlinedWebComplexStyle: GroupBoxStyle { - var roundedRectangle: RoundedRectangle = .init(cornerRadius: 8, style: .continuous) - - func makeBody(configuration: Configuration) -> some View { - VStack(alignment: .leading) { - configuration.label - configuration.content - } - .background(Color.systemBackground) - .clipShape(roundedRectangle) - .overlay( - roundedRectangle - .stroke(Color(.secondarySystemBackground), lineWidth: 1) - ) - } -} diff --git a/Mlem/Views/Shared/Links/User/UserLinkView.swift b/Mlem/Views/Shared/Links/User/UserLinkView.swift index aff3dc23e..7a22c22c5 100644 --- a/Mlem/Views/Shared/Links/User/UserLinkView.swift +++ b/Mlem/Views/Shared/Links/User/UserLinkView.swift @@ -16,6 +16,7 @@ struct UserLinkView: View { // to pick the correct flair var postContext: APIPost? var commentContext: APIComment? + var communityContext: CommunityModel? @available(*, deprecated, message: "Provide a UserModel rather than an APIPerson.") init( @@ -23,14 +24,16 @@ struct UserLinkView: View { serverInstanceLocation: ServerInstanceLocation, overrideShowAvatar: Bool? = nil, postContext: APIPost? = nil, - commentContext: APIComment? = nil + commentContext: APIComment? = nil, + communityContext: CommunityModel? = nil ) { self.init( user: UserModel(from: person), serverInstanceLocation: serverInstanceLocation, overrideShowAvatar: overrideShowAvatar, postContext: postContext, - commentContext: commentContext + commentContext: commentContext, + communityContext: communityContext ) } @@ -39,23 +42,27 @@ struct UserLinkView: View { serverInstanceLocation: ServerInstanceLocation, overrideShowAvatar: Bool? = nil, postContext: APIPost? = nil, - commentContext: APIComment? = nil + commentContext: APIComment? = nil, + communityContext: CommunityModel? = nil + ) { self.user = user self.serverInstanceLocation = serverInstanceLocation self.overrideShowAvatar = overrideShowAvatar self.postContext = postContext self.commentContext = commentContext + self.communityContext = communityContext } var body: some View { - NavigationLink(.userProfile(user)) { + NavigationLink(.userProfile(user, communityContext: communityContext)) { UserLabelView( user: user, serverInstanceLocation: serverInstanceLocation, overrideShowAvatar: overrideShowAvatar, postContext: postContext, - commentContext: commentContext + commentContext: commentContext, + communityContext: communityContext ) } .buttonStyle(.plain) diff --git a/Mlem/Views/Shared/Loading View.swift b/Mlem/Views/Shared/Loading View.swift index 814218f5b..090b7a727 100644 --- a/Mlem/Views/Shared/Loading View.swift +++ b/Mlem/Views/Shared/Loading View.swift @@ -9,7 +9,8 @@ import SwiftUI struct LoadingView: View { enum PossibleThingsToLoad { - case posts, image, comments, inbox, replies, mentions, messages, communityDetails, search, instances, instanceDetails + case posts, image, comments, inbox, replies, mentions, messages, + communityDetails, search, instances, instanceDetails, content, profile } let whatIsLoading: PossibleThingsToLoad @@ -23,6 +24,10 @@ struct LoadingView: View { switch whatIsLoading { case .posts: Text("Loading posts") + case .content: + Text("Loading content") + case .profile: + Text("Loading profile") case .image: Text("Loading image") case .comments: diff --git a/Mlem/Views/Shared/Markdown View.swift b/Mlem/Views/Shared/Markdown View.swift index 14d7108b8..e860dde80 100644 --- a/Mlem/Views/Shared/Markdown View.swift +++ b/Mlem/Views/Shared/Markdown View.swift @@ -226,14 +226,22 @@ struct MarkdownView: View { private let isNsfw: Bool private let replaceImagesWithEmoji: Bool private let isInline: Bool + private let alignment: TextAlignment - init(text: String, isNsfw: Bool, replaceImagesWithEmoji: Bool = false, isInline: Bool = false) { + init( + text: String, + isNsfw: Bool, + replaceImagesWithEmoji: Bool = false, + isInline: Bool = false, + alignment: TextAlignment = .leading + ) { _text = isInline ? .init(wrappedValue: MarkdownView.prepareInlineMarkdown(text: text)) : .init(wrappedValue: text) self.isNsfw = isNsfw self.replaceImagesWithEmoji = replaceImagesWithEmoji self.isInline = isInline + self.alignment = alignment } var body: some View { @@ -312,7 +320,8 @@ struct MarkdownView: View { func renderAsMarkdown(text: String, theme: Theme = .mlem) -> some View { Markdown(text) - .frame(maxWidth: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: alignment == .center ? .top : .topLeading) + .multilineTextAlignment(alignment) .markdownTheme(theme) } } diff --git a/Mlem/Views/Shared/Posts/Expanded Post.swift b/Mlem/Views/Shared/Posts/Expanded Post.swift index 32cb69ef3..29feb5489 100644 --- a/Mlem/Views/Shared/Posts/Expanded Post.swift +++ b/Mlem/Views/Shared/Posts/Expanded Post.swift @@ -56,6 +56,7 @@ struct ExpandedPost: View { @StateObject var commentTracker: CommentTracker = .init() @EnvironmentObject var postTracker: PostTracker @State var post: PostModel + var community: CommunityModel? @State var commentErrorDetails: ErrorDetails? @@ -237,7 +238,8 @@ struct ExpandedPost: View { UserLinkView( user: post.creator, - serverInstanceLocation: userServerInstanceLocation + serverInstanceLocation: userServerInstanceLocation, + communityContext: community ) } .padding(.top, AppConstants.postAndCommentSpacing) diff --git a/Mlem/Views/Shared/Posts/Feed Post.swift b/Mlem/Views/Shared/Posts/Feed Post.swift index 3ed8b4862..b2509fcfa 100644 --- a/Mlem/Views/Shared/Posts/Feed Post.swift +++ b/Mlem/Views/Shared/Posts/Feed Post.swift @@ -56,17 +56,20 @@ struct FeedPost: View { // MARK: Parameters let post: PostModel + let community: CommunityModel? let showPostCreator: Bool let showCommunity: Bool let enableSwipeActions: Bool init( post: PostModel, + community: CommunityModel? = nil, showPostCreator: Bool = true, showCommunity: Bool = true, enableSwipeActions: Bool = true ) { self.post = post + self.community = community self.showPostCreator = showPostCreator self.showCommunity = showCommunity self.enableSwipeActions = enableSwipeActions @@ -183,7 +186,8 @@ struct FeedPost: View { if showPostCreator { UserLinkView( user: post.creator, - serverInstanceLocation: userServerInstanceLocation + serverInstanceLocation: userServerInstanceLocation, + communityContext: community ) } } diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift index 150f716ee..424688f2e 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift @@ -33,14 +33,16 @@ struct CompactPost: View { // arguments let post: PostModel + let community: CommunityModel? let showCommunity: Bool // true to show community name, false to show username let menuFunctions: [MenuFunction] // computed var showReadCheck: Bool { post.read && diffWithoutColor && readMarkStyle == .check } - init(post: PostModel, showCommunity: Bool, menuFunctions: [MenuFunction]) { + init(post: PostModel, community: CommunityModel? = nil, showCommunity: Bool, menuFunctions: [MenuFunction]) { self.post = post + self.community = community self.showCommunity = showCommunity self.menuFunctions = menuFunctions } @@ -59,7 +61,8 @@ struct CompactPost: View { } else { UserLinkView( user: post.creator, - serverInstanceLocation: .trailing + serverInstanceLocation: .trailing, + communityContext: community ) } } diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift index 7e82b6ab2..d3ac1ebd8 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift @@ -22,18 +22,6 @@ struct HeadlinePost: View { // arguments let post: PostModel - // computed - var usernameColor: Color { - if post.creator.isAdmin { - return .red - } - if post.creator.isBot { - return .indigo - } - - return .secondary - } - var body: some View { VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { HStack(alignment: .top, spacing: spacing) { diff --git a/Mlem/Views/Shared/Reply Editor/Keyboard Accessories.swift b/Mlem/Views/Shared/Reply Editor/Keyboard Accessories.swift deleted file mode 100644 index 2166771eb..000000000 --- a/Mlem/Views/Shared/Reply Editor/Keyboard Accessories.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Keyboard Accessories.swift -// Mlem -// -// Created by David Bureš on 20.05.2023. -// - -import SwiftUI - -struct KeyboardAccessories: View { - var body: some View { - Text("Ahoj") - } -} diff --git a/Mlem/Views/Shared/Reply Editor/Reply Editor.swift b/Mlem/Views/Shared/Reply Editor/Reply Editor.swift deleted file mode 100644 index 9d73989af..000000000 --- a/Mlem/Views/Shared/Reply Editor/Reply Editor.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Reply Editor.swift -// Mlem -// -// Created by David Bureš on 20.05.2023. -// - -import SwiftUI - -struct ReplyEditor: UIViewRepresentable { - @Binding var text: String - - func makeUIView(context: Context) -> UITextView { - let textField = UITextView() - - textField.font = .systemFont(ofSize: 15) - - textField.becomeFirstResponder() - - textField.delegate = context.coordinator - - return textField - } - - func updateUIView(_ textField: UITextView, context _: Context) { - textField.text = text - } - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text) - } - - class Coordinator: NSObject, UITextViewDelegate { - @Binding var text: String - - init(text: Binding) { - _text = text - } - - func textViewDidChange(_ textView: UITextView) { - if let selectedRange = textView.selectedTextRange { - let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start) - print("Cursor position: \(cursorPosition)") - } - - text = textView.text - } - } - - typealias UIViewType = UITextView -} diff --git a/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift b/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift index 094958579..2a6d7b979 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift +++ b/Mlem/Views/Tabs/Feeds/Community List/Community List View.swift @@ -34,28 +34,11 @@ struct CommunityListView: View { ScrollViewReader { scrollProxy in HStack { List(selection: $selectedCommunity) { - HomepageFeedRowView( - feedType: .subscribed, - iconName: Icons.subscribedFeedFill, - iconColor: .red, - description: "Subscribed communities from all servers", - navigationContext: .sidebar - ) - .id("top") // For "scroll to top" sidebar item - HomepageFeedRowView( - feedType: .local, - iconName: Icons.localFeedFill, - iconColor: .green, - description: "Local communities from your server", - navigationContext: .sidebar - ) - HomepageFeedRowView( - feedType: .all, - iconName: Icons.federatedFeedFill, - iconColor: .blue, - description: "All communities that federate with your server", - navigationContext: .sidebar - ) + 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)) { diff --git a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift b/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift index 834f1aee8..1055fead3 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift +++ b/Mlem/Views/Tabs/Feeds/Community List/Components/CommunityListRowViews.swift @@ -132,19 +132,19 @@ struct CommuntiyFeedRowView: View { struct HomepageFeedRowView: View { let feedType: FeedType - let iconName: String - let iconColor: Color - let description: String - let navigationContext: NavigationContext + + init(_ feedType: FeedType) { + self.feedType = feedType + } var body: some View { NavigationLink(value: pathValue) { HStack { - Image(systemName: iconName).resizable() - .frame(width: 36, height: 36).foregroundColor(iconColor) + Image(systemName: feedType.iconNameCircle).resizable() + .frame(width: 36, height: 36).foregroundColor(feedType.color) VStack(alignment: .leading) { Text("\(feedType.label) Communities") - Text(description).font(.caption).foregroundColor(.gray) + Text(feedType.description).font(.caption).foregroundColor(.gray) } } .padding(.bottom, 1) @@ -153,11 +153,6 @@ struct HomepageFeedRowView: View { } private var pathValue: AnyHashable { - if navigationContext == .sidebar { - return CommunityLinkWithContext(community: nil, feedType: feedType) - } 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: nil, feedType: feedType)) - } + return CommunityLinkWithContext(community: nil, feedType: feedType) } } diff --git a/Mlem/Views/Tabs/Feeds/CommunityStatsView.swift b/Mlem/Views/Tabs/Feeds/CommunityStatsView.swift new file mode 100644 index 000000000..d52def8b2 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/CommunityStatsView.swift @@ -0,0 +1,107 @@ +// +// CommunityStatsView.swift +// Mlem +// +// Created by Sjmarf on 01/01/2024. +// + +import SwiftUI + +struct CommunityStatsView: View { + let community: CommunityModel + + var body: some View { + VStack(spacing: 16) { + VStack(spacing: 5) { + Text("Subscribers") + .foregroundStyle(.secondary) + 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") + .foregroundStyle(.secondary) + } + HStack { + Text("\(abbreviateNumber(community.postCount ?? 0))") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.pink) + } + } + .padding(10) + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + + VStack(spacing: 5) { + HStack { + Text("Comments") + .foregroundStyle(.secondary) + } + HStack { + Text("\(abbreviateNumber(community.commentCount ?? 0))") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.orange) + } + } + .padding(10) + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + } + .frame(maxWidth: .infinity) + + if let activeUserCount = community.activeUserCount { + VStack(spacing: 8) { + Text("Active Users") + .foregroundStyle(.secondary) + HStack(spacing: 16) { + activeUserBox("6mo", value: activeUserCount.sixMonths) + activeUserBox("1mo", value: activeUserCount.month) + activeUserBox("1w", value: activeUserCount.week) + activeUserBox("1d", value: activeUserCount.day) + } + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + } + } + .padding(.horizontal, 16) + } + + @ViewBuilder + func activeUserBox(_ label: String, value: Int) -> some View { + VStack { + Text(abbreviateNumber(value)) + .font(.title3) + .fontWeight(.semibold) + Text(label) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + + } +} + +#Preview { + VStack(spacing: 0) { + Divider() + CommunityStatsView(community: .mock()) + .padding(.top, 10) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemGroupedBackground)) +} diff --git a/Mlem/Views/Tabs/Feeds/CommunityView.swift b/Mlem/Views/Tabs/Feeds/CommunityView.swift new file mode 100644 index 000000000..5d2a1a436 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/CommunityView.swift @@ -0,0 +1,349 @@ +// +// 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/Components/Sidebar Header Avatar.swift b/Mlem/Views/Tabs/Feeds/Components/Sidebar Header Avatar.swift deleted file mode 100644 index 2ec287ee8..000000000 --- a/Mlem/Views/Tabs/Feeds/Components/Sidebar Header Avatar.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Sidebar Header Avatar.swift -// Mlem -// -// Created by Jake Shirley on 6/21/23. -// - -import Foundation - -import SwiftUI - -struct SidebarHeaderAvatar: View { - @State var shouldClipAvatar: Bool = false - @State var imageUrl: URL? - let avatarType: AvatarType - - var body: some View { - CachedImage( - url: imageUrl, - shouldExpand: false, - fixedSize: CGSize(width: AppConstants.hugeAvatarSize, height: AppConstants.hugeAvatarSize), - imageNotFound: { AnyView(DefaultAvatarView(avatarType: avatarType)) }, - contentMode: .fill - ) - .frame(width: AppConstants.hugeAvatarSize, height: AppConstants.hugeAvatarSize) - .clipShape(Circle()) - .overlay(Circle() - .stroke(.secondary, lineWidth: shouldClipAvatar ? 2 : 0)) - .shadow(radius: 10) - .background(shouldClipAvatar ? Circle() - .foregroundColor(.systemBackground) : nil) - } -} diff --git a/Mlem/Views/Tabs/Feeds/Components/Sidebar Header Label.swift b/Mlem/Views/Tabs/Feeds/Components/Sidebar Header Label.swift deleted file mode 100644 index 3382f55ea..000000000 --- a/Mlem/Views/Tabs/Feeds/Components/Sidebar Header Label.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Sidebar Header Label.swift -// Mlem -// -// Created by Jake Shirley on 6/21/23. -// - -import SwiftUI - -struct CommunitySidebarHeaderLabel: View { - @State var text: String - - init(_ text: String) { - self._text = State(initialValue: text) - } - - var body: some View { - Text(text) - .padding(.horizontal, 6) - .foregroundColor(.primary) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 6)) - .font(.callout) - .lineLimit(1) - } -} - -struct CommunitySidebarHeaderLabel_Previews: PreviewProvider { - static var previews: some View { - NavigationStack { - CommunitySidebarHeaderLabel("This is a label") - } - } -} diff --git a/Mlem/Views/Tabs/Feeds/Components/Sidebar Header.swift b/Mlem/Views/Tabs/Feeds/Components/Sidebar Header.swift deleted file mode 100644 index a73922f8d..000000000 --- a/Mlem/Views/Tabs/Feeds/Components/Sidebar Header.swift +++ /dev/null @@ -1,153 +0,0 @@ -// -// Sidebar Header.swift -// Mlem -// -// Created by Jake Shirley on 6/21/23. -// - -import SwiftUI - -struct CommunitySidebarHeader: View { - var title: String - var subtitle: String - @Binding var avatarSubtext: String - var avatarSubtextClicked: (() -> Void)? - - var bannerURL: URL? - var avatarUrl: URL? - - var label1: String? - var label2: String? - - let avatarType: AvatarType - - var body: some View { - ZStack(alignment: .top) { - // Banner - VStack { - if let bannerUrl = bannerURL { - CachedImage( - url: bannerUrl, - shouldExpand: false, - fixedSize: CGSize(width: UIScreen.main.bounds.width, height: 200), - contentMode: .fill - ).frame(width: UIScreen.main.bounds.width) - } - } - VStack { - if bannerURL != nil { - Spacer().frame(height: 110) - } - HStack(alignment: .top) { - VStack(alignment: .leading) { - SidebarHeaderAvatar( - shouldClipAvatar: AvatarView.shouldShowCommunityAvatarOutline(url: avatarUrl), - imageUrl: avatarUrl, - avatarType: avatarType - ) - - Button { - if let callback = avatarSubtextClicked { - callback() - } - } label: { - HStack { - Text(avatarSubtext).minimumScaleFactor(0.01) - }.foregroundColor(.gray) - } - - }.padding([.leading]) - - Spacer() - - VStack(alignment: .trailing) { - Spacer().frame(height: 60) - HStack { - if let label = label1 { - CommunitySidebarHeaderLabel(label) - } - if let label = label2 { - CommunitySidebarHeaderLabel(label) - } - }.frame(height: 16) - Spacer().frame(height: 20) - - Text(title) - .font(.title) - .bold() - .lineLimit(1) - .minimumScaleFactor(0.01) - Text(subtitle) - .font(.footnote) - .lineLimit(1) - .minimumScaleFactor(0.01) - }.padding([.trailing]) - } - } - } - } -} - -struct SidebarHeaderPreview: PreviewProvider { - static var previews: some View { - ScrollView { - VStack { - CommunitySidebarHeader( - title: "TestCommunityWithLongName", - subtitle: "@testcommunity@longnamedomain.website", - avatarSubtext: .constant("Created 3 days ago"), - bannerURL: URL(string: "https://picsum.photos/seed/mlem-banner/2001/300"), - avatarUrl: URL(string: "https://picsum.photos/seed/mlem-avatar/200"), - label1: "Label 1", - label2: "Label 2", - avatarType: .community - ) - Divider() - CommunitySidebarHeader( - title: "Test", - subtitle: "@test@test.come", - avatarSubtext: .constant("Created 3 days ago"), - bannerURL: URL(string: "https://picsum.photos/seed/mlem-banner/200/300"), - avatarUrl: URL(string: "https://picsum.photos/seed/mlem-avatar/200"), - label1: "Label 1", - label2: "Label 2", - avatarType: .community - ) - Divider() - CommunitySidebarHeader( - title: "Test With No Avatar", - subtitle: "@test@test.come", - avatarSubtext: .constant("Created 3 days ago"), - bannerURL: URL(string: "https://picsum.photos/seed/mlem-banner/200/300"), - avatarUrl: nil, - label1: "Label 1", - label2: "Label 2", - avatarType: .community - ) - Divider() - CommunitySidebarHeader( - title: "Test With No Banner", - subtitle: "@test@test.come", - avatarSubtext: .constant("Created 3 days ago"), - bannerURL: nil, - avatarUrl: URL(string: "https://picsum.photos/seed/mlem-avatar/200"), - label1: "Label 1", - label2: "Label 2", - avatarType: .community - ) - Divider() - CommunitySidebarHeader( - title: "Test With No Banner or Avatar", - subtitle: "@test@test.come", - avatarSubtext: .constant("Created 3 days ago"), - bannerURL: nil, - avatarUrl: nil, - label1: "Label 1", - label2: "Label 2", - avatarType: .community - ) - Spacer() - } - } - } -} diff --git a/Mlem/Views/Tabs/Feeds/Components/Sidebar View.swift b/Mlem/Views/Tabs/Feeds/Components/Sidebar View.swift deleted file mode 100644 index b52148f9c..000000000 --- a/Mlem/Views/Tabs/Feeds/Components/Sidebar View.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// Sidebar View.swift -// Mlem -// -// Created by David Bureš on 08.05.2023. -// - -import Dependencies -import SwiftUI - -struct CommunitySidebarView: View { - @Dependency(\.communityRepository) var communityRepository - @Dependency(\.errorHandler) var errorHandler - - // parameters - @State var community: CommunityModel - - @State private var selectionSection = 0 - var shouldShowCommunityHeaders: Bool = true - @State private var errorMessage: String? - - var body: some View { - Section { view } - .navigationTitle("Sidebar") - .navigationBarTitleDisplayMode(.inline) - .hoistNavigation() - .task(priority: .userInitiated) { - // Load community details if they weren't provided already - if community.moderators == nil { - await loadCommunity() - } - }.refreshable { - await loadCommunity() - } - } - - private func loadCommunity() async { - do { - errorMessage = nil - let communityDetails: GetCommunityResponse = try await communityRepository.loadDetails(for: community.communityId) - community = .init(from: communityDetails) - } catch { - errorMessage = "Unable to load community details, please try again." - errorHandler.handle(error) - } - } - - private func getRelativeTime(date: Date) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - - return formatter.localizedString(for: date, relativeTo: Date.now) - } - - var view: some View { - ScrollView { - CommunitySidebarHeader( - title: community.displayName, - subtitle: "@\(community.name)@\(community.communityUrl.host()!)", - avatarSubtext: .constant("Created \(getRelativeTime(date: community.creationDate))"), - bannerURL: shouldShowCommunityHeaders ? community.banner : nil, - avatarUrl: community.avatar, - label1: "\(community.subscriberCount ?? 0) Subscribers", - avatarType: .community - ) - - Picker(selection: $selectionSection, label: Text("Profile Section")) { - Text("Description").tag(0) - Text("Moderators").tag(1) - } - .pickerStyle(.segmented) - .padding(.horizontal) - - if selectionSection == 0 { - if let description = community.description { - MarkdownView(text: description, isNsfw: false).padding() - } - } else if selectionSection == 1 { - VStack { - Divider() - if let moderators = community.moderators { - ForEach(moderators) { moderatorView in - - NavigationLink(.apiPerson(moderatorView.moderator)) { - HStack { - UserLabelView( - person: moderatorView.moderator, - serverInstanceLocation: .bottom, - overrideShowAvatar: true, - communityContext: community - ) - Spacer() - }.padding() - } - Divider() - } - } - }.padding(.top) - } - } - .fancyTabScrollCompatible() - } - - @ViewBuilder - func errorView(errorDetails: String) -> some View { - VStack(spacing: 10) { - Image(systemName: Icons.warning) - .font(.title) - - Text("Community details loading failed!") - Text(errorDetails) - } - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - .accessibilityElement(children: .combine) - .padding() - } -} - -struct SidebarPreview: PreviewProvider { - static let previewCommunityDescription: String = """ - This is an example community with some markdown: - - Do not ~wear silly hats~ spam! - - Ok maybe just a little bit. - - I SAID **NO**! - """ - - static let previewCommunity: APICommunity = .mock( - name: "testcommunity", - title: "Test Community", - description: previewCommunityDescription, - actorId: URL(string: "https://lemmy.foo.com/c/testcommunity")!, - icon: "https://vlemmy.net/pictrs/image/190f2d6a-ac38-448d-ae9b-f6d751eb6e69.png?format=webp", - banner: "https://vlemmy.net/pictrs/image/719b61b3-8d8e-4aec-9f15-17be4a081f97.jpeg?format=webp" - ) - - static let previewUser: APIPerson = .mock( - name: "ExamplePerson", - displayName: "Example Person", - actorId: URL(string: "lem.foo.bar/u/exampleperson")! - ) - - static let previewModerator = APICommunityModeratorView(community: previewCommunity, moderator: previewUser) - - static var previews: some View { - let model = CommunityModel(from: GetCommunityResponse.mock( - communityView: .mock( - community: previewCommunity, - subscribed: .subscribed - ), - moderators: .init(repeating: previewModerator, count: 11) - )) - - CommunitySidebarView(community: model) - } -} diff --git a/Mlem/Views/Tabs/Feeds/Feed Root.swift b/Mlem/Views/Tabs/Feeds/Feed Root.swift index e07eb0487..0261ccae4 100644 --- a/Mlem/Views/Tabs/Feeds/Feed Root.swift +++ b/Mlem/Views/Tabs/Feeds/Feed Root.swift @@ -33,11 +33,9 @@ struct FeedRoot: View { } detail: { NavigationStack(path: $feedTabNavigation.path) { if let rootDetails { - FeedView( + FeedParentView( community: rootDetails.community, - feedType: rootDetails.feedType, - rootDetails: $rootDetails, - splitViewColumnVisibility: $columnVisibility + feedType: rootDetails.feedType ) .environmentObject(appState) .environmentObject(feedTabNavigation) diff --git a/Mlem/Views/Tabs/Feeds/Feed View Logic.swift b/Mlem/Views/Tabs/Feeds/Feed View Logic.swift deleted file mode 100644 index ee1f941eb..000000000 --- a/Mlem/Views/Tabs/Feeds/Feed View Logic.swift +++ /dev/null @@ -1,350 +0,0 @@ -// -// Feed View Logic.swift -// Mlem -// -// Created by Eric Andrews on 2023-07-21. -// - -import Foundation -import SwiftUI - -extension FeedView { - - func setDefaultSortMode() { - @AppStorage("defaultPostSorting") var defaultPostSorting: PostSortType = .hot - @AppStorage("fallbackDefaultPostSorting") var fallbackDefaultPostSorting: PostSortType = .hot - if let siteVersion = siteInformation.version, siteVersion < defaultPostSorting.minimumVersion { - postSortType = fallbackDefaultPostSorting - } else { - postSortType = defaultPostSorting - } - } - - // MARK: Feed loading - - func initFeed() async { - isLoading = true - if postTracker.items.isEmpty { - print("Post tracker is empty") - await loadFeed() - } else { - print("Post tracker is not empty") - isLoading = false - } - } - - func loadFeed() async { - defer { isLoading = false } - isLoading = true - do { - try await postTracker.loadNextPage( - communityId: community?.communityId, - sort: postSortType, - type: feedType, - filtering: filter - ) - } catch { - handle(error) - } - } - - @discardableResult - func refreshFeed() async -> Bool { - // NOTE: refresh doesn't need to touch isLoading because that visual cue is handled by .refreshable - do { - try await postTracker.refresh( - communityId: community?.communityId, - sort: postSortType, - feedType: feedType, - filtering: filter - ) - errorDetails = nil - return true - } catch { - handle(error) - return false - } - } - - /// Function to reset the feed, used as a callback to switcher options. Clears the items and displays a loading view. - func hardRefreshFeed() async { - defer { isLoading = false } - isLoading = true - do { - try await postTracker.refresh( - communityId: community?.communityId, - sort: postSortType, - feedType: feedType, - clearBeforeFetch: true, - filtering: filter - ) - } catch { - handle(error) - } - } - - // MARK: Community loading - - func fetchCommunityDetails() async { - if let community { - do { - let communityDetails: GetCommunityResponse = try await communityRepository.loadDetails(for: community.communityId) - self.community = .init(from: communityDetails) - } catch { - errorHandler.handle( - .init( - title: "Could not load community information", - message: "The server might be overloaded.\nTry again later.", - underlyingError: error - ), - showNoInternet: false - ) - } - } - } - - // MARK: Menus - - func genOuterSortMenuFunctions() -> [MenuFunction] { - PostSortType.availableOuterTypes.map { type in - let isSelected = postSortType == type - let imageName = isSelected ? type.iconNameFill : type.iconName - return MenuFunction.standardMenuFunction( - text: type.label, - imageName: imageName, - destructiveActionPrompt: nil, - enabled: !isSelected - ) { - postSortType = type - } - } - } - - func genTopSortMenuFunctions() -> [MenuFunction] { - PostSortType.availableTopTypes.map { type in - let isSelected = postSortType == type - return MenuFunction.standardMenuFunction( - text: type.label, - imageName: isSelected ? Icons.timeSortFill : Icons.timeSort, - destructiveActionPrompt: nil, - enabled: !isSelected - ) { - postSortType = type - } - } - } - - 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 - } - - // swiftlint:disable function_body_length - func genCommunitySpecificMenuFunctions() -> [MenuFunction] { - guard let community else { return [] } - var ret: [MenuFunction] = .init() - // new post - ret.append(MenuFunction.standardMenuFunction( - text: "New Post", - imageName: Icons.sendFill, - destructiveActionPrompt: nil, - enabled: true - ) { - editorTracker.openEditor(with: PostEditorModel( - community: community, - postTracker: postTracker - )) - }) - - // subscribe/unsubscribe - if let subscribed = community.subscribed { - let (subscribeText, subscribeSymbol, subscribePrompt) = subscribed - ? ("Unsubscribe", Icons.unsubscribe, "Really unsubscribe from \(community.name)?") - : ("Subscribe", Icons.subscribe, nil) - ret.append(MenuFunction.standardMenuFunction( - text: subscribeText, - imageName: subscribeSymbol, - destructiveActionPrompt: subscribePrompt, - enabled: true - ) { - Task(priority: .userInitiated) { - await toggleSubscribe() - } - }) - } - - // favorite/unfavorite - if favoriteCommunitiesTracker.isFavorited(community.community) { - ret.append(MenuFunction.standardMenuFunction( - text: "Unfavorite", - imageName: "star.slash", - destructiveActionPrompt: "Really unfavorite \(community.name)?", - enabled: true - ) { - favoriteCommunitiesTracker.unfavorite(community.community) - Task { - await notifier.add(.success("Unfavorited \(community.name)")) - } - }) - } else { - ret.append(MenuFunction.standardMenuFunction( - text: "Favorite", - imageName: "star", - destructiveActionPrompt: nil, - enabled: true - ) { - favoriteCommunitiesTracker.favorite(community.community) - Task { - await notifier.add(.success("Favorited \(community.name)")) - } - }) - } - - // share - ret.append(MenuFunction.shareMenuFunction(url: community.communityUrl)) - - // block/unblock - if let blocked = community.blocked { - // block - let (blockText, blockSymbol, blockPrompt) = blocked - ? ("Unblock", Icons.show, nil) - : ("Block", Icons.hide, "Really block \(community.name)?") - ret.append(MenuFunction.standardMenuFunction( - text: blockText, - imageName: blockSymbol, - destructiveActionPrompt: blockPrompt, - enabled: true - ) { - Task(priority: .userInitiated) { - await block() - } - }) - } - - return ret - } - - // swiftlint:enable function_body_length - - func genFeedSwitchingFunctions() -> [MenuFunction] { - var ret: [MenuFunction] = .init() - - FeedType.allCases.forEach { type in - let (imageName, enabled) = type != feedType - ? (type.iconName, true) - : (type.iconNameFill, false) - ret.append(MenuFunction.standardMenuFunction( - text: type.label, - imageName: imageName, - destructiveActionPrompt: nil, - enabled: enabled, - callback: { feedType = type } - )) - } - - return ret - } - - func genPostSizeSwitchingFunctions() -> [MenuFunction] { - PostSize.allCases.map { size in - let (imageName, enabled) = size != postSize - ? (size.iconName, true) - : (size.iconNameFill, false) - - return MenuFunction.standardMenuFunction( - text: size.label, - imageName: imageName, - destructiveActionPrompt: nil, - enabled: enabled, - callback: { postSize = size } - ) - } - } - - // MARK: Helper Functions - - private 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: refreshFeed) - 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 loadFeed() - } - return - } - default: - break - } - errorDetails = .init(error: error, refresh: refreshFeed) - } - - private 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 - } - - private func toggleSubscribe() async { - if var community { - hapticManager.play(haptic: .success, priority: .high) - do { - try await community.toggleSubscribe { - self.community = $0 - } - if community.subscribed ?? false { - await notifier.add(.success("Subscribed to \(community.name)")) - } else { - await notifier.add(.success("Unsubscribed from \(community.name)")) - } - } catch { - errorHandler.handle(error) - } - } - } - - private func block() async { - if var community { - hapticManager.play(haptic: .violentSuccess, priority: .high) - do { - try await community.toggleBlock { - self.community = $0 - } - // refresh the feed after blocking which will show/hide the posts - await hardRefreshFeed() - } catch { - errorHandler.handle(error) - } - } - } -} diff --git a/Mlem/Views/Tabs/Feeds/Feed View.swift b/Mlem/Views/Tabs/Feeds/Feed View.swift deleted file mode 100644 index 6acafb912..000000000 --- a/Mlem/Views/Tabs/Feeds/Feed View.swift +++ /dev/null @@ -1,368 +0,0 @@ -// -// Feed View (new).swift -// Mlem -// -// Created by Eric Andrews on 2023-07-21. -// - -import Dependencies -import Foundation -import SwiftUI - -// swiftlint:disable type_body_length -struct FeedView: View { - // MARK: Environment and settings - - @Dependency(\.communityRepository) var communityRepository - @Dependency(\.errorHandler) var errorHandler - @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker - @Dependency(\.hapticManager) var hapticManager - @Dependency(\.notifier) var notifier - @Dependency(\.siteInformation) var siteInformation - - @AppStorage("shouldShowCommunityHeaders") var shouldShowCommunityHeaders: Bool = false - @AppStorage("shouldBlurNsfw") var shouldBlurNsfw: Bool = true - @AppStorage("shouldShowPostCreator") var shouldShowPostCreator: Bool = true - @AppStorage("postSize") var postSize: PostSize = .large - @AppStorage("showReadPosts") var showReadPosts: Bool = true - - @Environment(\.navigationPathWithRoutes) private var navigationPath - @Environment(\.scrollViewProxy) private var scrollViewProxy - @EnvironmentObject var appState: AppState - @EnvironmentObject var filtersTracker: FiltersTracker - @EnvironmentObject var editorTracker: EditorTracker - - // MARK: Parameters and init - - @State var community: CommunityModel? - @State var feedType: FeedType - @Binding var rootDetails: CommunityLinkWithContext? - /// Applicable when presented as root view in a column of NavigationSplitView. - @Binding var splitViewColumnVisibility: NavigationSplitViewVisibility - - @State var errorDetails: ErrorDetails? - - init( - community: CommunityModel?, - feedType: FeedType, - rootDetails: Binding? = nil, - splitViewColumnVisibility: 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._postTracker = StateObject(wrappedValue: .init( - shouldPerformMergeSorting: false, - internetSpeed: internetSpeed, - upvoteOnSave: upvoteOnSave - )) - - self._rootDetails = rootDetails ?? .constant(nil) - self._splitViewColumnVisibility = splitViewColumnVisibility ?? .constant(.automatic) - self._community = State(initialValue: community) - - @AppStorage("fallbackDefaultPostSorting") var fallbackDefaultPostSorting: PostSortType = .hot - _postSortType = .init(initialValue: fallbackDefaultPostSorting) - } - - // MARK: State - - @StateObject var postTracker: PostTracker - - @State var postSortType: PostSortType - @State var isLoading: Bool = true - @State var shouldLoad: Bool = false - - @AppStorage("hasTranslucentInsets") var hasTranslucentInsets: Bool = true - - // MARK: Destructive confirmation - - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? - - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } - - @Namespace var scrollToTop - @State private var scrollToTopAppeared = false - private var scrollToTopId: Int? { - postTracker.items.first?.id - } - - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - // MARK: - Main Views - - var body: some View { - contentView - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(hasTranslucentInsets ? Color.secondarySystemBackground : Color.systemBackground) - .toolbar { - ToolbarItem(placement: .principal) { toolbarHeader } - ToolbarItem(placement: .navigationBarTrailing) { sortMenu } - ToolbarItemGroup(placement: .navigationBarTrailing) { ellipsisMenu } - } - .navigationBarTitleDisplayMode(.inline) - /// [2023.08] Set to `.visible` to workaround bug where navigation bar background may disappear on certain devices when device rotates. - .navigationBarColor(visibility: .visible) - .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 = { - if splitViewColumnVisibility == .all { - return .detailOnly - } else { - return .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 - // // Return `false` to use dismiss action to go back to sidebar. Not sure - // return false - } - } 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 - } - } - } - .environmentObject(postTracker) - .onAppear { - if isLoading { - Task(priority: .userInitiated) { - setDefaultSortMode() - await initFeed() - } - } - } - .task(priority: .background) { await fetchCommunityDetails() } - // using hardRefreshFeed() for these three so that the user gets immediate feedback, also kills the ScrollViewReader - .onChange(of: feedType) { _ in - Task(priority: .userInitiated) { - await hardRefreshFeed() - } - } - .onChange(of: postSortType) { _ in - Task(priority: .userInitiated) { - await hardRefreshFeed() - } - } - .onChange(of: appState.currentActiveAccount) { _ in - Task(priority: .userInitiated) { - setDefaultSortMode() - await hardRefreshFeed() - } - } - .onChange(of: showReadPosts) { _ in - Task(priority: .userInitiated) { - await hardRefreshFeed() - } - } - .onChange(of: shouldLoad) { value in - if value { - print("should load more posts...") - Task(priority: .medium) { await loadFeed() } - shouldLoad = false - } - } - .onChange(of: postTracker.items) { newValue in - if !newValue.isEmpty { - errorDetails = nil - } - } - .refreshable { - await refreshFeed() - } - } - - @ViewBuilder - private var contentView: some View { - ScrollView { - if !postTracker.items.isEmpty { - LazyVStack(spacing: 0) { - ScrollToView(appeared: $scrollToTopAppeared) - .id(scrollToTop) - - // note: using .uid here because .id causes swipe actions to break--state changes still seem to properly trigger rerenders this way 🤔 - ForEach(postTracker.items, id: \.uid) { post in - feedPost(for: post) - } - - // TODO: update to use proper LoadingState - EndOfFeedView(loadingState: isLoading && postTracker.page > 1 ? .loading : .done, viewType: .hobbit) - } - } - } - .frame(maxWidth: .infinity) - .overlay { - if postTracker.items.isEmpty { - noPostsView() - } - } - .fancyTabScrollCompatible() - } - - @ViewBuilder - private func noPostsView() -> some View { - VStack { - if let errorDetails { - ErrorView(errorDetails) - .frame(maxWidth: .infinity) - } else if isLoading { // don't show posts until site information loads to avoid jarring redraw - LoadingView(whatIsLoading: .posts) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .transition(.opacity) - } else { - NoPostsView(isLoading: $isLoading, postSortType: $postSortType, showReadPosts: $showReadPosts) - .transition(.scale(scale: 0.9).combined(with: .opacity)) - } - } - .animation(.easeOut(duration: 0.1), value: isLoading) - } - - // MARK: Helper Views - - @ViewBuilder - private func feedPost(for post: PostModel) -> some View { - VStack(spacing: 0) { - // TODO: reenable nav - NavigationLink(.postLinkWithContext(.init(post: post, postTracker: postTracker))) { - FeedPost( - post: post, - 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 var ellipsisMenu: some View { - Menu { - if let community { - // until we find a nice way to put nav stuff in MenuFunction, this'll have to do :( - NavigationLink(.communitySidebarLinkWithContext( - .init( - community: community - ) - )) { - Label("Sidebar", systemImage: "sidebar.right") - } - - ForEach(genCommunitySpecificMenuFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: confirmDestructive) - } - } - - Divider() - - ForEach(genEllipsisMenuFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: confirmDestructive) - } - - Menu { - ForEach(genPostSizeSwitchingFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: confirmDestructive) - } - } label: { - Label("Post Size", systemImage: Icons.postSizeSetting) - } - } label: { - Label("More", systemImage: "ellipsis") - .frame(height: AppConstants.barIconHitbox) - .contentShape(Rectangle()) - } - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) - } - - @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 - ) - } - } - - @ViewBuilder - private var toolbarHeader: some View { - if let community { - NavigationLink(.communitySidebarLinkWithContext(.init( - community: community - ))) { - Text(community.name) - .font(.headline) - .foregroundColor(.primary) - .accessibilityHint("Activate to view sidebar.") - } - } else { - Menu { - ForEach(genFeedSwitchingFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) // no destructive feed switches - } - } label: { - HStack(alignment: .center, spacing: 0) { - Text(feedType.label) - .font(.headline) - Image(systemName: Icons.dropdown) - .scaleEffect(0.7) - } - .foregroundColor(.primary) - .accessibilityElement(children: .combine) - .accessibilityHint("Activate to change feeds.") - // this disables the implicit animation on the header view... - .transaction { $0.animation = nil } - } - } - } -} - -// swiftlint:enable type_body_length diff --git a/Mlem/Views/Tabs/Feeds/FeedParentView.swift b/Mlem/Views/Tabs/Feeds/FeedParentView.swift new file mode 100644 index 000000000..86389cf43 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/FeedParentView.swift @@ -0,0 +1,44 @@ +// +// 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+Logic.swift b/Mlem/Views/Tabs/Feeds/FeedView+Logic.swift new file mode 100644 index 000000000..023402c32 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/FeedView+Logic.swift @@ -0,0 +1,27 @@ +// +// FeedView+Logic.swift +// Mlem +// +// Created by Sjmarf on 31/12/2023. +// + +import SwiftUI + +extension FeedView { + func genFeedSwitchingFunctions() -> [MenuFunction] { + var ret: [MenuFunction] = .init() + FeedType.allCases.forEach { type in + let (imageName, enabled) = type != feedType + ? (type.iconName, true) + : (type.iconNameFill, false) + ret.append(MenuFunction.standardMenuFunction( + text: type.label, + imageName: imageName, + destructiveActionPrompt: nil, + enabled: enabled, + callback: { feedType = type } + )) + } + return ret + } +} diff --git a/Mlem/Views/Tabs/Feeds/FeedView.swift b/Mlem/Views/Tabs/Feeds/FeedView.swift new file mode 100644 index 000000000..58d83b03d --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/FeedView.swift @@ -0,0 +1,227 @@ +// +// 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/PostFeedView+Logic.swift b/Mlem/Views/Tabs/Feeds/PostFeedView+Logic.swift new file mode 100644 index 000000000..de46f5025 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/PostFeedView+Logic.swift @@ -0,0 +1,54 @@ +// +// 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+MenuFunctions.swift b/Mlem/Views/Tabs/Feeds/PostFeedView+MenuFunctions.swift new file mode 100644 index 000000000..9f40cc5b9 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/PostFeedView+MenuFunctions.swift @@ -0,0 +1,81 @@ +// +// PostFeedView+MenuFunctions.swift +// Mlem +// +// Created by Sjmarf on 31/12/2023. +// + +import Foundation + +extension PostFeedView { + func genOuterSortMenuFunctions() -> [MenuFunction] { + PostSortType.availableOuterTypes.map { type in + let isSelected = postSortType == type + let imageName = isSelected ? type.iconNameFill : type.iconName + return MenuFunction.standardMenuFunction( + text: type.label, + imageName: imageName, + destructiveActionPrompt: nil, + enabled: !isSelected + ) { + postSortType = type + } + } + } + + func genTopSortMenuFunctions() -> [MenuFunction] { + PostSortType.availableTopTypes.map { type in + let isSelected = postSortType == type + return MenuFunction.standardMenuFunction( + text: type.label, + imageName: isSelected ? Icons.timeSortFill : Icons.timeSort, + destructiveActionPrompt: nil, + enabled: !isSelected + ) { + postSortType = type + } + } + } + + 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 + } + + func genPostSizeSwitchingFunctions() -> [MenuFunction] { + PostSize.allCases.map { size in + let (imageName, enabled) = size != postSize + ? (size.iconName, true) + : (size.iconNameFill, false) + + return MenuFunction.standardMenuFunction( + text: size.label, + imageName: imageName, + destructiveActionPrompt: nil, + enabled: enabled, + callback: { postSize = size } + ) + } + } +} diff --git a/Mlem/Views/Tabs/Feeds/PostFeedView.swift b/Mlem/Views/Tabs/Feeds/PostFeedView.swift new file mode 100644 index 000000000..0665a48e0 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/PostFeedView.swift @@ -0,0 +1,190 @@ +// +// 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) { + 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 d3b26b3fa..b7ee44a75 100644 --- a/Mlem/Views/Tabs/Inbox/Inbox View.swift +++ b/Mlem/Views/Tabs/Inbox/Inbox View.swift @@ -207,7 +207,7 @@ struct InboxView: View { MenuButton(menuFunction: menuFunction, confirmDestructive: nil) // no destructive functions } } label: { - Label("More", systemImage: "ellipsis") + Label("More", systemImage: Icons.menuCircle) .frame(height: AppConstants.barIconHitbox) .contentShape(Rectangle()) } diff --git a/Mlem/Views/Tabs/Profile/AvatarBannerView.swift b/Mlem/Views/Tabs/Profile/AvatarBannerView.swift new file mode 100644 index 000000000..4a72be6a5 --- /dev/null +++ b/Mlem/Views/Tabs/Profile/AvatarBannerView.swift @@ -0,0 +1,111 @@ +// +// UserHeaderView.swift +// Mlem +// +// Created by Sjmarf on 27/12/2023. +// + +import SwiftUI +import NukeUI + +struct AvatarBannerView: View { + + let type: AvatarType + + let avatar: URL? + let banner: URL? + + let showBanner: Bool + let showAvatar: Bool + + init(user: UserModel) { + self.type = .user + self.avatar = user.avatar + self.banner = user.banner + @AppStorage("shouldShowUserHeaders") var shouldShowUserHeaders: Bool = true + self.showBanner = shouldShowUserHeaders + @AppStorage("shouldShowUserAvatars") var shouldShowUserAvatars: Bool = true + self.showAvatar = shouldShowUserAvatars + } + + init(community: CommunityModel) { + self.type = .community + self.avatar = community.avatar + self.banner = community.banner + @AppStorage("shouldShowCommunityHeaders") var shouldShowCommunityHeaders: Bool = true + self.showBanner = shouldShowCommunityHeaders + @AppStorage("shouldShowCommunityIcons") var shouldShowCommunityIcons: Bool = true + self.showAvatar = shouldShowCommunityIcons + } + + static let bannerHeight: CGFloat = 170 + static let avatarOverdraw: CGFloat = 40 + static let avatarSize: CGFloat = 108 + static let avatarPadding: CGFloat = AppConstants.postAndCommentSpacing + + var avatarView: some View { + AvatarView( + url: avatar, + type: type, + avatarSize: AvatarBannerView.avatarSize, + lineWidth: 0, + iconResolution: .unrestricted + ) + } + + var body: some View { + Group { + if let banner, showBanner { + ZStack(alignment: .bottom) { + VStack { + LazyImage(url: banner) { state in + VStack { + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .clipped() + } else { + Color.secondarySystemBackground + } + } + .frame(minWidth: 0, maxWidth: .infinity) + .frame(height: AvatarBannerView.bannerHeight) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius)) + .mask { + ZStack(alignment: .bottom) { + Color.black + if showAvatar { + Circle() + .frame( + width: AvatarBannerView.avatarSize + AvatarBannerView.avatarPadding * 2, + height: AvatarBannerView.avatarSize + AvatarBannerView.avatarPadding * 2 + ) + .offset(y: AvatarBannerView.avatarOverdraw + AvatarBannerView.avatarPadding) + .blendMode(.destinationOut) + } + } + .compositingGroup() + } + + } + Spacer() + } + .overlay { + if showAvatar { + avatarView + .frame(maxHeight: .infinity, alignment: .bottom) + } + } + } + .frame(height: AvatarBannerView.bannerHeight + (showAvatar ? AvatarBannerView.avatarOverdraw : 0)) + } else { + if showAvatar { + avatarView + .padding(.top) + } + } + } + } +} diff --git a/Mlem/Views/Tabs/Profile/Profile View.swift b/Mlem/Views/Tabs/Profile/Profile View.swift index 048101249..2cede85e9 100644 --- a/Mlem/Views/Tabs/Profile/Profile View.swift +++ b/Mlem/Views/Tabs/Profile/Profile View.swift @@ -12,7 +12,7 @@ struct ProfileView: View { // appstorage @AppStorage("shouldShowUserHeaders") var shouldShowUserHeaders: Bool = true - let userID: Int + let user: UserModel? @StateObject private var profileTabNavigation: AnyNavigationPath = .init() @StateObject private var navigation: Navigation = .init() @@ -20,10 +20,15 @@ struct ProfileView: View { var body: some View { ScrollViewReader { proxy in NavigationStack(path: $profileTabNavigation.path) { - UserView(userID: userID) - .handleLemmyViews() - .environmentObject(profileTabNavigation) - .tabBarNavigationEnabled(.profile, navigation) + if let user { + UserView(user: user) + .handleLemmyViews() + .environmentObject(profileTabNavigation) + .tabBarNavigationEnabled(.profile, navigation) + } else { + LoadingView(whatIsLoading: .profile) + .fancyTabScrollCompatible() + } } .handleLemmyLinkResolution(navigationPath: .constant(profileTabNavigation)) .environment(\.navigationPathWithRoutes, $profileTabNavigation.path) diff --git a/Mlem/Views/Tabs/Profile/User View.swift b/Mlem/Views/Tabs/Profile/User View.swift deleted file mode 100644 index 4188c38a9..000000000 --- a/Mlem/Views/Tabs/Profile/User View.swift +++ /dev/null @@ -1,431 +0,0 @@ -// -// User View.swift -// Mlem -// -// Created by David Bureš on 02.04.2022. -// - -import Dependencies -import SwiftUI - -// swiftlint:disable file_length - -/// View for showing user profiles -/// Accepts the following parameters: -/// - **userID**: Non-optional ID of the user -struct UserView: View { - @Dependency(\.apiClient) var apiClient - @Dependency(\.errorHandler) var errorHandler - @Dependency(\.notifier) var notifier - @Dependency(\.personRepository) var personRepository - @Dependency(\.accountsTracker) var accountsTracker - - // appstorage - @AppStorage("shouldShowUserHeaders") var shouldShowUserHeaders: Bool = true - let internetSpeed: InternetSpeed - - // environment - @Environment(\.navigationPathWithRoutes) private var navigationPath - @Environment(\.scrollViewProxy) private var scrollViewProxy - @EnvironmentObject var appState: AppState - - // parameters - @State var userID: Int - @State var userDetails: APIPersonView? - - @StateObject private var privatePostTracker: PostTracker - @StateObject private var privateCommentTracker: CommentTracker = .init() - @State private var avatarSubtext: String = "" - @State private var showingCakeDay = false - @State private var moderatedCommunities: [APICommunityModeratorView] = [] - - @State private var selectionSection = UserViewTab.overview - @State private var errorDetails: ErrorDetails? - - @Namespace var scrollToTop - @State private var scrollToTopAppeared = false - - init(userID: Int, userDetails: APIPersonView? = nil) { - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast - @AppStorage("upvoteOnSave") var upvoteOnSave = false - - self.internetSpeed = internetSpeed - - self._userID = State(initialValue: userID) - self._userDetails = State(initialValue: userDetails) - - self._privatePostTracker = StateObject(wrappedValue: .init( - shouldPerformMergeSorting: false, - internetSpeed: internetSpeed, - upvoteOnSave: upvoteOnSave - )) - } - - // account switching - @State private var isPresentingAccountSwitcher: Bool = false - - var body: some View { - if let errorDetails { - ErrorView(errorDetails) - .fancyTabScrollCompatible() - .hoistNavigation() - } else { - contentView - .hoistNavigation { - if navigationPath.isEmpty { - withAnimation { - scrollViewProxy?.scrollTo(scrollToTop) - } - return true - } else { - if scrollToTopAppeared { - return false - } else { - withAnimation { - scrollViewProxy?.scrollTo(scrollToTop) - } - return true - } - } - } - .sheet(isPresented: $isPresentingAccountSwitcher) { - Form { - AccountListView() - } - } - } - } - - @ViewBuilder - private var contentView: some View { - if let userDetails { - view(for: userDetails) - } else { - progressView - } - } - - @ViewBuilder - private var moderatorButton: some View { - if let user = userDetails, !moderatedCommunities.isEmpty { - NavigationLink(.userModeratorLink(.init(user: user, moderatedCommunities: moderatedCommunities))) { - Image(systemName: Icons.moderation) - } - } - } - - @ViewBuilder - private var accountSwitcher: some View { - if isShowingOwnProfile() && accountsTracker.savedAccounts.count > 1 { - Button { - isPresentingAccountSwitcher = true - } label: { - Image(systemName: Icons.switchUser) - } - } - } - - private func header(for userDetails: APIPersonView) -> some View { - CommunitySidebarHeader( - title: userDetails.person.displayName ?? userDetails.person.name, - subtitle: "@\(userDetails.person.name)@\(userDetails.person.actorId.host()!)", - avatarSubtext: $avatarSubtext, - avatarSubtextClicked: toggleCakeDayVisible, - bannerURL: shouldShowUserHeaders ? userDetails.person.bannerUrl : nil, - avatarUrl: userDetails.person.avatarUrl, - label1: "\(userDetails.counts.commentCount) Comments", - label2: "\(userDetails.counts.postCount) Posts", - avatarType: .user - ) - } - - private func view(for userDetails: APIPersonView) -> some View { - ScrollView { - ScrollToView(appeared: $scrollToTopAppeared) - .id(scrollToTop) - - header(for: userDetails) - - if let bio = userDetails.person.bio { - MarkdownView(text: bio, isNsfw: false).padding() - } - - Picker(selection: $selectionSection, label: Text("Profile Section")) { - ForEach(UserViewTab.allCases, id: \.id) { tab in - // Skip tabs that are meant for only our profile - if tab.onlyShowInOwnProfile { - if isShowingOwnProfile() { - Text(tab.label).tag(tab.rawValue) - } - } else { - Text(tab.label).tag(tab.rawValue) - } - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - - UserFeedView( - userID: userID, - privatePostTracker: privatePostTracker, - privateCommentTracker: privateCommentTracker, - selectedTab: $selectionSection - ) - } - .fancyTabScrollCompatible() - .environmentObject(privatePostTracker) - .environmentObject(privateCommentTracker) - .navigationTitle(userDetails.person.displayName ?? userDetails.person.name) - .navigationBarTitleDisplayMode(.inline) - .navigationBarColor() - .headerProminence(.standard) - .refreshable { - await tryReloadUser() - }.toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - accountSwitcher - moderatorButton - } - } - } - - private func updateAvatarSubtext() { - if let user = userDetails { - if showingCakeDay { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "ddMMYY", options: 0, locale: Locale.current) - - avatarSubtext = "Joined \(dateFormatter.string(from: user.person.published))" - } else { - avatarSubtext = "Joined \(user.person.published.getRelativeTime(date: Date.now))" - } - } else { - avatarSubtext = "" - } - } - - private func toggleCakeDayVisible() { - showingCakeDay = !showingCakeDay - updateAvatarSubtext() - } - - private func isShowingOwnProfile() -> Bool { - appState.isCurrentAccountId(userID) - } - - @MainActor - private var progressView: some View { - ProgressView { - if isShowingOwnProfile() { - Text("Loading your profile…") - } else { - Text("Loading user profile…") - } - } - .task(priority: .userInitiated) { - await tryReloadUser() - } - } - - private func tryReloadUser() async { - do { - let authoredContent = try await personRepository.loadUserDetails(for: userID, limit: internetSpeed.pageSize) - var savedContentData: GetPersonDetailsResponse? - if isShowingOwnProfile() { - savedContentData = try await personRepository.loadUserDetails( - for: userID, - limit: internetSpeed.pageSize, - savedOnly: true - ) - } - - // accumulate comments and posts so we don't update state more than we need to - var newComments = authoredContent.comments - .sorted(by: { $0.comment.published > $1.comment.published }) - .map { HierarchicalComment(comment: $0, children: [], parentCollapsed: false, collapsed: false) } - - var newPosts = authoredContent.posts.map { PostModel(from: $0) } - - // add saved content, if present - if let savedContent = savedContentData { - newComments.append(contentsOf: - savedContent.comments - .sorted(by: { $0.comment.published > $1.comment.published }) - .map { HierarchicalComment(comment: $0, children: [], parentCollapsed: false, collapsed: false) }) - - newPosts.append(contentsOf: savedContent.posts.map { PostModel(from: $0) }) - } - - privateCommentTracker.comments = newComments - privatePostTracker.reset(with: newPosts) - - userDetails = authoredContent.personView - moderatedCommunities = authoredContent.moderates - updateAvatarSubtext() - - errorDetails = nil - } catch { - if userDetails == nil { - errorDetails = ErrorDetails(error: error, refresh: { - await tryReloadUser() - return userDetails != nil - }) - } else { - errorHandler.handle( - .init( - title: "Couldn't load user info", - message: "There was an error while loading user information.\nTry again later.", - underlyingError: error - ) - ) - } - } - } -} - -// TODO: darknavi - Move these to a common area for reuse -struct UserViewPreview: PreviewProvider { - static let previewAccount = SavedAccount( - id: 0, - instanceLink: URL(string: "lemmy.com")!, - accessToken: "abcdefg", - username: "Test Account" - ) - - // Only Admin and Bot work right now - // Because the rest require post/comment context - enum PreviewUserType: String, CaseIterable { - case normal - case mod - case op - case bot - case admin - case dev = "developer" - } - - static func generatePreviewUser( - name: String, - displayName: String, - userType: PreviewUserType - ) -> APIPerson { - .mock( - id: name.hashValue, - name: name, - displayName: displayName, - avatar: "https://lemmy.ml/pictrs/image/df86c06d-341c-4e79-9c80-d7c7eb64967a.jpeg?format=webp", - published: Date.now.advanced(by: -10000), - actorId: URL(string: "https://google.com")!, - bio: "Just here for the good vibes!", - banner: "https://i.imgur.com/wcayaCB.jpeg", - admin: userType == .admin, - botAccount: userType == .bot - ) - } - - static func generatePreviewComment(creator: APIPerson, isMod: Bool) -> APIComment { - APIComment( - id: 0, - creatorId: creator.id, - postId: 0, - content: "", - removed: false, - deleted: false, - published: Date.now, - updated: nil, - apId: "foo.bar", - local: false, - path: "foo", - distinguished: isMod, - languageId: 0 - ) - } - - static func generateFakeCommunity(id: Int, namePrefix: String) -> APICommunity { - .mock( - id: id, - name: "\(namePrefix) Fake Community \(id)", - title: "\(namePrefix) Fake Community \(id) Title", - description: "This is a fake community (#\(id))", - published: Date.now, - actorId: URL(string: "https://lemmy.google.com/c/\(id)")! - ) - } - - static func generatePreviewPost(creator: APIPerson) -> PostModel { - let community = generateFakeCommunity(id: 123, namePrefix: "Test") - let post: APIPost = .mock( - name: "Test Post Title", - body: "This is a test post body", - creatorId: creator.id, - embedDescription: "Embeedded Description", - embedTitle: "Embedded Title", - published: Date.now - ) - - let postVotes = APIPostAggregates( - id: 123, - postId: post.id, - comments: 0, - score: 10, - upvotes: 15, - downvotes: 5, - published: Date.now, - newestCommentTime: Date.now, - newestCommentTimeNecro: Date.now, - featuredCommunity: false, - featuredLocal: false - ) - - return PostModel(from: APIPostView( - post: post, - creator: creator, - community: community, - creatorBannedFromCommunity: false, - creatorIsModerator: false, - creatorIsAdmin: false, - counts: postVotes, - subscribed: .notSubscribed, - saved: false, - read: false, - creatorBlocked: false, - unreadComments: 0 - )) - } - - static func generateUserLinkView(name: String, userType: PreviewUserType) -> UserLinkView { - let previewUser = generatePreviewUser(name: name, displayName: name, userType: userType) - - var postContext: PostModel? - var commentContext: APIComment? - - if userType == .mod { - commentContext = generatePreviewComment(creator: previewUser, isMod: true) - } - - if userType == .op { - commentContext = generatePreviewComment(creator: previewUser, isMod: false) - postContext = generatePreviewPost(creator: previewUser) - } - - return UserLinkView( - user: UserModel(from: previewUser), - serverInstanceLocation: .bottom, - overrideShowAvatar: true, - postContext: postContext?.post, - commentContext: commentContext - ) - } - - static var previews: some View { - UserView( - userID: 123, - userDetails: APIPersonView( - person: generatePreviewUser(name: "actualUsername", displayName: "PreferredUsername", userType: .normal), - counts: APIPersonAggregates(id: 123, personId: 123, postCount: 123, postScore: 567, commentCount: 14, commentScore: 974), - isAdmin: false - ) - ).environmentObject(AppState()) - } -} - -// swiftlint:enable file_length diff --git a/Mlem/Views/Tabs/Profile/UserFeedView.swift b/Mlem/Views/Tabs/Profile/UserFeedView.swift index 424dc4cfb..1bf7303c3 100644 --- a/Mlem/Views/Tabs/Profile/UserFeedView.swift +++ b/Mlem/Views/Tabs/Profile/UserFeedView.swift @@ -6,11 +6,16 @@ // import SwiftUI +import Dependencies struct UserFeedView: View { - var userID: Int - @StateObject var privatePostTracker: PostTracker - @StateObject var privateCommentTracker: CommentTracker + @Dependency(\.siteInformation) var siteInformation + @EnvironmentObject var editorTracker: EditorTracker + + var user: UserModel + @ObservedObject var privatePostTracker: PostTracker + @ObservedObject var privateCommentTracker: CommentTracker + @ObservedObject var communityTracker: ContentTracker @Binding var selectedTab: UserViewTab @@ -27,38 +32,49 @@ struct UserFeedView: View { let hashValue: Int } + var isOwnProfile: Bool { + return siteInformation.myUserInfo?.localUserView.person.id == user.userId + } + var body: some View { - let feed = generateFeed() - .sorted(by: { - $0.published > $1.published - }) - if feed.isEmpty { - emptyFeed - } else { - if selectedTab == .posts { - VStack(spacing: 0) { - content(feed) + LazyVStack(spacing: 0) { + switch selectedTab { + case .communities: + Label( + "\(user.displayName) moderates \(communityTracker.items.count) communities.", + systemImage: Icons.moderationFill + ) + .foregroundStyle(.secondary) + .font(.footnote) + .padding(.vertical, 4) + Divider() + ForEach(communityTracker.items, id: \.uid) { community in + CommunityResultView(community, showTypeLabel: false, trackerCallback: { + communityTracker.update(with: $0) + }) + + Divider() } - } else { - LazyVStack(spacing: 0) { - content(feed) + .environmentObject(communityTracker) + default: + let feedItems = generateFeed() + if feedItems.isEmpty { + emptyFeed + } else { + ForEach(feedItems, id: \.uid) { feedItem in + if let post = feedItem.post { + postEntry(for: post) + } + if let comment = feedItem.comment { + commentEntry(for: comment) + } + } } } } } - func content(_ feed: [FeedItem]) -> some View { - ForEach(feed, id: \.uid) { feedItem in - if let post = feedItem.post { - postEntry(for: post) - } - if let comment = feedItem.comment { - commentEntry(for: comment) - } - } - } - func generateFeed() -> [FeedItem] { let feed: [FeedItem] switch selectedTab { @@ -70,9 +86,13 @@ struct UserFeedView: View { feed = generateCommentFeed() case .posts: feed = generatePostFeed() + default: + feed = [] } - return feed + return feed.sorted(by: { + $0.published > $1.published + }) } private func postEntry(for post: PostModel) -> some View { @@ -104,17 +124,21 @@ struct UserFeedView: View { } } + var emptyFeedText: String { + if isOwnProfile { + return "Nothing to see here, get out there and make some stuff!" + } else { + return "Nothing to see here." + } + } + @ViewBuilder private var emptyFeed: some View { - HStack { - Spacer() - Text("Nothing to see here, get out there and make some stuff!") - .padding() - .font(.headline) - .opacity(0.5) - Spacer() - } - .background() + Text(emptyFeedText) + .padding() + .font(.headline) + .opacity(0.5) + .multilineTextAlignment(.center) } private func generateCommentFeed(savedItems: Bool = false) -> [FeedItem] { @@ -126,7 +150,7 @@ struct UserFeedView: View { } else { // If we unfavorited something while // here we don't want it showing up in our feed - return $0.commentView.creator.id == userID + return $0.commentView.creator.id == user.userId } } @@ -151,7 +175,7 @@ struct UserFeedView: View { } else { // If we unfavorited something while // here we don't want it showing up in our feed - return $0.creator.userId == userID + return $0.creator.userId == user.userId } } diff --git a/Mlem/Views/Tabs/Profile/UserHeaderView.swift b/Mlem/Views/Tabs/Profile/UserHeaderView.swift new file mode 100644 index 000000000..1ab30c4dc --- /dev/null +++ b/Mlem/Views/Tabs/Profile/UserHeaderView.swift @@ -0,0 +1,74 @@ +// +// UserHeaderView.swift +// Mlem +// +// Created by Sjmarf on 27/12/2023. +// + +import SwiftUI +import NukeUI + +struct UserHeaderView: View { + @AppStorage("shouldShowUserHeaders") var shouldShowUserHeaders: Bool = true + @AppStorage("shouldShowUserAvatars") var shouldShowUserAvatars: Bool = true + + let user: UserModel + + static let bannerHeight: CGFloat = 170 + static let avatarOverdraw: CGFloat = 40 + static let avatarSize: CGFloat = 108 + static let avatarPadding: CGFloat = AppConstants.postAndCommentSpacing + + var body: some View { + Group { + if let banner = user.banner, shouldShowUserHeaders { + ZStack(alignment: .bottom) { + VStack { + LazyImage(url: banner) { state in + VStack { + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .clipped() + } else { + Color.secondarySystemBackground + } + } + .frame(minWidth: 0, maxWidth: .infinity) + .frame(height: UserHeaderView.bannerHeight) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius)) + .mask { + ZStack(alignment: .bottom) { + Color.black + if shouldShowUserAvatars { + Circle() + .frame( + width: UserHeaderView.avatarSize + UserHeaderView.avatarPadding * 2, + height: UserHeaderView.avatarSize + UserHeaderView.avatarPadding * 2 + ) + .offset(y: UserHeaderView.avatarOverdraw + UserHeaderView.avatarPadding) + .blendMode(.destinationOut) + } + } + .compositingGroup() + } + + } + Spacer() + } + if shouldShowUserAvatars { + AvatarView(user: user, avatarSize: UserHeaderView.avatarSize, lineWidth: 0, iconResolution: .unrestricted) + } + } + .frame(height: UserHeaderView.bannerHeight + (shouldShowUserAvatars ? UserHeaderView.avatarOverdraw : 0)) + } else { + if shouldShowUserAvatars { + AvatarView(user: user, avatarSize: UserHeaderView.avatarSize, lineWidth: 0, iconResolution: .unrestricted) + .padding(.top) + } + } + } + } +} diff --git a/Mlem/Views/Tabs/Profile/UserView+Logic.swift b/Mlem/Views/Tabs/Profile/UserView+Logic.swift new file mode 100644 index 000000000..21b6adc09 --- /dev/null +++ b/Mlem/Views/Tabs/Profile/UserView+Logic.swift @@ -0,0 +1,89 @@ +// +// UserView+Logic.swift +// Mlem +// +// Created by Sjmarf on 27/12/2023. +// + +import SwiftUI + +extension UserView { + var isOwnProfile: Bool { user.userId == siteInformation.myUserInfo?.localUserView.person.id } + + var tabs: [UserViewTab] { + var tabs: [UserViewTab] = [.overview, .posts, .comments] + if isOwnProfile { + tabs.append(.saved) + } + if !(user.moderatedCommunities?.isEmpty ?? true) { + tabs.append(.communities) + } + return tabs + } + + var cakeDayFormatter: DateFormatter { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "ddMMYY", options: 0, locale: Locale.current) + return dateFormatter + } + + var bioAlignment: TextAlignment { + if let bio = user.bio { + if bio.rangeOfCharacter(from: CharacterSet.newlines) != nil { + return .leading + } + if bio.count > 100 { + return .leading + } + } + return .center + } + + 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 ?? []) + + var savedContentData: GetPersonDetailsResponse? + if isOwnProfile { + savedContentData = try await personRepository.loadUserDetails( + for: user.userId, + limit: internetSpeed.pageSize, + savedOnly: true + ) + } + + // accumulate comments and posts so we don't update state more than we need to + var newComments = authoredContent.comments + .sorted(by: { $0.comment.published > $1.comment.published }) + .map { HierarchicalComment(comment: $0, children: [], parentCollapsed: false, collapsed: false) } + + var newPosts = authoredContent.posts.map { PostModel(from: $0) } + + // add saved content, if present + if let savedContent = savedContentData { + newComments.append(contentsOf: + savedContent.comments + .sorted(by: { $0.comment.published > $1.comment.published }) + .map { HierarchicalComment(comment: $0, children: [], parentCollapsed: false, collapsed: false) }) + + newPosts.append(contentsOf: savedContent.posts.map { PostModel(from: $0) }) + } + + privateCommentTracker.comments = newComments + privatePostTracker.reset(with: newPosts) + + self.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 + ) + ) + } + } +} diff --git a/Mlem/Views/Tabs/Profile/UserView.swift b/Mlem/Views/Tabs/Profile/UserView.swift new file mode 100644 index 000000000..2af52885b --- /dev/null +++ b/Mlem/Views/Tabs/Profile/UserView.swift @@ -0,0 +1,286 @@ +// +// NewUserView.swift +// Mlem +// +// Created by Sjmarf on 27/12/2023. +// + +import SwiftUI +import Dependencies + +// swiftlint:disable type_body_length +struct UserView: View { + @Dependency(\.apiClient) var apiClient + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier + @Dependency(\.personRepository) var personRepository + @Dependency(\.siteInformation) var siteInformation + + @Environment(\.navigationPathWithRoutes) private var navigationPath + @Environment(\.scrollViewProxy) private var scrollViewProxy + + let internetSpeed: InternetSpeed + let communityContext: CommunityModel? + + @State var user: UserModel + @State var selectedTab: UserViewTab = .overview + @State var isLoadingContent: Bool = true + + @State var isPresentingAccountSwitcher: Bool = false + + @StateObject var privatePostTracker: PostTracker + @StateObject var privateCommentTracker: CommentTracker = .init() + + @StateObject var communityTracker: ContentTracker = .init() + + @State private var isPresentingConfirmDestructive: Bool = false + @State private var confirmationMenuFunction: StandardMenuFunction? + + @Namespace var scrollToTop + @State private var scrollToTopAppeared = false + + func confirmDestructive(destructiveFunction: StandardMenuFunction) { + confirmationMenuFunction = destructiveFunction + isPresentingConfirmDestructive = true + } + + init(user: UserModel, communityContext: CommunityModel? = nil) { + @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast + @AppStorage("upvoteOnSave") var upvoteOnSave = false + + self.internetSpeed = internetSpeed + + self._privatePostTracker = StateObject(wrappedValue: .init( + shouldPerformMergeSorting: false, + internetSpeed: internetSpeed, + upvoteOnSave: upvoteOnSave + )) + + self._user = State(wrappedValue: user) + self.communityContext = communityContext + } + + var body: some View { + ScrollView { + ScrollToView(appeared: $scrollToTopAppeared) + .id(scrollToTop) + VStack(spacing: AppConstants.postAndCommentSpacing) { + AvatarBannerView(user: user) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.top, 10) + Button(action: user.copyFullyQualifiedUsername) { + VStack(spacing: 5) { + Text(user.displayName) + .font(.title.bold()) + .lineLimit(1) + .minimumScaleFactor(0.01) + Text(user.fullyQualifiedUsername ?? user.name) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .buttonStyle(.plain) + + flairs + + VStack(spacing: 0) { + let bioAlignment = bioAlignment + if let bio = user.bio { + Divider() + .padding(.bottom, AppConstants.postAndCommentSpacing) + MarkdownView(text: bio, isNsfw: false, alignment: bioAlignment).padding(AppConstants.postAndCommentSpacing) + + } + HStack { + Label(cakeDayFormatter.string(from: user.creationDate), systemImage: Icons.cakeDay) + Text("•") + Label(user.creationDate.getRelativeTime(date: Date.now, unitsStyle: .abbreviated), systemImage: Icons.time) + if bioAlignment == .leading { + Spacer() + } + } + .foregroundStyle(.secondary) + .font(.footnote) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.top, 2) + + Divider() + .padding(.top, AppConstants.postAndCommentSpacing * 2) + + if isLoadingContent { + VStack(spacing: 0) { + LoadingView(whatIsLoading: .content) + } + .transition(.opacity) + } else { + VStack(spacing: 0) { + BubblePicker(tabs, selected: $selectedTab) { tab in + switch tab { + case .posts: + Text("Posts (\(abbreviateNumber(user.postCount ?? 0)))") + case .comments: + Text("Comments (\(abbreviateNumber(user.commentCount ?? 0)))") + case .communities: + Text("Communities (\(abbreviateNumber(user.moderatedCommunities?.count ?? 0)))") + default: + Text(tab.label) + } + } + .padding(.vertical, 4) + Divider() + UserFeedView( + user: user, + privatePostTracker: privatePostTracker, + privateCommentTracker: privateCommentTracker, + communityTracker: communityTracker, + selectedTab: $selectedTab + ) + } + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.2), value: isLoadingContent) + } + } + .environmentObject(privatePostTracker) + .environmentObject(privateCommentTracker) + .destructiveConfirmation( + isPresentingConfirmDestructive: $isPresentingConfirmDestructive, + confirmationMenuFunction: confirmationMenuFunction + ) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + let functions = user.menuFunctions { user = $0 } + if functions.count == 1, let first = functions.first { + MenuButton(menuFunction: first, confirmDestructive: confirmDestructive) + } else { + Menu { + ForEach(functions) { item in + MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) + } + } label: { + Label("Menu", systemImage: Icons.menuCircle) + } + } + } + if isOwnProfile { + ToolbarItem(placement: .topBarLeading) { + Button("Switch Account", systemImage: Icons.switchUser) { + isPresentingAccountSwitcher = true + } + } + } + } + .task(priority: .userInitiated) { + if isLoadingContent { + await tryReloadUser() + } + } + .onChange(of: user.userId) { _ in + Task { + await tryReloadUser() + } + } + .refreshable { + await Task { + await tryReloadUser() + }.value + } + .hoistNavigation { + if navigationPath.isEmpty { + withAnimation { + scrollViewProxy?.scrollTo(scrollToTop) + } + return true + } else { + if scrollToTopAppeared { + return false + } else { + withAnimation { + scrollViewProxy?.scrollTo(scrollToTop) + } + return true + } + } + } + .fancyTabScrollCompatible() + .navigationTitle(user.displayName) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $isPresentingAccountSwitcher) { + Form { + AccountListView() + } + } + } + + var flairs: some View { + Group { + let flairs = user.getFlairs(communityContext: communityContext) + if !flairs.isEmpty { + VStack(spacing: AppConstants.postAndCommentSpacing) { + ForEach(flairs, id: \.self) { flair in + switch flair { + case .developer: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.developerFlair) + Text("Mlem Developer") + } + } + case .banned: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.bannedFlair) + if let expirationDate = user.banExpirationDate { + Text("Banned Until \(cakeDayFormatter.string(from: expirationDate))") + } else { + Text("Permanently Banned") + } + } + } + case .bot: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.botFlair) + Text("Bot Account") + } + } + case .admin: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.adminFlair) + let host = try? apiClient.session.instanceUrl.host() + Text("\(host ?? "Instance") Administrator") + } + } + case .moderator: + flairBackground(color: flair.color) { + HStack { + Image(systemName: Icons.moderationFill) + Text("\(communityContext?.displayName ?? "Community") Moderator") + } + } + default: + EmptyView() + } + } + } + .padding(.bottom, AppConstants.postAndCommentSpacing) + } + } + } + + @ViewBuilder + func flairBackground(color: Color, @ViewBuilder content: () -> Content) -> some View { + content() + .foregroundStyle(color) + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius).fill(color.opacity(0.2)) + ) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + } +} +// swiftlint:enable type_body_length diff --git a/Mlem/Views/Tabs/Search/BubblePicker.swift b/Mlem/Views/Tabs/Search/BubblePicker.swift new file mode 100644 index 000000000..7d159352c --- /dev/null +++ b/Mlem/Views/Tabs/Search/BubblePicker.swift @@ -0,0 +1,71 @@ +// +// SearchTabPicker.swift +// Mlem +// +// Created by Sjmarf on 18/09/2023. +// + +import SwiftUI +import Dependencies + +struct BubblePicker: View { + @Dependency(\.hapticManager) var hapticManager + + @Binding var selected: Value + let tabs: [Value] + @ViewBuilder let labelBuilder: (Value) -> any View + + init( + _ tabs: [Value], + selected: Binding, + @ViewBuilder labelBuilder: @escaping (Value) -> any View + ) { + self._selected = selected + self.tabs = tabs + self.labelBuilder = labelBuilder + } + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(tabs, id: \.self) { type in + Button { + selected = type + hapticManager.play(haptic: .gentleInfo, priority: .low) + } label: { + AnyView(labelBuilder(type)) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .foregroundStyle(selected == type ? .white : .primary) + .font(.subheadline) + .fontWeight(.semibold) + .background( + Group { + if selected == type { + Capsule() + .fill(.blue) + .transition(.scale.combined(with: .opacity)) + } + } + ) + .animation(.spring(response: 0.15, dampingFraction: 0.7), value: selected) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(EmptyButtonStyle()) + } + } + .padding(.horizontal, AppConstants.postAndCommentSpacing) + } + .scrollIndicators(.hidden) + } +} + +#Preview { + BubblePicker( + SearchTab.allCases, + selected: .constant(.communities) + ) { + Text($0.label) + } +} diff --git a/Mlem/Views/Tabs/Search/RecentSearchesView.swift b/Mlem/Views/Tabs/Search/RecentSearchesView.swift index 2aacffa3f..4ed143a83 100644 --- a/Mlem/Views/Tabs/Search/RecentSearchesView.swift +++ b/Mlem/Views/Tabs/Search/RecentSearchesView.swift @@ -73,15 +73,21 @@ struct RecentSearchesView: View { Group { if let community = contentModel.wrappedValue as? CommunityModel { CommunityResultView( - community: community, + community, showTypeLabel: true, - swipeActions: .init(trailingActions: [deleteSwipeAction(contentModel)]) + swipeActions: .init(trailingActions: [deleteSwipeAction(contentModel)]), + trackerCallback: { + contentTracker.update(with: AnyContentModel($0)) + } ) } else if let user = contentModel.wrappedValue as? UserModel { UserResultView( - user: user, + user, showTypeLabel: true, - swipeActions: .init(trailingActions: [deleteSwipeAction(contentModel)]) + swipeActions: .init(trailingActions: [deleteSwipeAction(contentModel)]), + trackerCallback: { + contentTracker.update(with: AnyContentModel($0)) + } ) } } diff --git a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift b/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift index 06abe20d1..6891f59e3 100644 --- a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift +++ b/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift @@ -12,15 +12,28 @@ struct CommunityResultView: View { @Dependency(\.apiClient) private var apiClient @Dependency(\.hapticManager) var hapticManager - @EnvironmentObject var contentTracker: ContentTracker - let community: CommunityModel let showTypeLabel: Bool - var swipeActions: SwipeConfiguration? + let trackerCallback: (_ item: CommunityModel) -> Void + let swipeActions: SwipeConfiguration? @State private var isPresentingConfirmDestructive: Bool = false @State private var confirmationMenuFunction: StandardMenuFunction? + @EnvironmentObject var editorTracker: EditorTracker + + init( + _ community: CommunityModel, + showTypeLabel: Bool = false, + swipeActions: SwipeConfiguration? = nil, + trackerCallback: @escaping (_ item: CommunityModel) -> Void = { _ in } + ) { + self.community = community + self.showTypeLabel = showTypeLabel + self.swipeActions = swipeActions + self.trackerCallback = trackerCallback + } + func confirmDestructive(destructiveFunction: StandardMenuFunction) { confirmationMenuFunction = destructiveFunction isPresentingConfirmDestructive = true @@ -47,6 +60,26 @@ struct CommunityResultView: View { return "Unknown instance" } + var subscriberCountColor: Color { + if community.favorited { + return .blue + } + if community.subscribed ?? false { + return .green + } + return .secondary + } + + var subscriberCountIcon: String { + if community.favorited { + return Icons.favoriteFill + } + if community.subscribed ?? false { + return Icons.subscribed + } + return Icons.personFill + } + var body: some View { NavigationLink(value: AppRoute.community(community)) { HStack(spacing: 10) { @@ -62,6 +95,7 @@ struct CommunityResultView: View { VStack(alignment: .leading, spacing: 4) { Text(title) + .lineLimit(1) .foregroundStyle(community.nsfw ? .red : .primary) Text(caption) .font(.footnote) @@ -69,12 +103,14 @@ struct CommunityResultView: View { .lineLimit(1) } Spacer() - HStack(spacing: 5) { - Text(abbreviateNumber(community.subscriberCount ?? 0)) - .monospacedDigit() - Image(systemName: (community.subscribed ?? false) ? Icons.subscribed : Icons.personFill) + if let subscriberCount = community.subscriberCount { + HStack(spacing: 5) { + Text(abbreviateNumber(subscriberCount)) + .monospacedDigit() + Image(systemName: subscriberCountIcon) + } + .foregroundStyle(subscriberCountColor) } - .foregroundStyle((community.subscribed ?? false) ? .green : .secondary) Image(systemName: Icons.forward) .imageScale(.small) .foregroundStyle(.tertiary) @@ -99,13 +135,14 @@ struct CommunityResultView: View { isPresentingConfirmDestructive: $isPresentingConfirmDestructive, confirmationMenuFunction: confirmationMenuFunction ) - .addSwipeyActions(swipeActions ?? community.swipeActions({ - contentTracker.update(with: AnyContentModel($0)) - }, confirmDestructive: confirmDestructive)) + .addSwipeyActions(swipeActions ?? community.swipeActions(trackerCallback, confirmDestructive: confirmDestructive)) .contextMenu { - ForEach(community.menuFunctions { - contentTracker.update(with: AnyContentModel($0)) - }) { item in + ForEach( + community.menuFunctions( + trackerCallback, + editorTracker: editorTracker + ) + ) { item in MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) } } @@ -114,7 +151,7 @@ struct CommunityResultView: View { #Preview { CommunityResultView( - community: .init(from: .mock()), + .init(from: .mock()), showTypeLabel: true ) } diff --git a/Mlem/Views/Tabs/Search/Results/UserResultView.swift b/Mlem/Views/Tabs/Search/Results/UserResultView.swift index b25c8bbf4..b19a17292 100644 --- a/Mlem/Views/Tabs/Search/Results/UserResultView.swift +++ b/Mlem/Views/Tabs/Search/Results/UserResultView.swift @@ -15,12 +15,28 @@ struct UserResultView: View { @EnvironmentObject var contentTracker: ContentTracker let user: UserModel + let communityContext: CommunityModel? let showTypeLabel: Bool - var swipeActions: SwipeConfiguration? + let trackerCallback: (_ item: UserModel) -> Void + let swipeActions: SwipeConfiguration? @State private var isPresentingConfirmDestructive: Bool = false @State private var confirmationMenuFunction: StandardMenuFunction? + init( + _ user: UserModel, + communityContext: CommunityModel? = nil, + showTypeLabel: Bool = false, + swipeActions: SwipeConfiguration? = nil, + trackerCallback: @escaping (_ item: UserModel) -> Void = { _ in } + ) { + self.user = user + self.communityContext = communityContext + self.showTypeLabel = showTypeLabel + self.swipeActions = swipeActions + self.trackerCallback = trackerCallback + } + func confirmDestructive(destructiveFunction: StandardMenuFunction) { confirmationMenuFunction = destructiveFunction isPresentingConfirmDestructive = true @@ -28,9 +44,9 @@ struct UserResultView: View { var title: String { if user.blocked { - return "\(user.name) ∙ Blocked" + return "\(user.displayName) ∙ Blocked" } else { - return user.name + return user.displayName } } @@ -48,7 +64,7 @@ struct UserResultView: View { } var body: some View { - NavigationLink(value: AppRoute.userProfile(user)) { + NavigationLink(value: AppRoute.userProfile(user, communityContext: communityContext)) { HStack(spacing: 10) { if user.blocked { Image(systemName: Icons.hide) @@ -59,7 +75,7 @@ struct UserResultView: View { } else { AvatarView(user: user, avatarSize: 48, iconResolution: .fixed(128)) } - let flairs = user.getFlairs() + let flairs = user.getFlairs(communityContext: communityContext) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { ForEach(flairs, id: \.self) { flair in @@ -68,6 +84,7 @@ struct UserResultView: View { .foregroundStyle(flair.color) } Text(title) + .lineLimit(1) } Text(caption) .font(.footnote) @@ -132,9 +149,7 @@ struct UserResultView: View { ) .addSwipeyActions(swipeActions ?? .init()) .contextMenu { - ForEach(user.menuFunctions { - contentTracker.update(with: AnyContentModel($0)) - }) { item in + ForEach(user.menuFunctions(trackerCallback)) { item in MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) } } @@ -143,7 +158,7 @@ struct UserResultView: View { #Preview { UserResultView( - user: .init(from: .mock()), + .init(from: .mock()), showTypeLabel: true ) } diff --git a/Mlem/Views/Tabs/Search/SearchHomeView.swift b/Mlem/Views/Tabs/Search/SearchHomeView.swift index 7a68f1163..df450a299 100644 --- a/Mlem/Views/Tabs/Search/SearchHomeView.swift +++ b/Mlem/Views/Tabs/Search/SearchHomeView.swift @@ -20,13 +20,10 @@ struct SearchHomeView: View { .fontWeight(.semibold) .padding(.horizontal, 18) .padding(.top, 12) - ScrollView(.horizontal) { - SearchTabPicker(selected: $searchModel.searchTab, tabs: SearchTab.homePageCases) - .padding(.horizontal) + BubblePicker(SearchTab.homePageCases, selected: $searchModel.searchTab) { + Text($0.label) } - .scrollIndicators(.hidden) - .padding(.top, 8) - .padding(.bottom, 12) + .padding(.bottom, 10) Divider() SearchResultListView(showTypeLabel: false) } diff --git a/Mlem/Views/Tabs/Search/SearchResultListView.swift b/Mlem/Views/Tabs/Search/SearchResultListView.swift index c50c20899..97c46ccd7 100644 --- a/Mlem/Views/Tabs/Search/SearchResultListView.swift +++ b/Mlem/Views/Tabs/Search/SearchResultListView.swift @@ -21,9 +21,13 @@ struct SearchResultListView: View { ForEach(contentTracker.items, id: \.uid) { contentModel in Group { if let community = contentModel.wrappedValue as? CommunityModel { - CommunityResultView(community: community, showTypeLabel: showTypeLabel) + CommunityResultView(community, showTypeLabel: showTypeLabel, trackerCallback: { + contentTracker.update(with: AnyContentModel($0)) + }) } else if let user = contentModel.wrappedValue as? UserModel { - UserResultView(user: user, showTypeLabel: showTypeLabel) + UserResultView(user, showTypeLabel: showTypeLabel, trackerCallback: { + contentTracker.update(with: AnyContentModel($0)) + }) } } .simultaneousGesture(TapGesture().onEnded { diff --git a/Mlem/Views/Tabs/Search/SearchResultsView.swift b/Mlem/Views/Tabs/Search/SearchResultsView.swift index c17ca9856..3d818c01c 100644 --- a/Mlem/Views/Tabs/Search/SearchResultsView.swift +++ b/Mlem/Views/Tabs/Search/SearchResultsView.swift @@ -41,11 +41,9 @@ struct SearchResultsView: View { @ViewBuilder private var tabs: some View { HStack { - ScrollView(.horizontal) { - SearchTabPicker(selected: $searchModel.searchTab) - .padding(.horizontal) + BubblePicker(SearchTab.allCases, selected: $searchModel.searchTab) { + Text($0.label) } - .scrollIndicators(.hidden) Group { if contentTracker.isLoading && contentTracker.page == 1 && !shouldLoad { ProgressView() @@ -55,7 +53,6 @@ struct SearchResultsView: View { } .animation(.default, value: contentTracker.isLoading) } - .padding(.vertical, 4) } } diff --git a/Mlem/Views/Tabs/Search/SearchTabPicker.swift b/Mlem/Views/Tabs/Search/SearchTabPicker.swift deleted file mode 100644 index af6b6d4de..000000000 --- a/Mlem/Views/Tabs/Search/SearchTabPicker.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// SearchTabPicker.swift -// Mlem -// -// Created by Sjmarf on 18/09/2023. -// - -import SwiftUI -import Dependencies - -enum SearchTab: String, CaseIterable { - case topResults, communities, users - - var label: String { - switch self { - case .topResults: - return "Top Results" - default: - return rawValue.capitalized - } - } - - static var homePageCases: [SearchTab] = [.communities, .users] -} - -struct SearchTabPicker: View { - @Dependency(\.hapticManager) var hapticManager - - @Binding var selected: SearchTab - var tabs: [SearchTab] = SearchTab.allCases - - var body: some View { - HStack(spacing: 0) { - ForEach(tabs, id: \.self) { type in - Button { - selected = type - hapticManager.play(haptic: .gentleInfo, priority: .low) - } label: { - Text(type.label) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .contentShape(Rectangle()) - .foregroundStyle(selected == type ? .white : .primary) - .font(.subheadline) - .fontWeight(.semibold) - .background( - Group { - if selected == type { - Capsule() - .fill(.blue) - .transition(.scale.combined(with: .opacity)) - } - } - ) - .animation(.spring(response: 0.15, dampingFraction: 0.7), value: selected) - } - .buttonStyle(EmptyButtonStyle()) - } - } - } -} - -#Preview { - SearchTabPicker( - selected: .constant(.communities), - tabs: SearchTab.homePageCases - ) -} diff --git a/Mlem/Views/Tabs/Settings/Components/AccountButtonView.swift b/Mlem/Views/Tabs/Settings/Components/AccountButtonView.swift index 332498791..e6405851c 100644 --- a/Mlem/Views/Tabs/Settings/Components/AccountButtonView.swift +++ b/Mlem/Views/Tabs/Settings/Components/AccountButtonView.swift @@ -13,8 +13,10 @@ struct AccountButtonView: View { @EnvironmentObject var appState: AppState @Dependency(\.accountsTracker) var accountsTracker: SavedAccountTracker @Environment(\.setAppFlow) private var setFlow + @Environment(\.dismiss) var dismiss @State var showingSignOutConfirmation: Bool = false + @Binding var isSwitching: Bool enum CaptionState { case instanceOnly, timeOnly, instanceAndTime @@ -23,9 +25,10 @@ struct AccountButtonView: View { let account: SavedAccount let caption: CaptionState - init(account: SavedAccount, caption: CaptionState = .instanceAndTime) { + init(account: SavedAccount, caption: CaptionState = .instanceAndTime, isSwitching: Binding) { self.account = account self.caption = caption + self._isSwitching = isSwitching } var timeText: String? { @@ -126,6 +129,7 @@ struct AccountButtonView: View { private func setFlow(using account: SavedAccount?) { if let account { + isSwitching = true setFlow(.account(account)) return } diff --git a/Mlem/Views/Tabs/Settings/Components/AccountListView+Logic.swift b/Mlem/Views/Tabs/Settings/Components/AccountListView+Logic.swift index 431809261..845d475b7 100644 --- a/Mlem/Views/Tabs/Settings/Components/AccountListView+Logic.swift +++ b/Mlem/Views/Tabs/Settings/Components/AccountListView+Logic.swift @@ -20,7 +20,7 @@ extension AccountListView { if appState.currentActiveAccount == $0 { return true } else if appState.currentActiveAccount == $1 { - return true + return false } return $0.lastUsed ?? .distantPast > $1.lastUsed ?? .distantPast } diff --git a/Mlem/Views/Tabs/Settings/Components/AccountListView.swift b/Mlem/Views/Tabs/Settings/Components/AccountListView.swift index a137283f4..d4c73ec33 100644 --- a/Mlem/Views/Tabs/Settings/Components/AccountListView.swift +++ b/Mlem/Views/Tabs/Settings/Components/AccountListView.swift @@ -31,6 +31,8 @@ struct AccountListView: View { @State private var isShowingInstanceAdditionSheet: Bool = false + @State var isSwitching: Bool = false + struct AccountGroup { let header: String let accounts: [SavedAccount] @@ -47,38 +49,41 @@ struct AccountListView: View { var body: some View { Group { - if accountsTracker.savedAccounts.count > 3 && groupAccountSort { - ForEach(Array(accountGroups.enumerated()), id: \.offset) { offset, group in - Section { - ForEach(group.accounts, id: \.self) { account in - AccountButtonView( - account: account, - caption: accountSort != .instance || group.header == "Other" ? .instanceAndTime : .timeOnly - ) + if !isSwitching { + if accountsTracker.savedAccounts.count > 3 && groupAccountSort { + ForEach(Array(accountGroups.enumerated()), id: \.offset) { offset, group in + Section { + ForEach(group.accounts, id: \.self) { account in + AccountButtonView( + account: account, + caption: accountSort != .instance || group.header == "Other" ? .instanceAndTime : .timeOnly, + isSwitching: $isSwitching + ) + } + } header: { + if offset == 0 { + topHeader(text: group.header) + } else { + Text(group.header) + } } - } header: { - if offset == 0 { - topHeader(text: group.header) - } else { - Text(group.header) + } + } else { + Section(header: topHeader()) { + ForEach(accounts, id: \.self) { account in + AccountButtonView(account: account, isSwitching: $isSwitching) } } } - } else { - Section(header: topHeader()) { - ForEach(accounts, id: \.self) { account in - AccountButtonView(account: account) + Section { + Button { + isShowingInstanceAdditionSheet = true + } label: { + Label("Add Account", systemImage: "plus") } + .accessibilityLabel("Add a new account.") } } - Section { - Button { - isShowingInstanceAdditionSheet = true - } label: { - Label("Add Account", systemImage: "plus") - } - .accessibilityLabel("Add a new account.") - } } .sheet(isPresented: $isShowingInstanceAdditionSheet) { AddSavedInstanceView(onboarding: false) diff --git a/Mlem/Window.swift b/Mlem/Window.swift index a6848658d..49da35c05 100644 --- a/Mlem/Window.swift +++ b/Mlem/Window.swift @@ -52,6 +52,7 @@ struct Window: View { favoriteCommunitiesTracker.clearStoredAccount() case let .account(account): var account = account + siteInformation.myUserInfo = nil appState.setActiveAccount(account, saveChanges: false) siteInformation.load(account: account) favoriteCommunitiesTracker.configure(for: account)