diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index 4e7762481..fcccc1cb2 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -7,6 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 0300F2C62B9C7D4B0022F7C4 /* CloseButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0300F2C52B9C7D4B0022F7C4 /* CloseButtonView.swift */; }; + 030245C02BA607EA00D07747 /* FeedToolbarContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030245BF2BA607EA00D07747 /* FeedToolbarContent.swift */; }; + 030245C22BA60A2600D07747 /* ToolbarEllipsisMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030245C12BA60A2600D07747 /* ToolbarEllipsisMenu.swift */; }; + 030245C42BA6123A00D07747 /* RemoveCommunityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030245C32BA6123A00D07747 /* RemoveCommunityView.swift */; }; + 030245C62BA6138100D07747 /* RemoveCommunityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030245C52BA6138100D07747 /* RemoveCommunityRequest.swift */; }; + 030245C82BA617FE00D07747 /* PurgeCommunityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030245C72BA617FE00D07747 /* PurgeCommunityRequest.swift */; }; + 030245CA2BA70F5200D07747 /* LinksSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030245C92BA70F5200D07747 /* LinksSettingsView.swift */; }; 0304F58A2B44AF5B00537BFA /* CollapsibleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0304F5892B44AF5B00537BFA /* CollapsibleSection.swift */; }; 0308E1142B0EA32A000CA955 /* AccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0308E1132B0EA32A000CA955 /* AccountSettingsView.swift */; }; 0308E1162B0EA42B000CA955 /* APILocalUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0308E1152B0EA42B000CA955 /* APILocalUserView.swift */; }; @@ -26,6 +33,8 @@ 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 */; }; + 030FF6862BCB218000F6BFAC /* Int+Abbreviated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FF6852BCB218000F6BFAC /* Int+Abbreviated.swift */; }; + 030FF6882BCEE58900F6BFAC /* BlockInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FF6872BCEE58900F6BFAC /* BlockInstance.swift */; }; 0317D46F2B558CB500EEE72C /* BadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0317D46E2B558CB500EEE72C /* BadgeView.swift */; }; 0317D4712B55AE0700EEE72C /* Color+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0317D4702B55AE0700EEE72C /* Color+Hex.swift */; }; 031A617C2B1BDFD100ABF23B /* AdvancedAccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031A617B2B1BDFD100ABF23B /* AdvancedAccountSettingsView.swift */; }; @@ -40,12 +49,15 @@ 032C1E042B5D7DAC00FB4F23 /* QuickSwitcherSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C1E032B5D7DAC00FB4F23 /* QuickSwitcherSettingsView.swift */; }; 032C1E062B5DBDB100FB4F23 /* LocalAccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032C1E052B5DBDB100FB4F23 /* LocalAccountSettingsView.swift */; }; 032DD2FD2AC3594B00F1B33D /* LinkAttatchmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032DD2FC2AC3594B00F1B33D /* LinkAttatchmentView.swift */; }; + 033EC0AF2BD3030A00AA238F /* BlockListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033EC0AE2BD3030A00AA238F /* BlockListView+Logic.swift */; }; 034C724F2A82B61200B8A4B8 /* LayoutWidgetTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 034C724E2A82B61200B8A4B8 /* LayoutWidgetTracker.swift */; }; + 0355A1DE2BB1F12500D54F9F /* ModerationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0355A1DD2BB1F12500D54F9F /* ModerationSettingsView.swift */; }; 0355DA4D2B5EB51900CDF5A5 /* InstanceModel+ContentModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0355DA4C2B5EB51900CDF5A5 /* InstanceModel+ContentModel.swift */; }; 0355DA4F2B5EB63600CDF5A5 /* InstanceStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0355DA4E2B5EB63600CDF5A5 /* InstanceStub.swift */; }; 0355DA512B5EB87700CDF5A5 /* InstanceResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0355DA502B5EB87700CDF5A5 /* InstanceResultView.swift */; }; 035EB0CA2A8687C200227859 /* JumpButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035EB0C92A8687C200227859 /* JumpButtonView.swift */; }; 036ED3BC2ABF1058009664BC /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036ED3BB2ABF1058009664BC /* SearchModel.swift */; }; + 038142F22BB46FFF00856C9B /* CommentItem+MenuFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038142F12BB46FFF00856C9B /* CommentItem+MenuFunctions.swift */; }; 038A16DF2A75172C0087987E /* LayoutWidgetEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038A16DE2A75172C0087987E /* LayoutWidgetEditView.swift */; }; 038A16E12A75AA880087987E /* LayoutWidgetModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038A16E02A75AA880087987E /* LayoutWidgetModel.swift */; }; 038A16E52A7A97380087987E /* LayoutWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 038A16E42A7A97380087987E /* LayoutWidgetView.swift */; }; @@ -54,6 +66,13 @@ 0394398F2A98EB2300463032 /* APIComment+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0394398E2A98EB2300463032 /* APIComment+Mock.swift */; }; 039439912A98FA6100463032 /* UserFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039439902A98FA6100463032 /* UserFeedView.swift */; }; 039439932A99098900463032 /* InternetConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039439922A99098900463032 /* InternetConnectionManager.swift */; }; + 039B4FE92BD2D81D00E42114 /* BlockListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039B4FE82BD2D81D00E42114 /* BlockListView.swift */; }; + 039C59A72BADA04100C18765 /* RemoveCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C59A62BADA04100C18765 /* RemoveCommentView.swift */; }; + 039C59A92BADA5DA00C18765 /* PurgeCommentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C59A82BADA5DA00C18765 /* PurgeCommentRequest.swift */; }; + 039C59AB2BADC85400C18765 /* RemoveCommentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C59AA2BADC85400C18765 /* RemoveCommentRequest.swift */; }; + 039C59AD2BADFF6200C18765 /* ListPostLikesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C59AC2BADFF6200C18765 /* ListPostLikesRequest.swift */; }; + 039C59AF2BAE029300C18765 /* VotesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C59AE2BAE029300C18765 /* VotesListView.swift */; }; + 039C59B12BAF272E00C18765 /* VotesTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C59B02BAF272E00C18765 /* VotesTracker.swift */; }; 039C8DB72B35A32D0096BAAF /* AccountSwitcherSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C8DB62B35A32D0096BAAF /* AccountSwitcherSettingsView.swift */; }; 039C8DB92B35A81C0096BAAF /* AccountIconStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C8DB82B35A81C0096BAAF /* AccountIconStack.swift */; }; 039C8DBB2B35B2EB0096BAAF /* AccountListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C8DBA2B35B2EB0096BAAF /* AccountListView.swift */; }; @@ -69,17 +88,32 @@ 03A276792AFD903600C0D66B /* CommunityModel+MenuFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A276782AFD903600C0D66B /* CommunityModel+MenuFunctions.swift */; }; 03A2767B2AFE560000C0D66B /* CommunityModel+SwipeActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A2767A2AFE560000C0D66B /* CommunityModel+SwipeActions.swift */; }; 03A2767D2AFE656700C0D66B /* UserModel+MenuFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A2767C2AFE656700C0D66B /* UserModel+MenuFunctions.swift */; }; + 03A4330B2B6FB2C10004E743 /* FediseerOpinionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A4330A2B6FB2C10004E743 /* FediseerOpinionListView.swift */; }; + 03A4330D2B6FC0940004E743 /* FediseerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A4330C2B6FC0940004E743 /* FediseerInfoView.swift */; }; + 03A4330F2B7186C20004E743 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A4330E2B7186C20004E743 /* WebView.swift */; }; 03A54C322B5331F30064CCDE /* InstanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A54C312B5331F30064CCDE /* InstanceView.swift */; }; 03A54C352B533BC50064CCDE /* InstanceModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A54C342B533BC50064CCDE /* InstanceModel.swift */; }; 03A54C372B545A430064CCDE /* InstanceModel+MenuFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A54C362B545A430064CCDE /* InstanceModel+MenuFunctions.swift */; }; + 03AFBEA32B6EA86B00F01F3C /* SiteResponse+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFBEA22B6EA86B00F01F3C /* SiteResponse+Mock.swift */; }; + 03AFBEA52B6EA90400F01F3C /* APIPersonView+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFBEA42B6EA90400F01F3C /* APIPersonView+Mock.swift */; }; + 03AFBEA72B6EA94900F01F3C /* APIPersonAggregates+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFBEA62B6EA94900F01F3C /* APIPersonAggregates+Mock.swift */; }; + 03AFBEA92B6EA9BC00F01F3C /* APISiteView+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFBEA82B6EA9BC00F01F3C /* APISiteView+Mock.swift */; }; + 03AFBEAB2B6EAA0C00F01F3C /* APILocalSite+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFBEAA2B6EAA0C00F01F3C /* APILocalSite+Mock.swift */; }; + 03AFBEAD2B6EAAF000F01F3C /* APILocalSiteRateLimit+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFBEAC2B6EAAF000F01F3C /* APILocalSiteRateLimit+Mock.swift */; }; + 03AFBEAF2B6EAB9C00F01F3C /* APISiteAggregates+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFBEAE2B6EAB9C00F01F3C /* APISiteAggregates+Mock.swift */; }; + 03AFBEB12B6EAD5B00F01F3C /* InstanceSafetyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFBEB02B6EAD5B00F01F3C /* InstanceSafetyView.swift */; }; + 03AFBEB32B6EB8B800F01F3C /* Line.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AFBEB22B6EB8B800F01F3C /* Line.swift */; }; 03B15BED2B55CBBB00E7C30A /* MarkdownTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B15BEC2B55CBBB00E7C30A /* MarkdownTheme.swift */; }; 03B643572A6864CD00F65700 /* TabBarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B643562A6864CD00F65700 /* TabBarSettingsView.swift */; }; 03B7AAEF2ABCB9DC00068B23 /* ContentTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B7AAEE2ABCB9DC00068B23 /* ContentTracker.swift */; }; 03B7AAF12ABE404300068B23 /* ContentModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B7AAF02ABE404300068B23 /* ContentModel.swift */; }; 03B7AAF32ABEF85300068B23 /* UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B7AAF22ABEF85200068B23 /* UserModel.swift */; }; - 03B7AAF52ABEFA7A00068B23 /* UserResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B7AAF42ABEFA7A00068B23 /* UserResultView.swift */; }; + 03B7AAF52ABEFA7A00068B23 /* UserListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B7AAF42ABEFA7A00068B23 /* UserListRow.swift */; }; + 03B85A3C2BB34D1F003C4203 /* PurgeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B85A3B2BB34D1F003C4203 /* PurgeContentView.swift */; }; + 03B85A3E2BB36C4B003C4203 /* UserRemovalWalker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B85A3D2BB36C4B003C4203 /* UserRemovalWalker.swift */; }; + 03B85A402BB38868003C4203 /* PostEllipsisMenus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B85A3F2BB38868003C4203 /* PostEllipsisMenus.swift */; }; 03BAA23A2A57DC1400D48252 /* PublishedTimestampView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03BAA2392A57DC1400D48252 /* PublishedTimestampView.swift */; }; - 03C897F62ABF49BD005F3403 /* Abbreviate Numbers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C897F52ABF49BD005F3403 /* Abbreviate Numbers.swift */; }; + 03C194082BA25B5200B00349 /* ProgressOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C194072BA25B5200B00349 /* ProgressOverlayView.swift */; }; 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 */; }; @@ -87,10 +121,20 @@ 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 */; }; + 03C942922B6457B4002068A4 /* BanUserEditorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C942912B6457B4002068A4 /* BanUserEditorModel.swift */; }; + 03C942962B648252002068A4 /* BanPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C942952B648252002068A4 /* BanPerson.swift */; }; 03CB329E2A6D8E910021EF27 /* PostComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CB329D2A6D8E910021EF27 /* PostComposerView.swift */; }; + 03CEE04B2B6EB9CD00D65B1B /* Fediseer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CEE04A2B6EB9CD00D65B1B /* Fediseer.swift */; }; + 03CEE04D2B6EBEA800D65B1B /* InstanceView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CEE04C2B6EBEA800D65B1B /* InstanceView+Logic.swift */; }; + 03CEE04F2B6ECFFC00D65B1B /* FediseerOpinionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CEE04E2B6ECFFC00D65B1B /* FediseerOpinionView.swift */; }; + 03CEE0512B6EED2C00D65B1B /* Array+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CEE0502B6EED2C00D65B1B /* Array+IsNotEmpty.swift */; }; + 03D89E722BB1BB0100F49DB3 /* ListCommentLikesRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D89E712BB1BB0100F49DB3 /* ListCommentLikesRequest.swift */; }; 03E0B9C82A61F0F400FED265 /* AdvancedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C72A61F0F400FED265 /* AdvancedSettingsView.swift */; }; 03E0B9CA2A62B4A400FED265 /* ContributorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9C92A62B4A400FED265 /* ContributorsView.swift */; }; 03E0B9CC2A62CD5800FED265 /* ThemeSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E0B9CB2A62CD5800FED265 /* ThemeSettingsView.swift */; }; + 03E47AEB2B66BADC00A3E4DB /* UptimeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E47AEA2B66BADC00A3E4DB /* UptimeData.swift */; }; + 03E47AED2B66BC0000A3E4DB /* InstanceUptimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E47AEC2B66BC0000A3E4DB /* InstanceUptimeView.swift */; }; + 03E47AEF2B66BD3C00A3E4DB /* InstanceModel+Uptime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E47AEE2B66BD3C00A3E4DB /* InstanceModel+Uptime.swift */; }; 03E79F3F2AE3E7100006700D /* SortingSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E79F3E2AE3E7100006700D /* SortingSettingsView.swift */; }; 03E90FB12B3703ED00E5A802 /* AccountSortMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E90FB02B3703ED00E5A802 /* AccountSortMode.swift */; }; 03EA79C42AC0D92C00BCDC91 /* PostComposerView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EA79C32AC0D92C00BCDC91 /* PostComposerView+Logic.swift */; }; @@ -98,13 +142,20 @@ 03EC92972AC069CE007BBE7E /* SearchResultListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC92962AC069CE007BBE7E /* SearchResultListView.swift */; }; 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */; }; 03ED5D5F2B6560FE005C245B /* PostModel+MenuFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ED5D5E2B6560FE005C245B /* PostModel+MenuFunctions.swift */; }; - 03EEEAF32AB8DCDF0087F8D8 /* CommunityResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF22AB8DCDF0087F8D8 /* CommunityResultView.swift */; }; + 03EEEAF32AB8DCDF0087F8D8 /* CommunityListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF22AB8DCDF0087F8D8 /* CommunityListRow.swift */; }; 03EEEAF72AB8ED3C0087F8D8 /* BubblePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF62AB8ED3C0087F8D8 /* BubblePicker.swift */; }; 03EEEAF92ABB985D0087F8D8 /* CommunityModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EEEAF82ABB985D0087F8D8 /* CommunityModel.swift */; }; 03EF1D0C2B434CB10056175C /* CommunityDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EF1D0B2B434CB10056175C /* CommunityDetailsView.swift */; }; + 03F0DF542B9D129D0018F239 /* BanUserView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F0DF532B9D129D0018F239 /* BanUserView+Logic.swift */; }; + 03F0DF562B9E0E210018F239 /* PurgePostRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F0DF552B9E0E210018F239 /* PurgePostRequest.swift */; }; + 03F0DF582B9E24EF0018F239 /* PurgePersonRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F0DF572B9E24EF0018F239 /* PurgePersonRequest.swift */; }; + 03F0DF5A2B9E28F30018F239 /* InstanceLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F0DF592B9E28F30018F239 /* InstanceLabelView.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 */; }; + 03F6D4B92B951E21008235A0 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 03F6D4B82B951E21008235A0 /* SwiftUIIntrospect */; }; + 03F6D4BB2B952738008235A0 /* SelectTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6D4BA2B952738008235A0 /* SelectTextView.swift */; }; + 03F6D4BD2B966D53008235A0 /* BodyEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F6D4BC2B966D53008235A0 /* BodyEditorView.swift */; }; 03F76FA02B2F5EF900E2B54A /* LinkAttachmentModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F76F9F2B2F5EF900E2B54A /* LinkAttachmentModel.swift */; }; 03F76FA22B2F5F1100E2B54A /* LinkAttachmentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F76FA12B2F5F1100E2B54A /* LinkAttachmentProxy.swift */; }; 03F76FA42B2F5F3500E2B54A /* UploadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F76FA32B2F5F3500E2B54A /* UploadProgressView.swift */; }; @@ -121,7 +172,6 @@ 504ECBAE2AB45B2A006C0B96 /* LemmyURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504ECBAD2AB45B2A006C0B96 /* LemmyURL.swift */; }; 504ECBB12AB4B101006C0B96 /* LemmyURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504ECBB02AB4B101006C0B96 /* LemmyURLTests.swift */; }; 505240E32A86916500EA4558 /* FavoriteCommunitiesTracker+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505240E22A86916500EA4558 /* FavoriteCommunitiesTracker+Dependency.swift */; }; - 505240E52A86E32700EA4558 /* CommunityListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505240E42A86E32700EA4558 /* CommunityListModel.swift */; }; 505240E72A88D36D00EA4558 /* SectionIndexTitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505240E62A88D36D00EA4558 /* SectionIndexTitles.swift */; }; 5064D03D2A6DE0AA00B22EE3 /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5064D03C2A6DE0AA00B22EE3 /* Notifier.swift */; }; 5064D03F2A6DE0DB00B22EE3 /* Notifier+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5064D03E2A6DE0DB00B22EE3 /* Notifier+Dependency.swift */; }; @@ -296,7 +346,6 @@ 6DCE71292A53C26600CFEB5E /* ServerInstanceLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.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 */; }; - 6DFF50432A48DED3001E648D /* Inbox View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFF50422A48DED3001E648D /* Inbox View.swift */; }; 6DFF50452A48E373001E648D /* GetPrivateMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFF50442A48E373001E648D /* GetPrivateMessages.swift */; }; 6FB4A4DE2B47860B00A7CD82 /* CollapsedCommentReplies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB4A4DD2B47860B00A7CD82 /* CollapsedCommentReplies.swift */; }; 6FF17D012B685C16007E1814 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF17D002B685C16007E1814 /* AppLockView.swift */; }; @@ -333,6 +382,10 @@ CD05E7792A4E381A0081D102 /* PostSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD05E7782A4E381A0081D102 /* PostSize.swift */; }; CD05E77F2A4F263B0081D102 /* Menu Function.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD05E77E2A4F263B0081D102 /* Menu Function.swift */; }; CD0BE42F2A65A73600314B24 /* Haptic Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0BE42E2A65A73600314B24 /* Haptic Manager.swift */; }; + CD0D5A432B8EC4DA005E3365 /* RemovePostRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0D5A422B8EC4DA005E3365 /* RemovePostRequest.swift */; }; + CD0D5A452B8EC5D9005E3365 /* RemovePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0D5A442B8EC5D9005E3365 /* RemovePostView.swift */; }; + CD0D5A482B8EC6F3005E3365 /* ReasonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0D5A472B8EC6F3005E3365 /* ReasonView.swift */; }; + CD0D5A4A2B8ED320005E3365 /* BanFormButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0D5A492B8ED320005E3365 /* BanFormButtonStyle.swift */; }; CD12627A2B4759BC007549F9 /* StandardPostTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1262792B4759BC007549F9 /* StandardPostTracker.swift */; }; CD12627D2B475E45007549F9 /* PostModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD12627C2B475E45007549F9 /* PostModel+TrackerItem.swift */; }; CD1446182A58FC3B00610EF1 /* InfoStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1446172A58FC3B00610EF1 /* InfoStackView.swift */; }; @@ -346,8 +399,12 @@ CD16A0642B66F81A000312D2 /* UserContentModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD16A0632B66F81A000312D2 /* UserContentModel+TrackerItem.swift */; }; CD16A0662B670039000312D2 /* HierarchicalComment+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD16A0652B670039000312D2 /* HierarchicalComment+TrackerItem.swift */; }; CD16A0682B670327000312D2 /* UserContentFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD16A0672B670327000312D2 /* UserContentFeedView.swift */; }; - CD16A06A2B670ABE000312D2 /* UserContentFeedView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD16A0692B670ABE000312D2 /* UserContentFeedView+Logic.swift */; }; CD16A06C2B674ABF000312D2 /* FeedHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD16A06B2B674ABF000312D2 /* FeedHeaderView.swift */; }; + CD17C1D92BA2660300A0C8BC /* ModlogNavigationLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD17C1D82BA2660300A0C8BC /* ModlogNavigationLinkView.swift */; }; + CD17C1DB2BA358FD00A0C8BC /* ModlogTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD17C1DA2BA358FD00A0C8BC /* ModlogTracker.swift */; }; + CD17C1DD2BA3596000A0C8BC /* ModlogEntry+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD17C1DC2BA3596000A0C8BC /* ModlogEntry+TrackerItem.swift */; }; + CD17C1E62BA369C700A0C8BC /* ModlogChildTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD17C1E52BA369C700A0C8BC /* ModlogChildTracker.swift */; }; + CD17C1EA2BA3997000A0C8BC /* TrackerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD17C1E92BA3997000A0C8BC /* TrackerProtocol.swift */; }; CD1824402AA8E24100D9BEB5 /* View+DestructiveConfirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD18243F2AA8E24100D9BEB5 /* View+DestructiveConfirmation.swift */; }; CD18DC6B2A5202D4002C56BC /* MarkPersonMentionAsReadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD18DC6A2A5202D4002C56BC /* MarkPersonMentionAsReadRequest.swift */; }; CD18DC6F2A5209C3002C56BC /* MarkPrivateMessageAsReadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD18DC6E2A5209C3002C56BC /* MarkPrivateMessageAsReadRequest.swift */; }; @@ -356,6 +413,41 @@ CD2053122ACB72190000AA38 /* AccountTransitionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2053112ACB72190000AA38 /* AccountTransitionView.swift */; }; CD2053142ACBAF150000AA38 /* AvatarType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2053132ACBAF150000AA38 /* AvatarType.swift */; }; CD2053172ACBBB5A0000AA38 /* DefaultAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2053162ACBBB5A0000AA38 /* DefaultAvatarView.swift */; }; + CD268C112B9A3CB30074DBEE /* SimpleCommunitySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD268C102B9A3CB30074DBEE /* SimpleCommunitySearchView.swift */; }; + CD268C142B9A3DD80074DBEE /* CommunityListRowBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD268C132B9A3DD80074DBEE /* CommunityListRowBody.swift */; }; + CD2697E32B9E13B70002B459 /* ModlogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697E22B9E13B70002B459 /* ModlogView.swift */; }; + CD2697E52B9E14FB0002B459 /* GetModlogRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697E42B9E14FB0002B459 /* GetModlogRequest.swift */; }; + CD2697E82B9E15580002B459 /* APIModRemovePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697E72B9E15580002B459 /* APIModRemovePostView.swift */; }; + CD2697EA2B9E15610002B459 /* APIModLockPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697E92B9E15610002B459 /* APIModLockPostView.swift */; }; + CD2697EC2B9E156D0002B459 /* APIModFeaturePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697EB2B9E156D0002B459 /* APIModFeaturePostView.swift */; }; + CD2697EE2B9E15740002B459 /* APIModRemoveCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697ED2B9E15740002B459 /* APIModRemoveCommentView.swift */; }; + CD2697F02B9E157E0002B459 /* APIModBanFromCommunityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697EF2B9E157E0002B459 /* APIModBanFromCommunityView.swift */; }; + CD2697F22B9E15860002B459 /* APIModRemoveCommunityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697F12B9E15860002B459 /* APIModRemoveCommunityView.swift */; }; + CD2697F42B9E158E0002B459 /* APIModBanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697F32B9E158E0002B459 /* APIModBanView.swift */; }; + CD2697F62B9E15A60002B459 /* APIModAddCommunityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697F52B9E15A60002B459 /* APIModAddCommunityView.swift */; }; + CD2697F82B9E15AD0002B459 /* APIModTransferCommunityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697F72B9E15AD0002B459 /* APIModTransferCommunityView.swift */; }; + CD2697FA2B9E15B90002B459 /* APIModAddView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697F92B9E15B90002B459 /* APIModAddView.swift */; }; + CD2697FC2B9E15C10002B459 /* APIAdminPurgePersonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697FB2B9E15C10002B459 /* APIAdminPurgePersonView.swift */; }; + CD2697FE2B9E15C80002B459 /* APIAdminPurgeCommunityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697FD2B9E15C80002B459 /* APIAdminPurgeCommunityView.swift */; }; + CD2698002B9E15CF0002B459 /* APIAdminPurgePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2697FF2B9E15CF0002B459 /* APIAdminPurgePostView.swift */; }; + CD2698022B9E15D60002B459 /* APIAdminPurgeCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698012B9E15D60002B459 /* APIAdminPurgeCommentView.swift */; }; + CD2698042B9E15E50002B459 /* APIModHideCommunityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698032B9E15E50002B459 /* APIModHideCommunityView.swift */; }; + CD2698062B9E15F10002B459 /* APIModlogActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698052B9E15F10002B459 /* APIModlogActionType.swift */; }; + CD2698082B9E162A0002B459 /* APIModRemovePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698072B9E162A0002B459 /* APIModRemovePost.swift */; }; + CD26980A2B9E166D0002B459 /* APIModLockPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698092B9E166D0002B459 /* APIModLockPost.swift */; }; + CD26980C2B9E16810002B459 /* APIModRemoveComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD26980B2B9E16810002B459 /* APIModRemoveComment.swift */; }; + CD26980E2B9E16A70002B459 /* APIModFeaturePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD26980D2B9E16A70002B459 /* APIModFeaturePost.swift */; }; + CD2698102B9E174C0002B459 /* APIModBanFromCommunity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD26980F2B9E174C0002B459 /* APIModBanFromCommunity.swift */; }; + CD2698122B9E17660002B459 /* APIModRemoveCommunity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698112B9E17660002B459 /* APIModRemoveCommunity.swift */; }; + CD2698152B9E17AE0002B459 /* APIModBan.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698142B9E17AD0002B459 /* APIModBan.swift */; }; + CD2698172B9E17C60002B459 /* APIModAddCommunity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698162B9E17C60002B459 /* APIModAddCommunity.swift */; }; + CD2698192B9E17DE0002B459 /* APIModTransferCommunity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698182B9E17DE0002B459 /* APIModTransferCommunity.swift */; }; + CD26981B2B9E17F70002B459 /* APIModAdd.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD26981A2B9E17F70002B459 /* APIModAdd.swift */; }; + CD26981D2B9E18090002B459 /* APIAdminPurgePerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD26981C2B9E18090002B459 /* APIAdminPurgePerson.swift */; }; + CD26981F2B9E181D0002B459 /* APIAdminPurgeCommunity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD26981E2B9E181D0002B459 /* APIAdminPurgeCommunity.swift */; }; + CD2698212B9E18350002B459 /* APIAdminPurgePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698202B9E18350002B459 /* APIAdminPurgePost.swift */; }; + CD2698232B9E18450002B459 /* APIAdminPurgeComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698222B9E18450002B459 /* APIAdminPurgeComment.swift */; }; + CD2698252B9E18770002B459 /* ApiModHideCommunity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2698242B9E18760002B459 /* ApiModHideCommunity.swift */; }; CD29ED372B2E85EA006937CE /* String+Alphabet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD29ED362B2E85EA006937CE /* String+Alphabet.swift */; }; CD29ED392B2E860C006937CE /* String+Trimmed.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD29ED382B2E860C006937CE /* String+Trimmed.swift */; }; CD29ED3B2B2E8624006937CE /* String+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD29ED3A2B2E8624006937CE /* String+IsNotEmpty.swift */; }; @@ -363,6 +455,10 @@ CD29ED412B2E867C006937CE /* UIApplication+TopMostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD29ED402B2E867C006937CE /* UIApplication+TopMostViewController.swift */; }; CD29ED472B2E8785006937CE /* EnvironmentValues+NavigationPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD29ED462B2E8785006937CE /* EnvironmentValues+NavigationPath.swift */; }; CD2BD6782A79F55800ECFF89 /* ImageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2BD6772A79F55800ECFF89 /* ImageSize.swift */; }; + CD2BFE742B9F5BE800717611 /* ModlogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2BFE732B9F5BE800717611 /* ModlogEntry.swift */; }; + CD2BFE782B9F60AC00717611 /* APIClient+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2BFE772B9F60AC00717611 /* APIClient+Instance.swift */; }; + CD2BFE7E2B9F670B00717611 /* ModlogEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2BFE7D2B9F670B00717611 /* ModlogEntryView.swift */; }; + CD2BFE822B9FA05300717611 /* ModlogLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2BFE812B9FA05300717611 /* ModlogLink.swift */; }; CD2E182B2A3B708500224F8A /* Settings Options.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2E182A2A3B708500224F8A /* Settings Options.swift */; }; CD309C462A93FBD300988F95 /* Logo View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD309C452A93FBD300988F95 /* Logo View.swift */; }; CD3720EC2B2E8F96004D7103 /* AlternativeIconCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3720EB2B2E8F96004D7103 /* AlternativeIconCell.swift */; }; @@ -376,10 +472,6 @@ CD391F9E2A539F1800E213B5 /* ReplyToMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD391F9D2A539F1800E213B5 /* ReplyToMention.swift */; }; CD391FA02A545F8600E213B5 /* Compact Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD391F9F2A545F8600E213B5 /* Compact Post.swift */; }; CD3FBCDD2A4A6F0600B2063F /* GetReplies.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCDC2A4A6F0600B2063F /* GetReplies.swift */; }; - CD3FBCE12A4A836000B2063F /* AllItemsFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCE02A4A836000B2063F /* AllItemsFeedView.swift */; }; - CD3FBCE32A4A844800B2063F /* Replies Feed View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCE22A4A844800B2063F /* Replies Feed View.swift */; }; - CD3FBCE52A4A89B900B2063F /* Mentions Feed View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCE42A4A89B900B2063F /* Mentions Feed View.swift */; }; - CD3FBCE72A4A8CE300B2063F /* Messages Feed View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCE62A4A8CE300B2063F /* Messages Feed View.swift */; }; CD3FBCE92A4B482700B2063F /* Generic Merge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCE82A4B482700B2063F /* Generic Merge.swift */; }; CD4368AE2AE23ED400BD8BD1 /* StandardTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368AD2AE23ED400BD8BD1 /* StandardTracker.swift */; }; CD4368B02AE23F1400BD8BD1 /* ChildTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368AF2AE23F1400BD8BD1 /* ChildTracker.swift */; }; @@ -403,16 +495,24 @@ CD4368D92AE2478300BD8BD1 /* MentionModel+InboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368D82AE2478300BD8BD1 /* MentionModel+InboxItem.swift */; }; CD4368DB2AE247B700BD8BD1 /* MentionTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368DA2AE247B700BD8BD1 /* MentionTracker.swift */; }; CD4368DD2AE24E1A00BD8BD1 /* InboxView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368DC2AE24E1A00BD8BD1 /* InboxView+Logic.swift */; }; + CD436F292BD325CB001711B9 /* String+StrippingDiacritics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD436F282BD325CB001711B9 /* String+StrippingDiacritics.swift */; }; CD45BCEE2A75CA7200A2899C /* Thumbnail Image View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD45BCED2A75CA7200A2899C /* Thumbnail Image View.swift */; }; CD46C1F62B0D0A5700065953 /* EnvironmentValues+TabReselectionHashValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD46C1F52B0D0A5700065953 /* EnvironmentValues+TabReselectionHashValue.swift */; }; CD46C1F82B0D0A8A00065953 /* View+ReselectAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD46C1F72B0D0A8A00065953 /* View+ReselectAction.swift */; }; CD4BAD352B4B2C0B00A1E726 /* FeedsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */; }; CD4BAD3B2B4C6C3200A1E726 /* FeedRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD3A2B4C6C3200A1E726 /* FeedRowView.swift */; }; - CD4BAD3D2B4C6C8E00A1E726 /* FeedType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD3C2B4C6C8E00A1E726 /* FeedType.swift */; }; + CD4BAD3D2B4C6C8E00A1E726 /* PostFeedType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD3C2B4C6C8E00A1E726 /* PostFeedType.swift */; }; CD4BAD432B507F2B00A1E726 /* AggregateFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BAD422B507F2B00A1E726 /* AggregateFeedView.swift */; }; CD4DBC032A6F803C001A1E61 /* ReplyToPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4DBC022A6F803C001A1E61 /* ReplyToPost.swift */; }; + CD4DD9DA2BABABB000085D41 /* InboxRoot.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4DD9D92BABABB000085D41 /* InboxRoot.swift */; }; + CD50504D2B80065300632C56 /* Date+DaysFromNow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD50504C2B80065300632C56 /* Date+DaysFromNow.swift */; }; + CD5050542B807BF800632C56 /* AddModToCommunity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5050532B807BF800632C56 /* AddModToCommunity.swift */; }; CD525F652A4B6D8F00BCA794 /* CommunityLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD525F642A4B6D8F00BCA794 /* CommunityLinkView.swift */; }; + CD55FE262B97F37A0020EE24 /* AddModView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD55FE252B97F37A0020EE24 /* AddModView.swift */; }; + CD55FE282B9A28D00020EE24 /* SimpleUserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD55FE272B9A28D00020EE24 /* SimpleUserSearchView.swift */; }; CD59E8A52A72C943005757F4 /* MarkAllAsReadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD59E8A42A72C943005757F4 /* MarkAllAsReadRequest.swift */; }; + CD5BB8172BADF7700027398F /* ChildSizeReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5BB8162BADF7700027398F /* ChildSizeReader.swift */; }; + CD5F76BC2B75BE700013A827 /* MarkReadBatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5F76BB2B75BE700013A827 /* MarkReadBatcher.swift */; }; CD6483302A38D31C00EE6CA3 /* UpvoteCounterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD64832F2A38D31C00EE6CA3 /* UpvoteCounterView.swift */; }; CD6483322A38D3A600EE6CA3 /* ScoreCounterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6483312A38D3A600EE6CA3 /* ScoreCounterView.swift */; }; CD6483362A39F20800EE6CA3 /* Post Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6483352A39F20800EE6CA3 /* Post Type.swift */; }; @@ -429,6 +529,7 @@ CD6F29A82A77FF1700F20B6B /* MarkPostRead.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6F29A72A77FF1700F20B6B /* MarkPostRead.swift */; }; CD6F29AA2A78003A00F20B6B /* PostRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6F29A92A78003A00F20B6B /* PostRepository.swift */; }; CD6F29AC2A78015200F20B6B /* PostRepository+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6F29AB2A78015200F20B6B /* PostRepository+Dependency.swift */; }; + CD7798A62BB0E5B50067DF82 /* InboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7798A52BB0E5B50067DF82 /* InboxView.swift */; }; CD7B53B52A5F251400006E81 /* CreatePrivateMessageReportRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7B53B42A5F251400006E81 /* CreatePrivateMessageReportRequest.swift */; }; CD7B53B72A5F258B00006E81 /* APIPrivateMessageReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7B53B62A5F258B00006E81 /* APIPrivateMessageReportView.swift */; }; CD7B53B92A5F263D00006E81 /* APIPrivateMessageReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7B53B82A5F263D00006E81 /* APIPrivateMessageReport.swift */; }; @@ -438,11 +539,12 @@ CD82A2552A716C7C00111034 /* APIPersonUnreadCounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD82A2542A716C7C00111034 /* APIPersonUnreadCounts.swift */; }; CD82A2572A716D7C00111034 /* PersonRepository+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD82A2562A716D7C00111034 /* PersonRepository+Dependency.swift */; }; CD82A2592A71775E00111034 /* UnreadTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD82A2582A71775E00111034 /* UnreadTracker.swift */; }; - CD8461662A96F9EB0026A627 /* Website Indicator View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8461652A96F9EB0026A627 /* Website Indicator View.swift */; }; CD863FBA2A6AEB5900A31ED9 /* View+FancyTabScrollCompatible.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD863FB92A6AEB5900A31ED9 /* View+FancyTabScrollCompatible.swift */; }; CD863FBC2A6B026400A31ED9 /* DocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD863FBB2A6B026400A31ED9 /* DocumentView.swift */; }; + CD876EC72B7736370075DC15 /* MarkReadBatcher+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD876EC62B7736370075DC15 /* MarkReadBatcher+Dependency.swift */; }; CD8C55342A95515C0060B75B /* Onboarding Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8C55332A95515C0060B75B /* Onboarding Text.swift */; }; CD8CF2092AF3F131009FFC23 /* Firm Info.ahap in Resources */ = {isa = PBXBuildFile; fileRef = CD8CF2082AF3F131009FFC23 /* Firm Info.ahap */; }; + CD9395272BA7CF92008F6C4C /* ModlogAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9395262BA7CF92008F6C4C /* ModlogAction.swift */; }; CD963FCB2B5F0388002352FD /* DefaultFeedType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD963FCA2B5F0388002352FD /* DefaultFeedType.swift */; }; CD9A03C62B34D20500C16276 /* EnvironmentValues+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9A03C52B34D20500C16276 /* EnvironmentValues+Navigation.swift */; }; CD9A03C82B389F7000C16276 /* EnvironmentValues+FeedType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9A03C72B389F7000C16276 /* EnvironmentValues+FeedType.swift */; }; @@ -453,6 +555,9 @@ CD9DD8832A622A6C0044EA8E /* ReportCommentReply.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9DD8822A622A6C0044EA8E /* ReportCommentReply.swift */; }; CD9DD8852A62302A0044EA8E /* ConcreteEditorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9DD8842A62302A0044EA8E /* ConcreteEditorModel.swift */; }; CDA145ED2A510AC100DDAFC9 /* MarkCommentReplyAsReadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA145EC2A510AC100DDAFC9 /* MarkCommentReplyAsReadRequest.swift */; }; + CDA1E84D2B93FC7C007953EF /* BanUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA1E84C2B93FC7C007953EF /* BanUserView.swift */; }; + CDA1E84F2B93FF83007953EF /* RemovedTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA1E84E2B93FF83007953EF /* RemovedTag.swift */; }; + CDA1E8512B940390007953EF /* LockedTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA1E8502B940390007953EF /* LockedTag.swift */; }; CDA217E42A62FB3300BDA173 /* ReplyToMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA217E32A62FB3300BDA173 /* ReplyToMessage.swift */; }; CDA217E62A63016A00BDA173 /* ReportMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA217E52A63016A00BDA173 /* ReportMessage.swift */; }; CDA217E82A63029B00BDA173 /* ReportMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA217E72A63029B00BDA173 /* ReportMention.swift */; }; @@ -469,12 +574,20 @@ CDB45C602AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB45C5F2AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift */; }; CDB45C622AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB45C612AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift */; }; CDB45C642AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB45C632AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift */; }; + CDB652532B8EABFC007B7797 /* FeaturePostRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB652522B8EABFC007B7797 /* FeaturePostRequest.swift */; }; + CDB652552B8EAC3E007B7797 /* APIPostFeatureType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB652542B8EAC3E007B7797 /* APIPostFeatureType.swift */; }; + CDB652572B8EAE15007B7797 /* APIPostResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB652562B8EAE15007B7797 /* APIPostResponse.swift */; }; + CDB652592B8EC024007B7797 /* LockPostRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB652582B8EC024007B7797 /* LockPostRequest.swift */; }; + CDBA5FC62BC9C58300469C05 /* GetUnreadRegistrationApplicationCountRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBA5FC52BC9C58300469C05 /* GetUnreadRegistrationApplicationCountRequest.swift */; }; + CDBA5FC82BCC477F00469C05 /* WarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBA5FC72BCC477F00469C05 /* WarningView.swift */; }; + CDBA5FCA2BD17F3D00469C05 /* CommunityListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBA5FC92BD17F3D00469C05 /* CommunityListModel.swift */; }; CDBCBA202B537A4B0070F60D /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBCBA1F2B537A4B0070F60D /* PostFeedView.swift */; }; CDBCBA242B54A5F40070F60D /* NoPostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDBCBA232B54A5F40070F60D /* NoPostsView.swift */; }; CDC1C93C2A7AA76000072E3D /* InternetSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC1C93B2A7AA76000072E3D /* InternetSpeed.swift */; }; CDC1C93F2A7AB8C700072E3D /* AccessibilitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC1C93E2A7AB8C700072E3D /* AccessibilitySettingsView.swift */; }; CDC1C9412A7ABA9C00072E3D /* ReadMarkStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC1C9402A7ABA9C00072E3D /* ReadMarkStyle.swift */; }; CDC1C9432A7AC24600072E3D /* ReadCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC1C9422A7AC24600072E3D /* ReadCheck.swift */; }; + CDC24EA62BC9B352009AA6D1 /* GetReportCountRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC24EA52BC9B352009AA6D1 /* GetReportCountRequest.swift */; }; CDC3E8002AEAFEAF008062CA /* InboxTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC3E7FF2AEAFEAF008062CA /* InboxTracker.swift */; }; CDC65D8F2A86B6DD007205E5 /* DeleteUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC65D8E2A86B6DD007205E5 /* DeleteUser.swift */; }; CDC65D912A86B830007205E5 /* DeleteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC65D902A86B830007205E5 /* DeleteAccountView.swift */; }; @@ -484,6 +597,57 @@ CDCBD7262A8D69A200387A2C /* Instance Picker View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCBD7252A8D69A200387A2C /* Instance Picker View.swift */; }; CDCBD7282A8D6B7700387A2C /* Instance Picker View Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCBD7272A8D6B7700387A2C /* Instance Picker View Logic.swift */; }; CDCBD72B2A8EC0A800387A2C /* Instance Summary.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDCBD72A2A8EC0A800387A2C /* Instance Summary.swift */; }; + CDD0B8AB2BB37B3D003E7174 /* WebsiteIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8AA2BB37B3D003E7174 /* WebsiteIndicatorView.swift */; }; + CDD0B8AD2BB4CD7D003E7174 /* CommentReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8AC2BB4CD7D003E7174 /* CommentReportModel.swift */; }; + CDD0B8B22BB4D990003E7174 /* CommentReportModel+InboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8B12BB4D990003E7174 /* CommentReportModel+InboxItem.swift */; }; + CDD0B8B42BB4DAFE003E7174 /* CommentReportModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8B32BB4DAFE003E7174 /* CommentReportModel+TrackerItem.swift */; }; + CDD0B8B62BB4DC12003E7174 /* CommentReportTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8B52BB4DC12003E7174 /* CommentReportTracker.swift */; }; + CDD0B8B92BB4E407003E7174 /* ListCommentReportsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8B82BB4E407003E7174 /* ListCommentReportsRequest.swift */; }; + CDD0B8BB2BB4E51C003E7174 /* APIClient+Moderation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8BA2BB4E51B003E7174 /* APIClient+Moderation.swift */; }; + CDD0B8BD2BB4F3D3003E7174 /* InboxCommentReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8BC2BB4F3D3003E7174 /* InboxCommentReportView.swift */; }; + CDD0B8BF2BB5B68F003E7174 /* EmbeddedCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8BE2BB5B68F003E7174 /* EmbeddedCommentView.swift */; }; + CDD0B8C12BB5BC51003E7174 /* InboxMessageBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8C02BB5BC51003E7174 /* InboxMessageBodyView.swift */; }; + CDD0B8C32BB5BCFD003E7174 /* InboxCommentReportBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8C22BB5BCFD003E7174 /* InboxCommentReportBodyView.swift */; }; + CDD0B8C52BB78056003E7174 /* ResolveButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8C42BB78056003E7174 /* ResolveButtonView.swift */; }; + CDD0B8C72BB78260003E7174 /* RemoveButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8C62BB78260003E7174 /* RemoveButtonView.swift */; }; + CDD0B8C92BB782CF003E7174 /* PurgeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8C82BB782CF003E7174 /* PurgeButtonView.swift */; }; + CDD0B8CB2BB7844F003E7174 /* BanButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8CA2BB7844F003E7174 /* BanButtonView.swift */; }; + CDD0B8CD2BB9E53F003E7174 /* Removable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8CC2BB9E53F003E7174 /* Removable.swift */; }; + CDD0B8CF2BBA0D31003E7174 /* ResolveCommentReportRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8CE2BBA0D31003E7174 /* ResolveCommentReportRequest.swift */; }; + CDD0B8D32BBB4158003E7174 /* InboxView+Feeds.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8D22BBB4158003E7174 /* InboxView+Feeds.swift */; }; + CDD0B8D52BBB41CC003E7174 /* InboxFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8D42BBB41CB003E7174 /* InboxFeedView.swift */; }; + CDD0B8D82BBF3C5A003E7174 /* PostReportTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8D72BBF3C5A003E7174 /* PostReportTracker.swift */; }; + CDD0B8DA2BBF3C87003E7174 /* PostReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8D92BBF3C87003E7174 /* PostReportModel.swift */; }; + CDD0B8DC2BBF4238003E7174 /* ListPostReportsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8DB2BBF4238003E7174 /* ListPostReportsRequest.swift */; }; + CDD0B8DE2BBF4601003E7174 /* PostReportModel+InboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8DD2BBF4601003E7174 /* PostReportModel+InboxItem.swift */; }; + CDD0B8E02BBF4689003E7174 /* PostReportModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8DF2BBF4689003E7174 /* PostReportModel+TrackerItem.swift */; }; + CDD0B8E22BBF49DB003E7174 /* InboxPostReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8E12BBF49DB003E7174 /* InboxPostReportView.swift */; }; + CDD0B8E42BBF4D4B003E7174 /* InboxPostReportBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8E32BBF4D4B003E7174 /* InboxPostReportBodyView.swift */; }; + CDD0B8E62BBF57D9003E7174 /* ResolvePostReportRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8E52BBF57D9003E7174 /* ResolvePostReportRequest.swift */; }; + CDD0B8E82BBF7C8B003E7174 /* MessageReportTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8E72BBF7C8B003E7174 /* MessageReportTracker.swift */; }; + CDD0B8EA2BBF7CAB003E7174 /* MessageReportModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8E92BBF7CAB003E7174 /* MessageReportModel.swift */; }; + CDD0B8EC2BBF7D0F003E7174 /* ListPrivateMessageReportsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8EB2BBF7D0F003E7174 /* ListPrivateMessageReportsRequest.swift */; }; + CDD0B8EE2BBF7F21003E7174 /* MessageReportModel+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8ED2BBF7F21003E7174 /* MessageReportModel+TrackerItem.swift */; }; + CDD0B8F02BBF7FB7003E7174 /* MessageReportModel+InboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8EF2BBF7FB7003E7174 /* MessageReportModel+InboxItem.swift */; }; + CDD0B8F22BBF8406003E7174 /* InboxMessageReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8F12BBF8406003E7174 /* InboxMessageReportView.swift */; }; + CDD0B8F42BBF843C003E7174 /* InboxMessageReportBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8F32BBF843B003E7174 /* InboxMessageReportBodyView.swift */; }; + CDD0B8F62BC064F0003E7174 /* ResolvePrivateMessageReportRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8F52BC064F0003E7174 /* ResolvePrivateMessageReportRequest.swift */; }; + CDD0B8F82BC07E6A003E7174 /* ListRegistrationApplicationsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8F72BC07E6A003E7174 /* ListRegistrationApplicationsRequest.swift */; }; + CDD0B8FA2BC07EA9003E7174 /* APIRegistrationApplicationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8F92BC07EA9003E7174 /* APIRegistrationApplicationView.swift */; }; + CDD0B8FC2BC07ED7003E7174 /* APIRegistrationApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8FB2BC07ED7003E7174 /* APIRegistrationApplication.swift */; }; + CDD0B8FE2BC07F4B003E7174 /* RegistrationApplicationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8FD2BC07F4B003E7174 /* RegistrationApplicationModel.swift */; }; + CDD0B9002BC080C7003E7174 /* ApproveRegistrationApplicationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B8FF2BC080C7003E7174 /* ApproveRegistrationApplicationRequest.swift */; }; + CDD0B9022BC084A9003E7174 /* DenyApplicationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B9012BC084A9003E7174 /* DenyApplicationView.swift */; }; + CDD0B9042BC08987003E7174 /* RegistrationApplication+TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B9032BC08987003E7174 /* RegistrationApplication+TrackerItem.swift */; }; + CDD0B9062BC089EA003E7174 /* RegistrationApplication+InboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B9052BC089EA003E7174 /* RegistrationApplication+InboxItem.swift */; }; + CDD0B9082BC08A6D003E7174 /* RegistrationApplicationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B9072BC08A6D003E7174 /* RegistrationApplicationTracker.swift */; }; + CDD0B90A2BC08E02003E7174 /* InboxRegistrationApplicationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B9092BC08E02003E7174 /* InboxRegistrationApplicationView.swift */; }; + CDD0B90C2BC08E21003E7174 /* InboxRegistrationApplicationBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0B90B2BC08E21003E7174 /* InboxRegistrationApplicationBodyView.swift */; }; + CDD0EF9C2B7D6B9100CA3504 /* ModToolTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0EF9B2B7D6B9100CA3504 /* ModToolTracker.swift */; }; + CDD0EF9E2B7D6F3E00CA3504 /* ModToolSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0EF9D2B7D6F3E00CA3504 /* ModToolSheet.swift */; }; + CDD0EFA22B7D9E5800CA3504 /* UserListRowBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0EFA12B7D9E5800CA3504 /* UserListRowBody.swift */; }; + CDD0EFA52B7E8B3200CA3504 /* ModeratorListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0EFA42B7E8B3200CA3504 /* ModeratorListView.swift */; }; + CDD0EFAD2B7EA73000CA3504 /* BanFromCommunity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD0EFAC2B7EA73000CA3504 /* BanFromCommunity.swift */; }; CDD55D1D2B23BA41002020C7 /* EasyTapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD55D1C2B23BA41002020C7 /* EasyTapLinkView.swift */; }; CDD55D222B2674BD002020C7 /* String+ParseLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD55D212B2674BD002020C7 /* String+ParseLinks.swift */; }; CDDB08782A5DF1330075BFEE /* CommentSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDB08772A5DF1330075BFEE /* CommentSettingsView.swift */; }; @@ -500,9 +664,8 @@ CDE3BA892A8C64BD00B972E2 /* Collapsible Text Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE3BA882A8C64BD00B972E2 /* Collapsible Text Item.swift */; }; CDE6A80B2A43E9F00062D161 /* CommentSortType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE6A80A2A43E9F00062D161 /* CommentSortType.swift */; }; CDE6A80D2A45EAB30062D161 /* Embedded Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE6A80C2A45EAB30062D161 /* Embedded Post.swift */; }; - CDE6A8162A490AE00062D161 /* InboxMessageBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE6A8152A490AE00062D161 /* InboxMessageBodyView.swift */; }; CDE6A8182A490AF20062D161 /* InboxMentionBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE6A8172A490AF20062D161 /* InboxMentionBodyView.swift */; }; - CDE6A81A2A490B970062D161 /* Inbox ReplyBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE6A8192A490B970062D161 /* Inbox ReplyBodyView.swift */; }; + CDE6A81A2A490B970062D161 /* InboxReplyBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE6A8192A490B970062D161 /* InboxReplyBodyView.swift */; }; CDE9CE4C2A7B0831002B97DD /* Gentle Info.ahap in Resources */ = {isa = PBXBuildFile; fileRef = CDE9CE4B2A7B0831002B97DD /* Gentle Info.ahap */; }; CDE9CE4F2A7B0B1B002B97DD /* Haptic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE9CE4E2A7B0B1B002B97DD /* Haptic.swift */; }; CDE9CE512A7B0C66002B97DD /* Light Success.ahap in Resources */ = {isa = PBXBuildFile; fileRef = CDE9CE502A7B0C66002B97DD /* Light Success.ahap */; }; @@ -548,7 +711,7 @@ E49F0E762A90395400BC4EE3 /* NavigationPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49F0E752A90395400BC4EE3 /* NavigationPath+Helpers.swift */; }; E4A7BFD12B35912500B95F56 /* InboxMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4A7BFD02B35912500B95F56 /* InboxMessageView.swift */; }; E4A7BFD32B35913F00B95F56 /* InboxMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4A7BFD22B35913F00B95F56 /* InboxMentionView.swift */; }; - E4D4DBA02A7C7B9D00C4F3DE /* Comments.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4D4DB9F2A7C7B9D00C4F3DE /* Comments.swift */; }; + E4D4DBA02A7C7B9D00C4F3DE /* Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4D4DB9F2A7C7B9D00C4F3DE /* Animations.swift */; }; E4DDB4322A81819300B3A7E0 /* Double+MaxZIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4DDB4312A81819300B3A7E0 /* Double+MaxZIndex.swift */; }; E4DDB4342A819C8000B3A7E0 /* QuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4DDB4332A819C8000B3A7E0 /* QuickLookView.swift */; }; E4F0B56F2ABD00A000BC3E4A /* View+PresentationBackgroundInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4F0B56E2ABD00A000BC3E4A /* View+PresentationBackgroundInteraction.swift */; }; @@ -573,6 +736,13 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0300F2C52B9C7D4B0022F7C4 /* CloseButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseButtonView.swift; sourceTree = ""; }; + 030245BF2BA607EA00D07747 /* FeedToolbarContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedToolbarContent.swift; sourceTree = ""; }; + 030245C12BA60A2600D07747 /* ToolbarEllipsisMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarEllipsisMenu.swift; sourceTree = ""; }; + 030245C32BA6123A00D07747 /* RemoveCommunityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveCommunityView.swift; sourceTree = ""; }; + 030245C52BA6138100D07747 /* RemoveCommunityRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveCommunityRequest.swift; sourceTree = ""; }; + 030245C72BA617FE00D07747 /* PurgeCommunityRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeCommunityRequest.swift; sourceTree = ""; }; + 030245C92BA70F5200D07747 /* LinksSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinksSettingsView.swift; sourceTree = ""; }; 0304F5892B44AF5B00537BFA /* CollapsibleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleSection.swift; sourceTree = ""; }; 0308E1132B0EA32A000CA955 /* AccountSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingsView.swift; sourceTree = ""; }; 0308E1152B0EA42B000CA955 /* APILocalUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APILocalUserView.swift; sourceTree = ""; }; @@ -592,6 +762,8 @@ 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 = ""; }; + 030FF6852BCB218000F6BFAC /* Int+Abbreviated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Abbreviated.swift"; sourceTree = ""; }; + 030FF6872BCEE58900F6BFAC /* BlockInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockInstance.swift; sourceTree = ""; }; 0317D46E2B558CB500EEE72C /* BadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeView.swift; sourceTree = ""; }; 0317D4702B55AE0700EEE72C /* Color+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.swift"; sourceTree = ""; }; 031A617B2B1BDFD100ABF23B /* AdvancedAccountSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedAccountSettingsView.swift; sourceTree = ""; }; @@ -606,12 +778,15 @@ 032C1E032B5D7DAC00FB4F23 /* QuickSwitcherSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSwitcherSettingsView.swift; sourceTree = ""; }; 032C1E052B5DBDB100FB4F23 /* LocalAccountSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountSettingsView.swift; sourceTree = ""; }; 032DD2FC2AC3594B00F1B33D /* LinkAttatchmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAttatchmentView.swift; sourceTree = ""; }; + 033EC0AE2BD3030A00AA238F /* BlockListView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlockListView+Logic.swift"; sourceTree = ""; }; 034C724E2A82B61200B8A4B8 /* LayoutWidgetTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutWidgetTracker.swift; sourceTree = ""; }; + 0355A1DD2BB1F12500D54F9F /* ModerationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModerationSettingsView.swift; sourceTree = ""; }; 0355DA4C2B5EB51900CDF5A5 /* InstanceModel+ContentModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceModel+ContentModel.swift"; sourceTree = ""; }; 0355DA4E2B5EB63600CDF5A5 /* InstanceStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStub.swift; sourceTree = ""; }; 0355DA502B5EB87700CDF5A5 /* InstanceResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceResultView.swift; sourceTree = ""; }; 035EB0C92A8687C200227859 /* JumpButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpButtonView.swift; sourceTree = ""; }; 036ED3BB2ABF1058009664BC /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = ""; }; + 038142F12BB46FFF00856C9B /* CommentItem+MenuFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentItem+MenuFunctions.swift"; sourceTree = ""; }; 038A16DE2A75172C0087987E /* LayoutWidgetEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutWidgetEditView.swift; sourceTree = ""; }; 038A16E02A75AA880087987E /* LayoutWidgetModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutWidgetModel.swift; sourceTree = ""; }; 038A16E42A7A97380087987E /* LayoutWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutWidgetView.swift; sourceTree = ""; }; @@ -620,6 +795,13 @@ 0394398E2A98EB2300463032 /* APIComment+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIComment+Mock.swift"; sourceTree = ""; }; 039439902A98FA6100463032 /* UserFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFeedView.swift; sourceTree = ""; }; 039439922A99098900463032 /* InternetConnectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetConnectionManager.swift; sourceTree = ""; }; + 039B4FE82BD2D81D00E42114 /* BlockListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListView.swift; sourceTree = ""; }; + 039C59A62BADA04100C18765 /* RemoveCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveCommentView.swift; sourceTree = ""; }; + 039C59A82BADA5DA00C18765 /* PurgeCommentRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeCommentRequest.swift; sourceTree = ""; }; + 039C59AA2BADC85400C18765 /* RemoveCommentRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveCommentRequest.swift; sourceTree = ""; }; + 039C59AC2BADFF6200C18765 /* ListPostLikesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPostLikesRequest.swift; sourceTree = ""; }; + 039C59AE2BAE029300C18765 /* VotesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotesListView.swift; sourceTree = ""; }; + 039C59B02BAF272E00C18765 /* VotesTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotesTracker.swift; sourceTree = ""; }; 039C8DB62B35A32D0096BAAF /* AccountSwitcherSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitcherSettingsView.swift; sourceTree = ""; }; 039C8DB82B35A81C0096BAAF /* AccountIconStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountIconStack.swift; sourceTree = ""; }; 039C8DBA2B35B2EB0096BAAF /* AccountListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListView.swift; sourceTree = ""; }; @@ -635,17 +817,32 @@ 03A276782AFD903600C0D66B /* CommunityModel+MenuFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommunityModel+MenuFunctions.swift"; sourceTree = ""; }; 03A2767A2AFE560000C0D66B /* CommunityModel+SwipeActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommunityModel+SwipeActions.swift"; sourceTree = ""; }; 03A2767C2AFE656700C0D66B /* UserModel+MenuFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserModel+MenuFunctions.swift"; sourceTree = ""; }; + 03A4330A2B6FB2C10004E743 /* FediseerOpinionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FediseerOpinionListView.swift; sourceTree = ""; }; + 03A4330C2B6FC0940004E743 /* FediseerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FediseerInfoView.swift; sourceTree = ""; }; + 03A4330E2B7186C20004E743 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; 03A54C312B5331F30064CCDE /* InstanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceView.swift; sourceTree = ""; }; 03A54C342B533BC50064CCDE /* InstanceModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceModel.swift; sourceTree = ""; }; 03A54C362B545A430064CCDE /* InstanceModel+MenuFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceModel+MenuFunctions.swift"; sourceTree = ""; }; + 03AFBEA22B6EA86B00F01F3C /* SiteResponse+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SiteResponse+Mock.swift"; sourceTree = ""; }; + 03AFBEA42B6EA90400F01F3C /* APIPersonView+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIPersonView+Mock.swift"; sourceTree = ""; }; + 03AFBEA62B6EA94900F01F3C /* APIPersonAggregates+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIPersonAggregates+Mock.swift"; sourceTree = ""; }; + 03AFBEA82B6EA9BC00F01F3C /* APISiteView+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APISiteView+Mock.swift"; sourceTree = ""; }; + 03AFBEAA2B6EAA0C00F01F3C /* APILocalSite+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APILocalSite+Mock.swift"; sourceTree = ""; }; + 03AFBEAC2B6EAAF000F01F3C /* APILocalSiteRateLimit+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APILocalSiteRateLimit+Mock.swift"; sourceTree = ""; }; + 03AFBEAE2B6EAB9C00F01F3C /* APISiteAggregates+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APISiteAggregates+Mock.swift"; sourceTree = ""; }; + 03AFBEB02B6EAD5B00F01F3C /* InstanceSafetyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSafetyView.swift; sourceTree = ""; }; + 03AFBEB22B6EB8B800F01F3C /* Line.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Line.swift; sourceTree = ""; }; 03B15BEC2B55CBBB00E7C30A /* MarkdownTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTheme.swift; sourceTree = ""; }; 03B643562A6864CD00F65700 /* TabBarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarSettingsView.swift; sourceTree = ""; }; 03B7AAEE2ABCB9DC00068B23 /* ContentTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTracker.swift; sourceTree = ""; }; 03B7AAF02ABE404300068B23 /* ContentModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentModel.swift; sourceTree = ""; }; 03B7AAF22ABEF85200068B23 /* UserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserModel.swift; sourceTree = ""; }; - 03B7AAF42ABEFA7A00068B23 /* UserResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserResultView.swift; sourceTree = ""; }; + 03B7AAF42ABEFA7A00068B23 /* UserListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListRow.swift; sourceTree = ""; }; + 03B85A3B2BB34D1F003C4203 /* PurgeContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeContentView.swift; sourceTree = ""; }; + 03B85A3D2BB36C4B003C4203 /* UserRemovalWalker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRemovalWalker.swift; sourceTree = ""; }; + 03B85A3F2BB38868003C4203 /* PostEllipsisMenus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEllipsisMenus.swift; sourceTree = ""; }; 03BAA2392A57DC1400D48252 /* PublishedTimestampView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedTimestampView.swift; sourceTree = ""; }; - 03C897F52ABF49BD005F3403 /* Abbreviate Numbers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Abbreviate Numbers.swift"; sourceTree = ""; }; + 03C194072BA25B5200B00349 /* ProgressOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressOverlayView.swift; sourceTree = ""; }; 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 = ""; }; @@ -653,10 +850,20 @@ 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 = ""; }; + 03C942912B6457B4002068A4 /* BanUserEditorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserEditorModel.swift; sourceTree = ""; }; + 03C942952B648252002068A4 /* BanPerson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanPerson.swift; sourceTree = ""; }; 03CB329D2A6D8E910021EF27 /* PostComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostComposerView.swift; sourceTree = ""; }; + 03CEE04A2B6EB9CD00D65B1B /* Fediseer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fediseer.swift; sourceTree = ""; }; + 03CEE04C2B6EBEA800D65B1B /* InstanceView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceView+Logic.swift"; sourceTree = ""; }; + 03CEE04E2B6ECFFC00D65B1B /* FediseerOpinionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FediseerOpinionView.swift; sourceTree = ""; }; + 03CEE0502B6EED2C00D65B1B /* Array+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+IsNotEmpty.swift"; sourceTree = ""; }; + 03D89E712BB1BB0100F49DB3 /* ListCommentLikesRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListCommentLikesRequest.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 = ""; }; 03E0B9CB2A62CD5800FED265 /* ThemeSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSettingsView.swift; sourceTree = ""; }; + 03E47AEA2B66BADC00A3E4DB /* UptimeData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UptimeData.swift; sourceTree = ""; }; + 03E47AEC2B66BC0000A3E4DB /* InstanceUptimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceUptimeView.swift; sourceTree = ""; }; + 03E47AEE2B66BD3C00A3E4DB /* InstanceModel+Uptime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstanceModel+Uptime.swift"; sourceTree = ""; }; 03E79F3E2AE3E7100006700D /* SortingSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortingSettingsView.swift; sourceTree = ""; }; 03E90FB02B3703ED00E5A802 /* AccountSortMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSortMode.swift; sourceTree = ""; }; 03EA79C32AC0D92C00BCDC91 /* PostComposerView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostComposerView+Logic.swift"; sourceTree = ""; }; @@ -664,13 +871,19 @@ 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 = ""; }; 03ED5D5E2B6560FE005C245B /* PostModel+MenuFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostModel+MenuFunctions.swift"; sourceTree = ""; }; - 03EEEAF22AB8DCDF0087F8D8 /* CommunityResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityResultView.swift; sourceTree = ""; }; + 03EEEAF22AB8DCDF0087F8D8 /* CommunityListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListRow.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 /* CommunityDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityDetailsView.swift; sourceTree = ""; }; + 03F0DF532B9D129D0018F239 /* BanUserView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BanUserView+Logic.swift"; sourceTree = ""; }; + 03F0DF552B9E0E210018F239 /* PurgePostRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgePostRequest.swift; sourceTree = ""; }; + 03F0DF572B9E24EF0018F239 /* PurgePersonRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgePersonRequest.swift; sourceTree = ""; }; + 03F0DF592B9E28F30018F239 /* InstanceLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceLabelView.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 = ""; }; + 03F6D4BA2B952738008235A0 /* SelectTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectTextView.swift; sourceTree = ""; }; + 03F6D4BC2B966D53008235A0 /* BodyEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyEditorView.swift; sourceTree = ""; }; 03F76F9F2B2F5EF900E2B54A /* LinkAttachmentModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAttachmentModel.swift; sourceTree = ""; }; 03F76FA12B2F5F1100E2B54A /* LinkAttachmentProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAttachmentProxy.swift; sourceTree = ""; }; 03F76FA32B2F5F3500E2B54A /* UploadProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadProgressView.swift; sourceTree = ""; }; @@ -687,7 +900,6 @@ 504ECBAD2AB45B2A006C0B96 /* LemmyURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LemmyURL.swift; sourceTree = ""; }; 504ECBB02AB4B101006C0B96 /* LemmyURLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LemmyURLTests.swift; sourceTree = ""; }; 505240E22A86916500EA4558 /* FavoriteCommunitiesTracker+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteCommunitiesTracker+Dependency.swift"; sourceTree = ""; }; - 505240E42A86E32700EA4558 /* CommunityListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListModel.swift; sourceTree = ""; }; 505240E62A88D36D00EA4558 /* SectionIndexTitles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionIndexTitles.swift; sourceTree = ""; }; 5064D03C2A6DE0AA00B22EE3 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = ""; }; 5064D03E2A6DE0DB00B22EE3 /* Notifier+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notifier+Dependency.swift"; sourceTree = ""; }; @@ -864,7 +1076,6 @@ 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInstanceLocation.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 = ""; }; - 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 = ""; }; 6FB4A4DD2B47860B00A7CD82 /* CollapsedCommentReplies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsedCommentReplies.swift; sourceTree = ""; }; 6FF17D002B685C16007E1814 /* AppLockView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = ""; }; @@ -898,6 +1109,10 @@ CD05E7782A4E381A0081D102 /* PostSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSize.swift; sourceTree = ""; }; CD05E77E2A4F263B0081D102 /* Menu Function.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Menu Function.swift"; sourceTree = ""; }; CD0BE42E2A65A73600314B24 /* Haptic Manager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Haptic Manager.swift"; sourceTree = ""; }; + CD0D5A422B8EC4DA005E3365 /* RemovePostRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemovePostRequest.swift; sourceTree = ""; }; + CD0D5A442B8EC5D9005E3365 /* RemovePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemovePostView.swift; sourceTree = ""; }; + CD0D5A472B8EC6F3005E3365 /* ReasonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReasonView.swift; sourceTree = ""; }; + CD0D5A492B8ED320005E3365 /* BanFormButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanFormButtonStyle.swift; sourceTree = ""; }; CD1262792B4759BC007549F9 /* StandardPostTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandardPostTracker.swift; sourceTree = ""; }; CD12627B2B475A80007549F9 /* README - Generic Trackers.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "README - Generic Trackers.md"; sourceTree = ""; }; CD12627C2B475E45007549F9 /* PostModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostModel+TrackerItem.swift"; sourceTree = ""; }; @@ -912,8 +1127,12 @@ CD16A0632B66F81A000312D2 /* UserContentModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserContentModel+TrackerItem.swift"; sourceTree = ""; }; CD16A0652B670039000312D2 /* HierarchicalComment+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HierarchicalComment+TrackerItem.swift"; sourceTree = ""; }; CD16A0672B670327000312D2 /* UserContentFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserContentFeedView.swift; sourceTree = ""; }; - CD16A0692B670ABE000312D2 /* UserContentFeedView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserContentFeedView+Logic.swift"; sourceTree = ""; }; CD16A06B2B674ABF000312D2 /* FeedHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedHeaderView.swift; sourceTree = ""; }; + CD17C1D82BA2660300A0C8BC /* ModlogNavigationLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogNavigationLinkView.swift; sourceTree = ""; }; + CD17C1DA2BA358FD00A0C8BC /* ModlogTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogTracker.swift; sourceTree = ""; }; + CD17C1DC2BA3596000A0C8BC /* ModlogEntry+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModlogEntry+TrackerItem.swift"; sourceTree = ""; }; + CD17C1E52BA369C700A0C8BC /* ModlogChildTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogChildTracker.swift; sourceTree = ""; }; + CD17C1E92BA3997000A0C8BC /* TrackerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerProtocol.swift; sourceTree = ""; }; CD18243F2AA8E24100D9BEB5 /* View+DestructiveConfirmation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+DestructiveConfirmation.swift"; sourceTree = ""; }; CD18DC6A2A5202D4002C56BC /* MarkPersonMentionAsReadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkPersonMentionAsReadRequest.swift; sourceTree = ""; }; CD18DC6E2A5209C3002C56BC /* MarkPrivateMessageAsReadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkPrivateMessageAsReadRequest.swift; sourceTree = ""; }; @@ -922,6 +1141,41 @@ CD2053112ACB72190000AA38 /* AccountTransitionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTransitionView.swift; sourceTree = ""; }; CD2053132ACBAF150000AA38 /* AvatarType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarType.swift; sourceTree = ""; }; CD2053162ACBBB5A0000AA38 /* DefaultAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAvatarView.swift; sourceTree = ""; }; + CD268C102B9A3CB30074DBEE /* SimpleCommunitySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleCommunitySearchView.swift; sourceTree = ""; }; + CD268C132B9A3DD80074DBEE /* CommunityListRowBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListRowBody.swift; sourceTree = ""; }; + CD2697E22B9E13B70002B459 /* ModlogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogView.swift; sourceTree = ""; }; + CD2697E42B9E14FB0002B459 /* GetModlogRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetModlogRequest.swift; sourceTree = ""; }; + CD2697E72B9E15580002B459 /* APIModRemovePostView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModRemovePostView.swift; sourceTree = ""; }; + CD2697E92B9E15610002B459 /* APIModLockPostView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModLockPostView.swift; sourceTree = ""; }; + CD2697EB2B9E156D0002B459 /* APIModFeaturePostView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModFeaturePostView.swift; sourceTree = ""; }; + CD2697ED2B9E15740002B459 /* APIModRemoveCommentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModRemoveCommentView.swift; sourceTree = ""; }; + CD2697EF2B9E157E0002B459 /* APIModBanFromCommunityView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModBanFromCommunityView.swift; sourceTree = ""; }; + CD2697F12B9E15860002B459 /* APIModRemoveCommunityView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModRemoveCommunityView.swift; sourceTree = ""; }; + CD2697F32B9E158E0002B459 /* APIModBanView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModBanView.swift; sourceTree = ""; }; + CD2697F52B9E15A60002B459 /* APIModAddCommunityView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModAddCommunityView.swift; sourceTree = ""; }; + CD2697F72B9E15AD0002B459 /* APIModTransferCommunityView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModTransferCommunityView.swift; sourceTree = ""; }; + CD2697F92B9E15B90002B459 /* APIModAddView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModAddView.swift; sourceTree = ""; }; + CD2697FB2B9E15C10002B459 /* APIAdminPurgePersonView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIAdminPurgePersonView.swift; sourceTree = ""; }; + CD2697FD2B9E15C80002B459 /* APIAdminPurgeCommunityView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIAdminPurgeCommunityView.swift; sourceTree = ""; }; + CD2697FF2B9E15CF0002B459 /* APIAdminPurgePostView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIAdminPurgePostView.swift; sourceTree = ""; }; + CD2698012B9E15D60002B459 /* APIAdminPurgeCommentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIAdminPurgeCommentView.swift; sourceTree = ""; }; + CD2698032B9E15E50002B459 /* APIModHideCommunityView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModHideCommunityView.swift; sourceTree = ""; }; + CD2698052B9E15F10002B459 /* APIModlogActionType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModlogActionType.swift; sourceTree = ""; }; + CD2698072B9E162A0002B459 /* APIModRemovePost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModRemovePost.swift; sourceTree = ""; }; + CD2698092B9E166D0002B459 /* APIModLockPost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModLockPost.swift; sourceTree = ""; }; + CD26980B2B9E16810002B459 /* APIModRemoveComment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModRemoveComment.swift; sourceTree = ""; }; + CD26980D2B9E16A70002B459 /* APIModFeaturePost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModFeaturePost.swift; sourceTree = ""; }; + CD26980F2B9E174C0002B459 /* APIModBanFromCommunity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModBanFromCommunity.swift; sourceTree = ""; }; + CD2698112B9E17660002B459 /* APIModRemoveCommunity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModRemoveCommunity.swift; sourceTree = ""; }; + CD2698142B9E17AD0002B459 /* APIModBan.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModBan.swift; sourceTree = ""; }; + CD2698162B9E17C60002B459 /* APIModAddCommunity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModAddCommunity.swift; sourceTree = ""; }; + CD2698182B9E17DE0002B459 /* APIModTransferCommunity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModTransferCommunity.swift; sourceTree = ""; }; + CD26981A2B9E17F70002B459 /* APIModAdd.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIModAdd.swift; sourceTree = ""; }; + CD26981C2B9E18090002B459 /* APIAdminPurgePerson.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIAdminPurgePerson.swift; sourceTree = ""; }; + CD26981E2B9E181D0002B459 /* APIAdminPurgeCommunity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIAdminPurgeCommunity.swift; sourceTree = ""; }; + CD2698202B9E18350002B459 /* APIAdminPurgePost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIAdminPurgePost.swift; sourceTree = ""; }; + CD2698222B9E18450002B459 /* APIAdminPurgeComment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIAdminPurgeComment.swift; sourceTree = ""; }; + CD2698242B9E18760002B459 /* ApiModHideCommunity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApiModHideCommunity.swift; sourceTree = ""; }; CD29ED362B2E85EA006937CE /* String+Alphabet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Alphabet.swift"; sourceTree = ""; }; CD29ED382B2E860C006937CE /* String+Trimmed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Trimmed.swift"; sourceTree = ""; }; CD29ED3A2B2E8624006937CE /* String+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+IsNotEmpty.swift"; sourceTree = ""; }; @@ -929,6 +1183,10 @@ CD29ED402B2E867C006937CE /* UIApplication+TopMostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+TopMostViewController.swift"; sourceTree = ""; }; CD29ED462B2E8785006937CE /* EnvironmentValues+NavigationPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+NavigationPath.swift"; sourceTree = ""; }; CD2BD6772A79F55800ECFF89 /* ImageSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSize.swift; sourceTree = ""; }; + CD2BFE732B9F5BE800717611 /* ModlogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogEntry.swift; sourceTree = ""; }; + CD2BFE772B9F60AC00717611 /* APIClient+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient+Instance.swift"; sourceTree = ""; }; + CD2BFE7D2B9F670B00717611 /* ModlogEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogEntryView.swift; sourceTree = ""; }; + CD2BFE812B9FA05300717611 /* ModlogLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogLink.swift; sourceTree = ""; }; CD2E182A2A3B708500224F8A /* Settings Options.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Settings Options.swift"; sourceTree = ""; }; CD309C452A93FBD300988F95 /* Logo View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logo View.swift"; sourceTree = ""; }; CD3720EB2B2E8F96004D7103 /* AlternativeIconCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlternativeIconCell.swift; sourceTree = ""; }; @@ -942,10 +1200,6 @@ CD391F9D2A539F1800E213B5 /* ReplyToMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyToMention.swift; sourceTree = ""; }; CD391F9F2A545F8600E213B5 /* Compact Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Compact Post.swift"; sourceTree = ""; }; CD3FBCDC2A4A6F0600B2063F /* GetReplies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetReplies.swift; sourceTree = ""; }; - CD3FBCE02A4A836000B2063F /* AllItemsFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllItemsFeedView.swift; sourceTree = ""; }; - CD3FBCE22A4A844800B2063F /* Replies Feed View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Replies Feed View.swift"; sourceTree = ""; }; - CD3FBCE42A4A89B900B2063F /* Mentions Feed View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mentions Feed View.swift"; sourceTree = ""; }; - CD3FBCE62A4A8CE300B2063F /* Messages Feed View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Messages Feed View.swift"; sourceTree = ""; }; CD3FBCE82A4B482700B2063F /* Generic Merge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Generic Merge.swift"; sourceTree = ""; }; CD4368AD2AE23ED400BD8BD1 /* StandardTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandardTracker.swift; sourceTree = ""; }; CD4368AF2AE23F1400BD8BD1 /* ChildTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildTracker.swift; sourceTree = ""; }; @@ -968,16 +1222,24 @@ CD4368D82AE2478300BD8BD1 /* MentionModel+InboxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionModel+InboxItem.swift"; sourceTree = ""; }; CD4368DA2AE247B700BD8BD1 /* MentionTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionTracker.swift; sourceTree = ""; }; CD4368DC2AE24E1A00BD8BD1 /* InboxView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InboxView+Logic.swift"; sourceTree = ""; }; + CD436F282BD325CB001711B9 /* String+StrippingDiacritics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+StrippingDiacritics.swift"; sourceTree = ""; }; CD45BCED2A75CA7200A2899C /* Thumbnail Image View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Thumbnail Image View.swift"; sourceTree = ""; }; CD46C1F52B0D0A5700065953 /* EnvironmentValues+TabReselectionHashValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+TabReselectionHashValue.swift"; sourceTree = ""; }; CD46C1F72B0D0A8A00065953 /* View+ReselectAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ReselectAction.swift"; sourceTree = ""; }; CD4BAD342B4B2C0B00A1E726 /* FeedsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedsView.swift; sourceTree = ""; }; CD4BAD3A2B4C6C3200A1E726 /* FeedRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedRowView.swift; sourceTree = ""; }; - CD4BAD3C2B4C6C8E00A1E726 /* FeedType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedType.swift; sourceTree = ""; }; + CD4BAD3C2B4C6C8E00A1E726 /* PostFeedType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedType.swift; sourceTree = ""; }; CD4BAD422B507F2B00A1E726 /* AggregateFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregateFeedView.swift; sourceTree = ""; }; CD4DBC022A6F803C001A1E61 /* ReplyToPost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyToPost.swift; sourceTree = ""; }; + CD4DD9D92BABABB000085D41 /* InboxRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxRoot.swift; sourceTree = ""; }; + CD50504C2B80065300632C56 /* Date+DaysFromNow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+DaysFromNow.swift"; sourceTree = ""; }; + CD5050532B807BF800632C56 /* AddModToCommunity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddModToCommunity.swift; sourceTree = ""; }; CD525F642A4B6D8F00BCA794 /* CommunityLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLinkView.swift; sourceTree = ""; }; + CD55FE252B97F37A0020EE24 /* AddModView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddModView.swift; sourceTree = ""; }; + CD55FE272B9A28D00020EE24 /* SimpleUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleUserSearchView.swift; sourceTree = ""; }; CD59E8A42A72C943005757F4 /* MarkAllAsReadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkAllAsReadRequest.swift; sourceTree = ""; }; + CD5BB8162BADF7700027398F /* ChildSizeReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildSizeReader.swift; sourceTree = ""; }; + CD5F76BB2B75BE700013A827 /* MarkReadBatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkReadBatcher.swift; sourceTree = ""; }; CD64832F2A38D31C00EE6CA3 /* UpvoteCounterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpvoteCounterView.swift; sourceTree = ""; }; CD6483312A38D3A600EE6CA3 /* ScoreCounterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreCounterView.swift; sourceTree = ""; }; CD6483352A39F20800EE6CA3 /* Post Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post Type.swift"; sourceTree = ""; }; @@ -994,6 +1256,7 @@ CD6F29A72A77FF1700F20B6B /* MarkPostRead.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkPostRead.swift; sourceTree = ""; }; CD6F29A92A78003A00F20B6B /* PostRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRepository.swift; sourceTree = ""; }; CD6F29AB2A78015200F20B6B /* PostRepository+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostRepository+Dependency.swift"; sourceTree = ""; }; + CD7798A52BB0E5B50067DF82 /* InboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxView.swift; sourceTree = ""; }; CD7B53B42A5F251400006E81 /* CreatePrivateMessageReportRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePrivateMessageReportRequest.swift; sourceTree = ""; }; CD7B53B62A5F258B00006E81 /* APIPrivateMessageReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPrivateMessageReportView.swift; sourceTree = ""; }; CD7B53B82A5F263D00006E81 /* APIPrivateMessageReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPrivateMessageReport.swift; sourceTree = ""; }; @@ -1003,11 +1266,13 @@ CD82A2542A716C7C00111034 /* APIPersonUnreadCounts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPersonUnreadCounts.swift; sourceTree = ""; }; CD82A2562A716D7C00111034 /* PersonRepository+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersonRepository+Dependency.swift"; sourceTree = ""; }; CD82A2582A71775E00111034 /* UnreadTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadTracker.swift; sourceTree = ""; }; - CD8461652A96F9EB0026A627 /* Website Indicator View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Website Indicator View.swift"; sourceTree = ""; }; CD863FB92A6AEB5900A31ED9 /* View+FancyTabScrollCompatible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+FancyTabScrollCompatible.swift"; sourceTree = ""; }; CD863FBB2A6B026400A31ED9 /* DocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentView.swift; sourceTree = ""; }; + CD876EC62B7736370075DC15 /* MarkReadBatcher+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MarkReadBatcher+Dependency.swift"; sourceTree = ""; }; + CD88B8FB2BDD4B4D0026A6C8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; CD8C55332A95515C0060B75B /* Onboarding Text.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Onboarding Text.swift"; sourceTree = ""; }; CD8CF2082AF3F131009FFC23 /* Firm Info.ahap */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Firm Info.ahap"; sourceTree = ""; }; + CD9395262BA7CF92008F6C4C /* ModlogAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModlogAction.swift; sourceTree = ""; }; CD963FCA2B5F0388002352FD /* DefaultFeedType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultFeedType.swift; sourceTree = ""; }; CD9A03C52B34D20500C16276 /* EnvironmentValues+Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+Navigation.swift"; sourceTree = ""; }; CD9A03C72B389F7000C16276 /* EnvironmentValues+FeedType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+FeedType.swift"; sourceTree = ""; }; @@ -1018,6 +1283,9 @@ CD9DD8822A622A6C0044EA8E /* ReportCommentReply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportCommentReply.swift; sourceTree = ""; }; CD9DD8842A62302A0044EA8E /* ConcreteEditorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcreteEditorModel.swift; sourceTree = ""; }; CDA145EC2A510AC100DDAFC9 /* MarkCommentReplyAsReadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkCommentReplyAsReadRequest.swift; sourceTree = ""; }; + CDA1E84C2B93FC7C007953EF /* BanUserView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BanUserView.swift; sourceTree = ""; }; + CDA1E84E2B93FF83007953EF /* RemovedTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemovedTag.swift; sourceTree = ""; }; + CDA1E8502B940390007953EF /* LockedTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockedTag.swift; sourceTree = ""; }; CDA217E32A62FB3300BDA173 /* ReplyToMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyToMessage.swift; sourceTree = ""; }; CDA217E52A63016A00BDA173 /* ReportMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportMessage.swift; sourceTree = ""; }; CDA217E72A63029B00BDA173 /* ReportMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportMention.swift; sourceTree = ""; }; @@ -1033,12 +1301,20 @@ CDB45C5F2AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionModel+TrackerItem.swift"; sourceTree = ""; }; CDB45C612AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReplyModel+TrackerItem.swift"; sourceTree = ""; }; CDB45C632AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageModel+TrackerItem.swift"; sourceTree = ""; }; + CDB652522B8EABFC007B7797 /* FeaturePostRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeaturePostRequest.swift; sourceTree = ""; }; + CDB652542B8EAC3E007B7797 /* APIPostFeatureType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPostFeatureType.swift; sourceTree = ""; }; + CDB652562B8EAE15007B7797 /* APIPostResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIPostResponse.swift; sourceTree = ""; }; + CDB652582B8EC024007B7797 /* LockPostRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LockPostRequest.swift; sourceTree = ""; }; + CDBA5FC52BC9C58300469C05 /* GetUnreadRegistrationApplicationCountRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetUnreadRegistrationApplicationCountRequest.swift; sourceTree = ""; }; + CDBA5FC72BCC477F00469C05 /* WarningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WarningView.swift; sourceTree = ""; }; + CDBA5FC92BD17F3D00469C05 /* CommunityListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListModel.swift; sourceTree = ""; }; CDBCBA1F2B537A4B0070F60D /* PostFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedView.swift; sourceTree = ""; }; CDBCBA232B54A5F40070F60D /* NoPostsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoPostsView.swift; sourceTree = ""; }; CDC1C93B2A7AA76000072E3D /* InternetSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternetSpeed.swift; sourceTree = ""; }; CDC1C93E2A7AB8C700072E3D /* AccessibilitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilitySettingsView.swift; sourceTree = ""; }; CDC1C9402A7ABA9C00072E3D /* ReadMarkStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkStyle.swift; sourceTree = ""; }; CDC1C9422A7AC24600072E3D /* ReadCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadCheck.swift; sourceTree = ""; }; + CDC24EA52BC9B352009AA6D1 /* GetReportCountRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetReportCountRequest.swift; sourceTree = ""; }; CDC3E7FF2AEAFEAF008062CA /* InboxTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxTracker.swift; sourceTree = ""; }; CDC65D8E2A86B6DD007205E5 /* DeleteUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteUser.swift; sourceTree = ""; }; CDC65D902A86B830007205E5 /* DeleteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountView.swift; sourceTree = ""; }; @@ -1048,6 +1324,57 @@ CDCBD7252A8D69A200387A2C /* Instance Picker View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance Picker View.swift"; sourceTree = ""; }; CDCBD7272A8D6B7700387A2C /* Instance Picker View Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance Picker View Logic.swift"; sourceTree = ""; }; CDCBD72A2A8EC0A800387A2C /* Instance Summary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance Summary.swift"; sourceTree = ""; }; + CDD0B8AA2BB37B3D003E7174 /* WebsiteIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteIndicatorView.swift; sourceTree = ""; }; + CDD0B8AC2BB4CD7D003E7174 /* CommentReportModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentReportModel.swift; sourceTree = ""; }; + CDD0B8B12BB4D990003E7174 /* CommentReportModel+InboxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentReportModel+InboxItem.swift"; sourceTree = ""; }; + CDD0B8B32BB4DAFE003E7174 /* CommentReportModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentReportModel+TrackerItem.swift"; sourceTree = ""; }; + CDD0B8B52BB4DC12003E7174 /* CommentReportTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentReportTracker.swift; sourceTree = ""; }; + CDD0B8B82BB4E407003E7174 /* ListCommentReportsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListCommentReportsRequest.swift; sourceTree = ""; }; + CDD0B8BA2BB4E51B003E7174 /* APIClient+Moderation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient+Moderation.swift"; sourceTree = ""; }; + CDD0B8BC2BB4F3D3003E7174 /* InboxCommentReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxCommentReportView.swift; sourceTree = ""; }; + CDD0B8BE2BB5B68F003E7174 /* EmbeddedCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedCommentView.swift; sourceTree = ""; }; + CDD0B8C02BB5BC51003E7174 /* InboxMessageBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxMessageBodyView.swift; sourceTree = ""; }; + CDD0B8C22BB5BCFD003E7174 /* InboxCommentReportBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxCommentReportBodyView.swift; sourceTree = ""; }; + CDD0B8C42BB78056003E7174 /* ResolveButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveButtonView.swift; sourceTree = ""; }; + CDD0B8C62BB78260003E7174 /* RemoveButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveButtonView.swift; sourceTree = ""; }; + CDD0B8C82BB782CF003E7174 /* PurgeButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeButtonView.swift; sourceTree = ""; }; + CDD0B8CA2BB7844F003E7174 /* BanButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanButtonView.swift; sourceTree = ""; }; + CDD0B8CC2BB9E53F003E7174 /* Removable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Removable.swift; sourceTree = ""; }; + CDD0B8CE2BBA0D31003E7174 /* ResolveCommentReportRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveCommentReportRequest.swift; sourceTree = ""; }; + CDD0B8D22BBB4158003E7174 /* InboxView+Feeds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InboxView+Feeds.swift"; sourceTree = ""; }; + CDD0B8D42BBB41CB003E7174 /* InboxFeedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InboxFeedView.swift; sourceTree = ""; }; + CDD0B8D72BBF3C5A003E7174 /* PostReportTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostReportTracker.swift; sourceTree = ""; }; + CDD0B8D92BBF3C87003E7174 /* PostReportModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostReportModel.swift; sourceTree = ""; }; + CDD0B8DB2BBF4238003E7174 /* ListPostReportsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPostReportsRequest.swift; sourceTree = ""; }; + CDD0B8DD2BBF4601003E7174 /* PostReportModel+InboxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostReportModel+InboxItem.swift"; sourceTree = ""; }; + CDD0B8DF2BBF4689003E7174 /* PostReportModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostReportModel+TrackerItem.swift"; sourceTree = ""; }; + CDD0B8E12BBF49DB003E7174 /* InboxPostReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxPostReportView.swift; sourceTree = ""; }; + CDD0B8E32BBF4D4B003E7174 /* InboxPostReportBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxPostReportBodyView.swift; sourceTree = ""; }; + CDD0B8E52BBF57D9003E7174 /* ResolvePostReportRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolvePostReportRequest.swift; sourceTree = ""; }; + CDD0B8E72BBF7C8B003E7174 /* MessageReportTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReportTracker.swift; sourceTree = ""; }; + CDD0B8E92BBF7CAB003E7174 /* MessageReportModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReportModel.swift; sourceTree = ""; }; + CDD0B8EB2BBF7D0F003E7174 /* ListPrivateMessageReportsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPrivateMessageReportsRequest.swift; sourceTree = ""; }; + CDD0B8ED2BBF7F21003E7174 /* MessageReportModel+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReportModel+TrackerItem.swift"; sourceTree = ""; }; + CDD0B8EF2BBF7FB7003E7174 /* MessageReportModel+InboxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReportModel+InboxItem.swift"; sourceTree = ""; }; + CDD0B8F12BBF8406003E7174 /* InboxMessageReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxMessageReportView.swift; sourceTree = ""; }; + CDD0B8F32BBF843B003E7174 /* InboxMessageReportBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxMessageReportBodyView.swift; sourceTree = ""; }; + CDD0B8F52BC064F0003E7174 /* ResolvePrivateMessageReportRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolvePrivateMessageReportRequest.swift; sourceTree = ""; }; + CDD0B8F72BC07E6A003E7174 /* ListRegistrationApplicationsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRegistrationApplicationsRequest.swift; sourceTree = ""; }; + CDD0B8F92BC07EA9003E7174 /* APIRegistrationApplicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIRegistrationApplicationView.swift; sourceTree = ""; }; + CDD0B8FB2BC07ED7003E7174 /* APIRegistrationApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIRegistrationApplication.swift; sourceTree = ""; }; + CDD0B8FD2BC07F4B003E7174 /* RegistrationApplicationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationApplicationModel.swift; sourceTree = ""; }; + CDD0B8FF2BC080C7003E7174 /* ApproveRegistrationApplicationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApproveRegistrationApplicationRequest.swift; sourceTree = ""; }; + CDD0B9012BC084A9003E7174 /* DenyApplicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DenyApplicationView.swift; sourceTree = ""; }; + CDD0B9032BC08987003E7174 /* RegistrationApplication+TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RegistrationApplication+TrackerItem.swift"; sourceTree = ""; }; + CDD0B9052BC089EA003E7174 /* RegistrationApplication+InboxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RegistrationApplication+InboxItem.swift"; sourceTree = ""; }; + CDD0B9072BC08A6D003E7174 /* RegistrationApplicationTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationApplicationTracker.swift; sourceTree = ""; }; + CDD0B9092BC08E02003E7174 /* InboxRegistrationApplicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxRegistrationApplicationView.swift; sourceTree = ""; }; + CDD0B90B2BC08E21003E7174 /* InboxRegistrationApplicationBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxRegistrationApplicationBodyView.swift; sourceTree = ""; }; + CDD0EF9B2B7D6B9100CA3504 /* ModToolTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModToolTracker.swift; sourceTree = ""; }; + CDD0EF9D2B7D6F3E00CA3504 /* ModToolSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModToolSheet.swift; sourceTree = ""; }; + CDD0EFA12B7D9E5800CA3504 /* UserListRowBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListRowBody.swift; sourceTree = ""; }; + CDD0EFA42B7E8B3200CA3504 /* ModeratorListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeratorListView.swift; sourceTree = ""; }; + CDD0EFAC2B7EA73000CA3504 /* BanFromCommunity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanFromCommunity.swift; sourceTree = ""; }; CDD55D1C2B23BA41002020C7 /* EasyTapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EasyTapLinkView.swift; sourceTree = ""; }; CDD55D212B2674BD002020C7 /* String+ParseLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+ParseLinks.swift"; sourceTree = ""; }; CDDB08772A5DF1330075BFEE /* CommentSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentSettingsView.swift; sourceTree = ""; }; @@ -1064,9 +1391,8 @@ CDE3BA882A8C64BD00B972E2 /* Collapsible Text Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collapsible Text Item.swift"; sourceTree = ""; }; CDE6A80A2A43E9F00062D161 /* CommentSortType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentSortType.swift; sourceTree = ""; }; CDE6A80C2A45EAB30062D161 /* Embedded Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Embedded Post.swift"; sourceTree = ""; }; - CDE6A8152A490AE00062D161 /* InboxMessageBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxMessageBodyView.swift; sourceTree = ""; }; CDE6A8172A490AF20062D161 /* InboxMentionBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxMentionBodyView.swift; sourceTree = ""; }; - CDE6A8192A490B970062D161 /* Inbox ReplyBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Inbox ReplyBodyView.swift"; sourceTree = ""; }; + CDE6A8192A490B970062D161 /* InboxReplyBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxReplyBodyView.swift; sourceTree = ""; }; CDE9CE4B2A7B0831002B97DD /* Gentle Info.ahap */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Gentle Info.ahap"; sourceTree = ""; }; CDE9CE4E2A7B0B1B002B97DD /* Haptic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptic.swift; sourceTree = ""; }; CDE9CE502A7B0C66002B97DD /* Light Success.ahap */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Light Success.ahap"; sourceTree = ""; }; @@ -1112,7 +1438,7 @@ E49F0E752A90395400BC4EE3 /* NavigationPath+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationPath+Helpers.swift"; sourceTree = ""; }; E4A7BFD02B35912500B95F56 /* InboxMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxMessageView.swift; sourceTree = ""; }; E4A7BFD22B35913F00B95F56 /* InboxMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxMentionView.swift; sourceTree = ""; }; - E4D4DB9F2A7C7B9D00C4F3DE /* Comments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comments.swift; sourceTree = ""; }; + E4D4DB9F2A7C7B9D00C4F3DE /* Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Animations.swift; sourceTree = ""; }; E4DDB4312A81819300B3A7E0 /* Double+MaxZIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+MaxZIndex.swift"; sourceTree = ""; }; E4DDB4332A819C8000B3A7E0 /* QuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookView.swift; sourceTree = ""; }; E4F0B56E2ABD00A000BC3E4A /* View+PresentationBackgroundInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+PresentationBackgroundInteraction.swift"; sourceTree = ""; }; @@ -1124,6 +1450,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 03F6D4B92B951E21008235A0 /* SwiftUIIntrospect in Frameworks */, B104A6DA2A59BF3C00B3E725 /* NukeExtensions in Frameworks */, 63D24EDC2A169F12005CCA81 /* MarkdownUI in Frameworks */, B104A6D82A59BF3C00B3E725 /* Nuke in Frameworks */, @@ -1152,10 +1479,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 030245CB2BA70F5F00D07747 /* Links */ = { + isa = PBXGroup; + children = ( + 030245C92BA70F5200D07747 /* LinksSettingsView.swift */, + ); + path = Links; + sourceTree = ""; + }; 0308E1122B0EA31F000CA955 /* Account */ = { isa = PBXGroup; children = ( 0308E1132B0EA32A000CA955 /* AccountSettingsView.swift */, + 039B4FE82BD2D81D00E42114 /* BlockListView.swift */, + 033EC0AE2BD3030A00AA238F /* BlockListView+Logic.swift */, 03A18CBA2B0FD6F000BA69D2 /* ProfileSettingsView.swift */, 031A617B2B1BDFD100ABF23B /* AdvancedAccountSettingsView.swift */, 03F4DC9E2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift */, @@ -1271,9 +1608,7 @@ 030D00832AD0842900953B1D /* Results */ = { isa = PBXGroup; children = ( - 03EEEAF22AB8DCDF0087F8D8 /* CommunityResultView.swift */, 0355DA502B5EB87700CDF5A5 /* InstanceResultView.swift */, - 03B7AAF42ABEFA7A00068B23 /* UserResultView.swift */, ); path = Results; sourceTree = ""; @@ -1301,6 +1636,14 @@ path = "Search Bar"; sourceTree = ""; }; + 030FF6842BCB217700F6BFAC /* Int */ = { + isa = PBXGroup; + children = ( + 030FF6852BCB218000F6BFAC /* Int+Abbreviated.swift */, + ); + path = Int; + sourceTree = ""; + }; 031A93D42AC847D10077030C /* Image Upload */ = { isa = PBXGroup; children = ( @@ -1368,6 +1711,14 @@ path = Shared; sourceTree = ""; }; + 0355A1DC2BB1F0FE00D54F9F /* Moderation */ = { + isa = PBXGroup; + children = ( + 0355A1DD2BB1F12500D54F9F /* ModerationSettingsView.swift */, + ); + path = Moderation; + sourceTree = ""; + }; 03A1B3F52A83FFDA00AB0DE0 /* Protocol */ = { isa = PBXGroup; children = ( @@ -1381,6 +1732,14 @@ isa = PBXGroup; children = ( 03A54C312B5331F30064CCDE /* InstanceView.swift */, + 03CEE04C2B6EBEA800D65B1B /* InstanceView+Logic.swift */, + 03A4330A2B6FB2C10004E743 /* FediseerOpinionListView.swift */, + 03E47AEC2B66BC0000A3E4DB /* InstanceUptimeView.swift */, + 03AFBEB02B6EAD5B00F01F3C /* InstanceSafetyView.swift */, + 03A4330C2B6FC0940004E743 /* FediseerInfoView.swift */, + 03CEE04E2B6ECFFC00D65B1B /* FediseerOpinionView.swift */, + 03CEE04A2B6EB9CD00D65B1B /* Fediseer.swift */, + 03E47AEA2B66BADC00A3E4DB /* UptimeData.swift */, 031F95562B5C7FF20069C244 /* InstanceDetailsView.swift */, ); path = Instance; @@ -1390,6 +1749,7 @@ isa = PBXGroup; children = ( 03A54C342B533BC50064CCDE /* InstanceModel.swift */, + 03E47AEE2B66BD3C00A3E4DB /* InstanceModel+Uptime.swift */, 0355DA4E2B5EB63600CDF5A5 /* InstanceStub.swift */, 0355DA4C2B5EB51900CDF5A5 /* InstanceModel+ContentModel.swift */, 03A54C362B545A430064CCDE /* InstanceModel+MenuFunctions.swift */, @@ -1405,6 +1765,14 @@ path = TabBar; sourceTree = ""; }; + 03C942902B6457A3002068A4 /* Administration */ = { + isa = PBXGroup; + children = ( + 03C942912B6457B4002068A4 /* BanUserEditorModel.swift */, + ); + path = Administration; + sourceTree = ""; + }; 03E79F3D2AE3E6FF0006700D /* Sorting */ = { isa = PBXGroup; children = ( @@ -1422,6 +1790,13 @@ path = Post; sourceTree = ""; }; + 03F6D4B72B951E21008235A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; 03FD64FD2AE538C600957AA9 /* Community */ = { isa = PBXGroup; children = ( @@ -1489,11 +1864,18 @@ 0394398E2A98EB2300463032 /* APIComment+Mock.swift */, 50811B312A9204C1006BA3F2 /* APICommunity+Mock.swift */, 50811B332A9204EB006BA3F2 /* APICommunityAggregates+Mock.swift */, + 03AFBEAE2B6EAB9C00F01F3C /* APISiteAggregates+Mock.swift */, 50811B372A920545006BA3F2 /* APICommunityModeratorView+Mock.swift */, 50811B2F2A92049B006BA3F2 /* APICommunityView+Mock.swift */, 50811B392A920569006BA3F2 /* APIPerson+Mock.swift */, + 03AFBEA42B6EA90400F01F3C /* APIPersonView+Mock.swift */, + 03AFBEA62B6EA94900F01F3C /* APIPersonAggregates+Mock.swift */, 50811B432A920945006BA3F2 /* APIPost+Mock.swift */, 50811B352A920519006BA3F2 /* APISite+Mock.swift */, + 03AFBEA82B6EA9BC00F01F3C /* APISiteView+Mock.swift */, + 03AFBEAC2B6EAAF000F01F3C /* APILocalSiteRateLimit+Mock.swift */, + 03AFBEAA2B6EAA0C00F01F3C /* APILocalSite+Mock.swift */, + 03AFBEA22B6EA86B00F01F3C /* SiteResponse+Mock.swift */, 50811B3B2A92059C006BA3F2 /* BlockCommunityResponse+Mock.swift */, 50811B3F2A9205EE006BA3F2 /* CommunityResponse+Mock.swift */, 50811B2B2A920443006BA3F2 /* Date+Mock.swift */, @@ -1534,6 +1916,7 @@ 5064D03E2A6DE0DB00B22EE3 /* Notifier+Dependency.swift */, 50785F722A98E03F00117245 /* SiteInformationTracker+Dependency.swift */, CD4368CD2AE242C900BD8BD1 /* InboxRepository+Dependency.swift */, + CD876EC62B7736370075DC15 /* MarkReadBatcher+Dependency.swift */, ); path = Dependency; sourceTree = ""; @@ -1588,6 +1971,7 @@ CD4368B72AE23F5400BD8BD1 /* ParentTrackerProtocol.swift */, CD4368B92AE23F6400BD8BD1 /* TrackerItem.swift */, CD4368BB2AE23F6F00BD8BD1 /* TrackerSort.swift */, + CD17C1E92BA3997000A0C8BC /* TrackerProtocol.swift */, ); path = Generics; sourceTree = ""; @@ -1607,6 +1991,9 @@ children = ( 6318DE5327FB958800CC2AD6 /* Stickied Tag.swift */, CD6483372A3A0F2200EE6CA3 /* NSFW Tag.swift */, + 03B85A3F2BB38868003C4203 /* PostEllipsisMenus.swift */, + CDA1E84E2B93FF83007953EF /* RemovedTag.swift */, + CDA1E8502B940390007953EF /* LockedTag.swift */, ); path = Components; sourceTree = ""; @@ -1614,6 +2001,7 @@ 6318EDC427EE4E0500BFCAE8 /* Models */ = { isa = PBXGroup; children = ( + CD5F76BA2B75B8290013A827 /* Batchers */, CDEBC3262A9A57E900518D9D /* Content */, CDA2C5242A705D3100649D5A /* Composers */, B14E93BE2A45CA1A00D6DA93 /* Navigation Contexts */, @@ -1641,7 +2029,6 @@ B1955A1B2A606B810056CF99 /* Easter */, 630049EB27EF390900D5105B /* Networking */, 6322A5D127F88CFD00135D4F /* Time Parser.swift */, - 03C897F52ABF49BD005F3403 /* Abbreviate Numbers.swift */, 50EC39B12A346DDC00E014C2 /* URLHandler.swift */, CD3FBCE82A4B482700B2063F /* Generic Merge.swift */, 50CC4A7E2AA0D3A90074C845 /* InstanceMetadataParser.swift */, @@ -1665,7 +2052,11 @@ 035EB0C92A8687C200227859 /* JumpButtonView.swift */, CD1446172A58FC3B00610EF1 /* InfoStackView.swift */, CDE3BA882A8C64BD00B972E2 /* Collapsible Text Item.swift */, - CD8461652A96F9EB0026A627 /* Website Indicator View.swift */, + CDD0B8AA2BB37B3D003E7174 /* WebsiteIndicatorView.swift */, + CDD0B8C42BB78056003E7174 /* ResolveButtonView.swift */, + CDD0B8C62BB78260003E7174 /* RemoveButtonView.swift */, + CDD0B8C82BB782CF003E7174 /* PurgeButtonView.swift */, + CDD0B8CA2BB7844F003E7174 /* BanButtonView.swift */, ); path = Components; sourceTree = ""; @@ -1687,6 +2078,7 @@ 6332FDC127EFCB530009A98A /* Extensions */ = { isa = PBXGroup; children = ( + 030FF6842BCB217700F6BFAC /* Int */, CD29ED2C2B2E829B006937CE /* Array */, CD29ED2D2B2E82D1006937CE /* Bundle */, CD29ED2F2B2E83F6006937CE /* Color */, @@ -1727,9 +2119,11 @@ 63344C522A07D189001BC616 /* Views */ = { isa = PBXGroup; children = ( + 0355A1DC2BB1F0FE00D54F9F /* Moderation */, 032C1E022B5D7D9C00FB4F23 /* AccountList */, 0308E1122B0EA31F000CA955 /* Account */, 030AC0432A62F82B00037155 /* General */, + 030245CB2BA70F5F00D07747 /* Links */, 03E79F3D2AE3E6FF0006700D /* Sorting */, 030AC0442A62F83100037155 /* Filters */, CDC1C93D2A7AB8B400072E3D /* Accessibility */, @@ -1751,10 +2145,12 @@ 6363D5B827EE196700E34822 = { isa = PBXGroup; children = ( + CD88B8FB2BDD4B4D0026A6C8 /* PrivacyInfo.xcprivacy */, 6363D5C327EE196700E34822 /* Mlem */, 6363D5D927EE196A00E34822 /* MlemTests */, 6363D5E327EE196A00E34822 /* MlemUITests */, 6363D5C227EE196700E34822 /* Products */, + 03F6D4B72B951E21008235A0 /* Frameworks */, ); sourceTree = ""; }; @@ -1896,6 +2292,7 @@ 637218042A3A2AAD008C4816 /* Models */ = { isa = PBXGroup; children = ( + CDD0B8AE2BB4CE44003E7174 /* Reports */, CD6A2A772B1A552800003E23 /* Common */, CDF842582A49D23800723DA0 /* Messages */, 637218052A3A2AAD008C4816 /* Comments */, @@ -1918,8 +2315,6 @@ 637218082A3A2AAD008C4816 /* APICommentView.swift */, 637218092A3A2AAD008C4816 /* APICommentReply.swift */, 6372180A2A3A2AAD008C4816 /* APICommentReplyView.swift */, - 6D693A492A51B98F009E2D76 /* APICommentReportView.swift */, - 6D693A4B2A51B99E009E2D76 /* APICommentReport.swift */, ); path = Comments; sourceTree = ""; @@ -1927,11 +2322,10 @@ 6372180B2A3A2AAD008C4816 /* Posts */ = { isa = PBXGroup; children = ( + CDB652562B8EAE15007B7797 /* APIPostResponse.swift */, 6372180C2A3A2AAD008C4816 /* APIPost.swift */, 6372180D2A3A2AAD008C4816 /* APIPostAggregates.swift */, 6372180E2A3A2AAD008C4816 /* APIPostView.swift */, - 6D693A3F2A51147E009E2D76 /* APIPostReportView.swift */, - 6D693A412A5114DF009E2D76 /* APIPostReport.swift */, ); path = Posts; sourceTree = ""; @@ -1952,6 +2346,7 @@ 637218142A3A2AAD008C4816 /* Site */ = { isa = PBXGroup; children = ( + CD2698132B9E17870002B459 /* Modlog */, 637218152A3A2AAD008C4816 /* APILocalSiteRateLimit.swift */, 637218162A3A2AAD008C4816 /* APIFederatedInstances.swift */, 637218172A3A2AAD008C4816 /* APIMyUserInfo.swift */, @@ -1963,6 +2358,8 @@ 6372181B2A3A2AAD008C4816 /* APISiteView.swift */, 6372181C2A3A2AAD008C4816 /* APISite.swift */, 6372181D2A3A2AAD008C4816 /* APISiteAggregates.swift */, + CDD0B8F92BC07EA9003E7174 /* APIRegistrationApplicationView.swift */, + CDD0B8FB2BC07ED7003E7174 /* APIRegistrationApplication.swift */, ); path = Site; sourceTree = ""; @@ -1982,6 +2379,7 @@ 637218242A3A2AAD008C4816 /* Requests */ = { isa = PBXGroup; children = ( + CDD0B8B72BB4E3F2003E7174 /* Moderation */, CDC65D8D2A86B6D4007205E5 /* User */, CDF842622A49EAEC00723DA0 /* Messages */, 637218252A3A2AAD008C4816 /* LoginRequest.swift */, @@ -1999,7 +2397,11 @@ 637218262A3A2AAD008C4816 /* Post */ = { isa = PBXGroup; children = ( + CDB652582B8EC024007B7797 /* LockPostRequest.swift */, + 03F0DF552B9E0E210018F239 /* PurgePostRequest.swift */, + CDB652522B8EABFC007B7797 /* FeaturePostRequest.swift */, 637218272A3A2AAD008C4816 /* EditPost.swift */, + 039C59AC2BADFF6200C18765 /* ListPostLikesRequest.swift */, 637218282A3A2AAD008C4816 /* CreatePostLike.swift */, 637218292A3A2AAD008C4816 /* DeletePost.swift */, 6372182A2A3A2AAD008C4816 /* CreatePost.swift */, @@ -2009,6 +2411,7 @@ 6372182D2A3A2AAD008C4816 /* SavePost.swift */, 6D693A3D2A5113DF009E2D76 /* CreatePostReport.swift */, CD6F29A72A77FF1700F20B6B /* MarkPostRead.swift */, + CD0D5A422B8EC4DA005E3365 /* RemovePostRequest.swift */, ); path = Post; sourceTree = ""; @@ -2016,9 +2419,11 @@ 6372182E2A3A2AAD008C4816 /* Person */ = { isa = PBXGroup; children = ( + 03C942952B648252002068A4 /* BanPerson.swift */, 6372182F2A3A2AAD008C4816 /* GetPersonDetails.swift */, CDF842632A49EAFA00723DA0 /* GetPersonMentions.swift */, CD3FBCDC2A4A6F0600B2063F /* GetReplies.swift */, + 03F0DF572B9E24EF0018F239 /* PurgePersonRequest.swift */, CDA145EC2A510AC100DDAFC9 /* MarkCommentReplyAsReadRequest.swift */, CD18DC6A2A5202D4002C56BC /* MarkPersonMentionAsReadRequest.swift */, CD18DC6E2A5209C3002C56BC /* MarkPrivateMessageAsReadRequest.swift */, @@ -2034,8 +2439,11 @@ children = ( 637218312A3A2AAD008C4816 /* CreateComment.swift */, 637218322A3A2AAD008C4816 /* CreateCommentLike.swift */, + 039C59A82BADA5DA00C18765 /* PurgeCommentRequest.swift */, + 03D89E712BB1BB0100F49DB3 /* ListCommentLikesRequest.swift */, 637218332A3A2AAD008C4816 /* GetComment.swift */, 637218342A3A2AAD008C4816 /* GetComments.swift */, + 039C59AA2BADC85400C18765 /* RemoveCommentRequest.swift */, 637218352A3A2AAD008C4816 /* SaveComment.swift */, 637218362A3A2AAD008C4816 /* EditComment.swift */, 637218372A3A2AAD008C4816 /* DeleteComment.swift */, @@ -2047,8 +2455,10 @@ 6372183A2A3A2AAD008C4816 /* Site */ = { isa = PBXGroup; children = ( + CD2697E42B9E14FB0002B459 /* GetModlogRequest.swift */, 6372183B2A3A2AAD008C4816 /* GetSite.swift */, 031A617F2B1CEA7300ABF23B /* ChangePassword.swift */, + 030FF6872BCEE58900F6BFAC /* BlockInstance.swift */, 03A18CBC2B1005A400BA69D2 /* SaveUserSettings.swift */, ); path = Site; @@ -2060,8 +2470,12 @@ 6372183D2A3A2AAD008C4816 /* HideCommunity.swift */, 6372183E2A3A2AAD008C4816 /* FollowCommunity.swift */, 6372183F2A3A2AAD008C4816 /* ListCommunities.swift */, + 030245C72BA617FE00D07747 /* PurgeCommunityRequest.swift */, + 030245C52BA6138100D07747 /* RemoveCommunityRequest.swift */, 637218402A3A2AAD008C4816 /* GetCommunity.swift */, 637218412A3A2AAD008C4816 /* BlockCommunity.swift */, + CDD0EFAC2B7EA73000CA3504 /* BanFromCommunity.swift */, + CD5050532B807BF800632C56 /* AddModToCommunity.swift */, ); path = Community; sourceTree = ""; @@ -2082,6 +2496,8 @@ CDB0117E2A6F70A000D043EB /* Editor Tracker.swift */, 50785F702A98C4F600117245 /* SiteInformationTracker.swift */, 03B7AAEE2ABCB9DC00068B23 /* ContentTracker.swift */, + CDD0EF9B2B7D6B9100CA3504 /* ModToolTracker.swift */, + 03B85A3D2BB36C4B003C4203 /* UserRemovalWalker.swift */, ); path = Trackers; sourceTree = ""; @@ -2090,30 +2506,38 @@ isa = PBXGroup; children = ( 6FF17D002B685C16007E1814 /* AppLockView.swift */, - CD2053152ACBBB490000AA38 /* Avatars */, - CD2E147B2A6B2891004198DE /* Components */, - 6332FDCD27EFDD0A0009A98A /* Accounts */, - CD2E147A2A6B2871004198DE /* Comments */, - CD2E14792A6B285F004198DE /* Posts */, - ADF266932A4E89F800EBA648 /* Composer */, - CD525F662A4B892900BCA794 /* Links */, - E453A1CE2A81C1F20004BB8A /* Quick Look */, + 0317D46E2B558CB500EEE72C /* BadgeView.swift */, + B11D72822A49FAA7009DC22F /* Cached Image.swift */, + 0300F2C52B9C7D4B0022F7C4 /* CloseButtonView.swift */, + 0304F5892B44AF5B00537BFA /* CollapsibleSection.swift */, + 6374570F2A18CB6600B69C03 /* Custom Text Field.swift */, + CD863FBB2A6B026400A31ED9 /* DocumentView.swift */, 63A09B68285F53E9004F0032 /* Error View.swift */, 6322A5CA27F77A4D00135D4F /* Loading View.swift */, - 6386E03F2A045723006B3C1D /* Website Icon Complex.swift */, 63D24EDD2A169F2A005CCA81 /* Markdown View.swift */, 03B15BEC2B55CBBB00E7C30A /* MarkdownTheme.swift */, - 03A54C302B5331D50064CCDE /* Instance */, 03BAA2392A57DC1400D48252 /* PublishedTimestampView.swift */, - 6374570F2A18CB6600B69C03 /* Custom Text Field.swift */, - 0304F5892B44AF5B00537BFA /* CollapsibleSection.swift */, - B11D72822A49FAA7009DC22F /* Cached Image.swift */, - 0317D46E2B558CB500EEE72C /* BadgeView.swift */, + E46AF9912B29AA340087FDF3 /* ScrollToView.swift */, + 03F6D4BA2B952738008235A0 /* SelectTextView.swift */, 50F2851B2A5C5C1500CF8865 /* TokenRefreshView.swift */, - CD863FBB2A6B026400A31ED9 /* DocumentView.swift */, - 030E86422AC6F6CB000283A6 /* Search Bar */, + 030245C12BA60A2600D07747 /* ToolbarEllipsisMenu.swift */, CD20530F2AC878B50000AA38 /* UpdatedTimestampView.swift */, - E46AF9912B29AA340087FDF3 /* ScrollToView.swift */, + 6386E03F2A045723006B3C1D /* Website Icon Complex.swift */, + 03A4330E2B7186C20004E743 /* WebView.swift */, + 6332FDCD27EFDD0A0009A98A /* Accounts */, + CD2053152ACBBB490000AA38 /* Avatars */, + CD2E147A2A6B2871004198DE /* Comments */, + CD268C122B9A3DCF0074DBEE /* CommunityList */, + CD2E147B2A6B2891004198DE /* Components */, + ADF266932A4E89F800EBA648 /* Composer */, + 03A54C302B5331D50064CCDE /* Instance */, + CD525F662A4B892900BCA794 /* Links */, + CDD0EF922B7A957B00CA3504 /* Moderation */, + CD2E14792A6B285F004198DE /* Posts */, + E453A1CE2A81C1F20004BB8A /* Quick Look */, + 030E86422AC6F6CB000283A6 /* Search Bar */, + CDD0EFA32B7E8AF900CA3504 /* UserList */, + CDBA5FC72BCC477F00469C05 /* WarningView.swift */, ); path = Shared; sourceTree = ""; @@ -2142,7 +2566,8 @@ CD2053132ACBAF150000AA38 /* AvatarType.swift */, CD4368BD2AE23FA600BD8BD1 /* LoadingState.swift */, CD4368C92AE2428C00BD8BD1 /* ContentIdentifiable.swift */, - CD4BAD3C2B4C6C8E00A1E726 /* FeedType.swift */, + CD4BAD3C2B4C6C8E00A1E726 /* PostFeedType.swift */, + CDB652542B8EAC3E007B7797 /* APIPostFeatureType.swift */, ); path = Enums; sourceTree = ""; @@ -2150,6 +2575,7 @@ 6DA61F7F2A55B831001EA633 /* Search */ = { isa = PBXGroup; children = ( + CD5BB8152BADF7660027398F /* BubblePicker */, 6DA61F802A55B83F001EA633 /* SearchView.swift */, 03C897F72ABF652D005F3403 /* SearchRoot.swift */, 03C898002AC04EF9005F3403 /* SearchResultsView.swift */, @@ -2157,7 +2583,6 @@ 03C898022AC04F61005F3403 /* RecentSearchesView.swift */, 03EC92942AC064AE007BBE7E /* SearchHomeView.swift */, 036ED3BB2ABF1058009664BC /* SearchModel.swift */, - 03EEEAF62AB8ED3C0087F8D8 /* BubblePicker.swift */, 030D00832AD0842900953B1D /* Results */, ); path = Search; @@ -2186,9 +2611,12 @@ 6DFF50412A48DEC0001E648D /* Inbox */ = { isa = PBXGroup; children = ( - CDE6A80E2A4908200062D161 /* Feed */, - 6DFF50422A48DED3001E648D /* Inbox View.swift */, + CDD0B8D42BBB41CB003E7174 /* InboxFeedView.swift */, + CD4DD9D92BABABB000085D41 /* InboxRoot.swift */, + CD7798A52BB0E5B50067DF82 /* InboxView.swift */, + CDD0B8D22BBB4158003E7174 /* InboxView+Feeds.swift */, CD4368DC2AE24E1A00BD8BD1 /* InboxView+Logic.swift */, + CDE6A8142A490AC60062D161 /* Item Types */, ); path = Inbox; sourceTree = ""; @@ -2205,9 +2633,11 @@ ADF266932A4E89F800EBA648 /* Composer */ = { isa = PBXGroup; children = ( + 03EA79C32AC0D92C00BCDC91 /* PostComposerView+Logic.swift */, CD391F952A535F5400E213B5 /* ResponseEditorView.swift */, + 03C194072BA25B5200B00349 /* ProgressOverlayView.swift */, + 03F6D4BC2B966D53008235A0 /* BodyEditorView.swift */, 03CB329D2A6D8E910021EF27 /* PostComposerView.swift */, - 03EA79C32AC0D92C00BCDC91 /* PostComposerView+Logic.swift */, ); path = Composer; sourceTree = ""; @@ -2218,6 +2648,7 @@ B14E93BF2A45CA3400D6DA93 /* Post Link.swift */, B14E93C12A45D3B300D6DA93 /* Community Link.swift */, 6DE118382A4A20D600810C7E /* Lazy Load Post Link.swift */, + CD2BFE812B9FA05300717611 /* ModlogLink.swift */, ); path = "Navigation Contexts"; sourceTree = ""; @@ -2239,6 +2670,8 @@ 50A8812D2A72D76C003E3661 /* APIClient+Comment.swift */, 03EC92982AC0BF8A007BBE7E /* APIClient+Pictrs.swift */, CDEBC3382A9ADE6C00518D9D /* APIClient+Post.swift */, + CD2BFE772B9F60AC00717611 /* APIClient+Instance.swift */, + CDD0B8BA2BB4E51B003E7174 /* APIClient+Moderation.swift */, ); path = APIClient; sourceTree = ""; @@ -2246,6 +2679,7 @@ CD1262782B47597E007549F9 /* Feeds */ = { isa = PBXGroup; children = ( + CD17C1DE2BA3642800A0C8BC /* Modlog */, CD1262792B4759BC007549F9 /* StandardPostTracker.swift */, CD16A05F2B66F724000312D2 /* UserContentTracker.swift */, ); @@ -2264,6 +2698,16 @@ path = Data; sourceTree = ""; }; + CD17C1DE2BA3642800A0C8BC /* Modlog */ = { + isa = PBXGroup; + children = ( + CD17C1DA2BA358FD00A0C8BC /* ModlogTracker.swift */, + CD17C1E52BA369C700A0C8BC /* ModlogChildTracker.swift */, + CD9395262BA7CF92008F6C4C /* ModlogAction.swift */, + ); + path = Modlog; + sourceTree = ""; + }; CD18243E2AA8E23100D9BEB5 /* View Modifiers */ = { isa = PBXGroup; children = ( @@ -2288,6 +2732,53 @@ path = Avatars; sourceTree = ""; }; + CD268C122B9A3DCF0074DBEE /* CommunityList */ = { + isa = PBXGroup; + children = ( + CD268C132B9A3DD80074DBEE /* CommunityListRowBody.swift */, + 03EEEAF22AB8DCDF0087F8D8 /* CommunityListRow.swift */, + ); + path = CommunityList; + sourceTree = ""; + }; + CD2698132B9E17870002B459 /* Modlog */ = { + isa = PBXGroup; + children = ( + CD26980B2B9E16810002B459 /* APIModRemoveComment.swift */, + CD2698092B9E166D0002B459 /* APIModLockPost.swift */, + CD2698072B9E162A0002B459 /* APIModRemovePost.swift */, + CD2697E72B9E15580002B459 /* APIModRemovePostView.swift */, + CD26980D2B9E16A70002B459 /* APIModFeaturePost.swift */, + CD2697E92B9E15610002B459 /* APIModLockPostView.swift */, + CD2697ED2B9E15740002B459 /* APIModRemoveCommentView.swift */, + CD2697EB2B9E156D0002B459 /* APIModFeaturePostView.swift */, + CD2697EF2B9E157E0002B459 /* APIModBanFromCommunityView.swift */, + CD26980F2B9E174C0002B459 /* APIModBanFromCommunity.swift */, + CD2697F12B9E15860002B459 /* APIModRemoveCommunityView.swift */, + CD2698142B9E17AD0002B459 /* APIModBan.swift */, + CD2698112B9E17660002B459 /* APIModRemoveCommunity.swift */, + CD2697F32B9E158E0002B459 /* APIModBanView.swift */, + CD2697F52B9E15A60002B459 /* APIModAddCommunityView.swift */, + CD2698162B9E17C60002B459 /* APIModAddCommunity.swift */, + CD2697F72B9E15AD0002B459 /* APIModTransferCommunityView.swift */, + CD2698182B9E17DE0002B459 /* APIModTransferCommunity.swift */, + CD2697F92B9E15B90002B459 /* APIModAddView.swift */, + CD26981A2B9E17F70002B459 /* APIModAdd.swift */, + CD2697FB2B9E15C10002B459 /* APIAdminPurgePersonView.swift */, + CD26981C2B9E18090002B459 /* APIAdminPurgePerson.swift */, + CD2697FD2B9E15C80002B459 /* APIAdminPurgeCommunityView.swift */, + CD26981E2B9E181D0002B459 /* APIAdminPurgeCommunity.swift */, + CD2697FF2B9E15CF0002B459 /* APIAdminPurgePostView.swift */, + CD2698202B9E18350002B459 /* APIAdminPurgePost.swift */, + CD2698012B9E15D60002B459 /* APIAdminPurgeCommentView.swift */, + CD2698222B9E18450002B459 /* APIAdminPurgeComment.swift */, + CD2698032B9E15E50002B459 /* APIModHideCommunityView.swift */, + CD2698242B9E18760002B459 /* ApiModHideCommunity.swift */, + CD2698052B9E15F10002B459 /* APIModlogActionType.swift */, + ); + path = Modlog; + sourceTree = ""; + }; CD29ED2B2B2E8119006937CE /* View */ = { isa = PBXGroup; children = ( @@ -2302,6 +2793,7 @@ isa = PBXGroup; children = ( 63344C552A07D81D001BC616 /* Array+Prepend.swift */, + 03CEE0502B6EED2C00D65B1B /* Array+IsNotEmpty.swift */, 63344C572A07DB9A001BC616 /* Array+MoveElements.swift */, E453477D2A9DE37300D1B46F /* Array+SafeIndexing.swift */, ); @@ -2321,6 +2813,7 @@ isa = PBXGroup; children = ( 6D15D74B2A44DC240061B5CB /* Date+Formatter.swift */, + CD50504C2B80065300632C56 /* Date+DaysFromNow.swift */, ); path = Date; sourceTree = ""; @@ -2432,6 +2925,23 @@ path = TimeInterval; sourceTree = ""; }; + CD2BFE702B9E6CC700717611 /* Modlog */ = { + isa = PBXGroup; + children = ( + CD2BFE732B9F5BE800717611 /* ModlogEntry.swift */, + ); + path = Modlog; + sourceTree = ""; + }; + CD2BFE7C2B9F64E000717611 /* Modlog */ = { + isa = PBXGroup; + children = ( + CD2697E22B9E13B70002B459 /* ModlogView.swift */, + CD2BFE7D2B9F670B00717611 /* ModlogEntryView.swift */, + ); + path = Modlog; + sourceTree = ""; + }; CD2E14792A6B285F004198DE /* Posts */ = { isa = PBXGroup; children = ( @@ -2450,6 +2960,7 @@ children = ( CD69F5722A4239D70028D4F7 /* Comment Item.swift */, CD69F5742A42479A0028D4F7 /* Comment Item Logic.swift */, + 038142F12BB46FFF00856C9B /* CommentItem+MenuFunctions.swift */, 6399719427F463150057F611 /* Components */, ); path = Comments; @@ -2458,6 +2969,7 @@ CD2E147B2A6B2891004198DE /* Components */ = { isa = PBXGroup; children = ( + CD2BFE7C2B9F64E000717611 /* Modlog */, CD69F5702A422EDD0028D4F7 /* InteractionBarView.swift */, 632E8EE427EE63BD007E8D75 /* Components */, CDF1EF152A6C3BC2003594B6 /* End Of Feed View.swift */, @@ -2465,7 +2977,9 @@ CD45BCED2A75CA7200A2899C /* Thumbnail Image View.swift */, CDC1C9422A7AC24600072E3D /* ReadCheck.swift */, CD309C452A93FBD300988F95 /* Logo View.swift */, + 03AFBEB22B6EB8B800F01F3C /* Line.swift */, CDD55D1C2B23BA41002020C7 /* EasyTapLinkView.swift */, + CDD0B8BE2BB5B68F003E7174 /* EmbeddedCommentView.swift */, ); path = Components; sourceTree = ""; @@ -2509,6 +3023,10 @@ CD4368C32AE240B100BD8BD1 /* MentionModel.swift */, CD4368C52AE240BF00BD8BD1 /* MessageModel.swift */, CD4368C72AE2426700BD8BD1 /* ReplyModel.swift */, + CDD0B8AC2BB4CD7D003E7174 /* CommentReportModel.swift */, + CDD0B8D92BBF3C87003E7174 /* PostReportModel.swift */, + CDD0B8E92BBF7CAB003E7174 /* MessageReportModel.swift */, + CDD0B8FD2BC07F4B003E7174 /* RegistrationApplicationModel.swift */, ); path = Inbox; sourceTree = ""; @@ -2523,6 +3041,11 @@ CD12627C2B475E45007549F9 /* PostModel+TrackerItem.swift */, CD16A0632B66F81A000312D2 /* UserContentModel+TrackerItem.swift */, CD16A0652B670039000312D2 /* HierarchicalComment+TrackerItem.swift */, + CD17C1DC2BA3596000A0C8BC /* ModlogEntry+TrackerItem.swift */, + CDD0B8B32BB4DAFE003E7174 /* CommentReportModel+TrackerItem.swift */, + CDD0B8DF2BBF4689003E7174 /* PostReportModel+TrackerItem.swift */, + CDD0B8ED2BBF7F21003E7174 /* MessageReportModel+TrackerItem.swift */, + CDD0B9032BC08987003E7174 /* RegistrationApplication+TrackerItem.swift */, ); path = "Tracker Items"; sourceTree = ""; @@ -2548,27 +3071,63 @@ CDCA28D32B58AF53009D9F54 /* PostFeedView+MenuFunctions.swift */, CDEC95182B5D950D004BA288 /* PostFeedView+Logic.swift */, CD16A0672B670327000312D2 /* UserContentFeedView.swift */, - CD16A0692B670ABE000312D2 /* UserContentFeedView+Logic.swift */, CD16A06B2B674ABF000312D2 /* FeedHeaderView.swift */, ); path = Components; sourceTree = ""; }; + CD50504E2B80098F00632C56 /* Tools */ = { + isa = PBXGroup; + children = ( + CDA1E84C2B93FC7C007953EF /* BanUserView.swift */, + 03F0DF532B9D129D0018F239 /* BanUserView+Logic.swift */, + 039C59AE2BAE029300C18765 /* VotesListView.swift */, + 039C59B02BAF272E00C18765 /* VotesTracker.swift */, + CD55FE252B97F37A0020EE24 /* AddModView.swift */, + CD55FE272B9A28D00020EE24 /* SimpleUserSearchView.swift */, + CD268C102B9A3CB30074DBEE /* SimpleCommunitySearchView.swift */, + CD0D5A442B8EC5D9005E3365 /* RemovePostView.swift */, + 03B85A3B2BB34D1F003C4203 /* PurgeContentView.swift */, + 039C59A62BADA04100C18765 /* RemoveCommentView.swift */, + 030245C32BA6123A00D07747 /* RemoveCommunityView.swift */, + CDD0B9012BC084A9003E7174 /* DenyApplicationView.swift */, + ); + path = Tools; + sourceTree = ""; + }; CD525F662A4B892900BCA794 /* Links */ = { isa = PBXGroup; children = ( 032109482AA7C41800912DFC /* AvatarView.swift */, + 03F0DF592B9E28F30018F239 /* InstanceLabelView.swift */, 032109442AA7C32100912DFC /* User */, 032109452AA7C32F00912DFC /* Community */, ); path = Links; sourceTree = ""; }; + CD5BB8152BADF7660027398F /* BubblePicker */ = { + isa = PBXGroup; + children = ( + 03EEEAF62AB8ED3C0087F8D8 /* BubblePicker.swift */, + CD5BB8162BADF7700027398F /* ChildSizeReader.swift */, + ); + path = BubblePicker; + sourceTree = ""; + }; + CD5F76BA2B75B8290013A827 /* Batchers */ = { + isa = PBXGroup; + children = ( + CD5F76BB2B75BE700013A827 /* MarkReadBatcher.swift */, + ); + path = Batchers; + sourceTree = ""; + }; CD62D12D2B5ED48000395BD9 /* Community List */ = { isa = PBXGroup; children = ( 6D8F08FE2A4029AE003EB4FD /* CommunityListSection.swift */, - 505240E42A86E32700EA4558 /* CommunityListModel.swift */, + CDBA5FC92BD17F3D00469C05 /* CommunityListModel.swift */, ); path = "Community List"; sourceTree = ""; @@ -2635,6 +3194,7 @@ CDA2C5242A705D3100649D5A /* Composers */ = { isa = PBXGroup; children = ( + 03C942902B6457A3002068A4 /* Administration */, CD391F922A533B6C00E213B5 /* Response Composers */, CDA2C5252A705D6000649D5A /* PostEditor.swift */, ); @@ -2679,6 +3239,10 @@ CD4368D82AE2478300BD8BD1 /* MentionModel+InboxItem.swift */, CD4368D42AE2463900BD8BD1 /* MessageModel+InboxItem.swift */, CD4368D62AE2464D00BD8BD1 /* ReplyModel+InboxItem.swift */, + CDD0B8B12BB4D990003E7174 /* CommentReportModel+InboxItem.swift */, + CDD0B8DD2BBF4601003E7174 /* PostReportModel+InboxItem.swift */, + CDD0B8EF2BBF7FB7003E7174 /* MessageReportModel+InboxItem.swift */, + CDD0B9052BC089EA003E7174 /* RegistrationApplication+InboxItem.swift */, ); path = "Inbox Items"; sourceTree = ""; @@ -2704,6 +3268,7 @@ children = ( CDB45C5D2AF1A96C00A1FF08 /* AssociatedColorProtocol.swift */, CDC6A8C92A6F1C8D00CC11AC /* AssociatedIconProtocol.swift */, + CDD0B8CC2BB9E53F003E7174 /* Removable.swift */, ); path = Protocols; sourceTree = ""; @@ -2717,6 +3282,80 @@ path = Components; sourceTree = ""; }; + CDD0B8AE2BB4CE44003E7174 /* Reports */ = { + isa = PBXGroup; + children = ( + 6D693A4B2A51B99E009E2D76 /* APICommentReport.swift */, + 6D693A492A51B98F009E2D76 /* APICommentReportView.swift */, + 6D693A412A5114DF009E2D76 /* APIPostReport.swift */, + 6D693A3F2A51147E009E2D76 /* APIPostReportView.swift */, + CD7B53B82A5F263D00006E81 /* APIPrivateMessageReport.swift */, + CD7B53B62A5F258B00006E81 /* APIPrivateMessageReportView.swift */, + ); + path = Reports; + sourceTree = ""; + }; + CDD0B8B72BB4E3F2003E7174 /* Moderation */ = { + isa = PBXGroup; + children = ( + CDD0B8B82BB4E407003E7174 /* ListCommentReportsRequest.swift */, + CDD0B8CE2BBA0D31003E7174 /* ResolveCommentReportRequest.swift */, + CDD0B8DB2BBF4238003E7174 /* ListPostReportsRequest.swift */, + CDD0B8E52BBF57D9003E7174 /* ResolvePostReportRequest.swift */, + CDD0B8EB2BBF7D0F003E7174 /* ListPrivateMessageReportsRequest.swift */, + CDD0B8F52BC064F0003E7174 /* ResolvePrivateMessageReportRequest.swift */, + CDD0B8F72BC07E6A003E7174 /* ListRegistrationApplicationsRequest.swift */, + CDD0B8FF2BC080C7003E7174 /* ApproveRegistrationApplicationRequest.swift */, + CDC24EA52BC9B352009AA6D1 /* GetReportCountRequest.swift */, + CDBA5FC52BC9C58300469C05 /* GetUnreadRegistrationApplicationCountRequest.swift */, + ); + path = Moderation; + sourceTree = ""; + }; + CDD0B8D62BBB8E4E003E7174 /* ChildTrackers */ = { + isa = PBXGroup; + children = ( + CDD0B8B52BB4DC12003E7174 /* CommentReportTracker.swift */, + CD4368DA2AE247B700BD8BD1 /* MentionTracker.swift */, + CD4368CF2AE245F400BD8BD1 /* MessageTracker.swift */, + CD4368D12AE2460100BD8BD1 /* ReplyTracker.swift */, + CDD0B8D72BBF3C5A003E7174 /* PostReportTracker.swift */, + CDD0B8E72BBF7C8B003E7174 /* MessageReportTracker.swift */, + CDD0B9072BC08A6D003E7174 /* RegistrationApplicationTracker.swift */, + ); + path = ChildTrackers; + sourceTree = ""; + }; + CDD0EF922B7A957B00CA3504 /* Moderation */ = { + isa = PBXGroup; + children = ( + CD50504E2B80098F00632C56 /* Tools */, + CDD0EFAE2B7EF34D00CA3504 /* Components */, + CDD0EF9D2B7D6F3E00CA3504 /* ModToolSheet.swift */, + ); + path = Moderation; + sourceTree = ""; + }; + CDD0EFA32B7E8AF900CA3504 /* UserList */ = { + isa = PBXGroup; + children = ( + CDD0EFA12B7D9E5800CA3504 /* UserListRowBody.swift */, + 03B7AAF42ABEFA7A00068B23 /* UserListRow.swift */, + CDD0EFA42B7E8B3200CA3504 /* ModeratorListView.swift */, + ); + path = UserList; + sourceTree = ""; + }; + CDD0EFAE2B7EF34D00CA3504 /* Components */ = { + isa = PBXGroup; + children = ( + CD0D5A492B8ED320005E3365 /* BanFormButtonStyle.swift */, + CD0D5A472B8EC6F3005E3365 /* ReasonView.swift */, + CD17C1D82BA2660300A0C8BC /* ModlogNavigationLinkView.swift */, + ); + path = Components; + sourceTree = ""; + }; CDD55D202B2674B3002020C7 /* String */ = { isa = PBXGroup; children = ( @@ -2726,6 +3365,7 @@ CDD55D212B2674BD002020C7 /* String+ParseLinks.swift */, CD29ED382B2E860C006937CE /* String+Trimmed.swift */, CD29ED3C2B2E863C006937CE /* String+WithEscapedCharacters.swift */, + CD436F282BD325CB001711B9 /* String+StrippingDiacritics.swift */, ); path = String; sourceTree = ""; @@ -2742,27 +3382,23 @@ path = "Fancy Tab Bar"; sourceTree = ""; }; - CDE6A80E2A4908200062D161 /* Feed */ = { - isa = PBXGroup; - children = ( - CDE6A8142A490AC60062D161 /* Item Types */, - CD3FBCE02A4A836000B2063F /* AllItemsFeedView.swift */, - CD3FBCE22A4A844800B2063F /* Replies Feed View.swift */, - CD3FBCE42A4A89B900B2063F /* Mentions Feed View.swift */, - CD3FBCE62A4A8CE300B2063F /* Messages Feed View.swift */, - ); - path = Feed; - sourceTree = ""; - }; CDE6A8142A490AC60062D161 /* Item Types */ = { isa = PBXGroup; children = ( - E449C5962B35239500E3BCF4 /* InboxReplyView.swift */, - E4A7BFD02B35912500B95F56 /* InboxMessageView.swift */, - E4A7BFD22B35913F00B95F56 /* InboxMentionView.swift */, - CDE6A8152A490AE00062D161 /* InboxMessageBodyView.swift */, + CDD0B8C22BB5BCFD003E7174 /* InboxCommentReportBodyView.swift */, + CDD0B8BC2BB4F3D3003E7174 /* InboxCommentReportView.swift */, CDE6A8172A490AF20062D161 /* InboxMentionBodyView.swift */, - CDE6A8192A490B970062D161 /* Inbox ReplyBodyView.swift */, + E4A7BFD22B35913F00B95F56 /* InboxMentionView.swift */, + CDD0B8C02BB5BC51003E7174 /* InboxMessageBodyView.swift */, + E4A7BFD02B35912500B95F56 /* InboxMessageView.swift */, + CDD0B8F32BBF843B003E7174 /* InboxMessageReportBodyView.swift */, + CDD0B8F12BBF8406003E7174 /* InboxMessageReportView.swift */, + CDD0B8E32BBF4D4B003E7174 /* InboxPostReportBodyView.swift */, + CDD0B8E12BBF49DB003E7174 /* InboxPostReportView.swift */, + CDD0B90B2BC08E21003E7174 /* InboxRegistrationApplicationBodyView.swift */, + CDD0B9092BC08E02003E7174 /* InboxRegistrationApplicationView.swift */, + CDE6A8192A490B970062D161 /* InboxReplyBodyView.swift */, + E449C5962B35239500E3BCF4 /* InboxReplyView.swift */, ); path = "Item Types"; sourceTree = ""; @@ -2811,6 +3447,7 @@ CDEBC3262A9A57E900518D9D /* Content */ = { isa = PBXGroup; children = ( + CD2BFE702B9E6CC700717611 /* Modlog */, CD4368C22AE2409D00BD8BD1 /* Inbox */, CDEBC3272A9A57F200518D9D /* Content Model Identifier.swift */, 03ED5D5D2B6560F4005C245B /* Post */, @@ -2828,6 +3465,7 @@ children = ( CDEC95112B5B318B004BA288 /* CommunityFeedView.swift */, CD4BAD422B507F2B00A1E726 /* AggregateFeedView.swift */, + 030245BF2BA607EA00D07747 /* FeedToolbarContent.swift */, CDEC95132B5CBC42004BA288 /* AggregateFeedView+Logic.swift */, ); path = "Feed Types"; @@ -2838,8 +3476,6 @@ children = ( 6D7782332A48EE8C008AC1BF /* APIPrivateMessageView.swift */, 6D7782352A48EED8008AC1BF /* APIPrivateMessage.swift */, - CD7B53B62A5F258B00006E81 /* APIPrivateMessageReportView.swift */, - CD7B53B82A5F263D00006E81 /* APIPrivateMessageReport.swift */, ); path = Messages; sourceTree = ""; @@ -2847,11 +3483,9 @@ CDF8425F2A49EA2A00723DA0 /* Inbox */ = { isa = PBXGroup; children = ( + CDD0B8D62BBB8E4E003E7174 /* ChildTrackers */, CDF8426A2A4A2AB600723DA0 /* InboxItem.swift */, CD82A2582A71775E00111034 /* UnreadTracker.swift */, - CD4368CF2AE245F400BD8BD1 /* MessageTracker.swift */, - CD4368D12AE2460100BD8BD1 /* ReplyTracker.swift */, - CD4368DA2AE247B700BD8BD1 /* MentionTracker.swift */, CDC3E7FF2AEAFEAF008062CA /* InboxTracker.swift */, ); path = Inbox; @@ -2914,7 +3548,7 @@ E4D4DB9E2A7C7A5800C4F3DE /* Animations */ = { isa = PBXGroup; children = ( - E4D4DB9F2A7C7B9D00C4F3DE /* Comments.swift */, + E4D4DB9F2A7C7B9D00C4F3DE /* Animations.swift */, ); path = Animations; sourceTree = ""; @@ -2953,6 +3587,7 @@ B104A6DD2A59BF3C00B3E725 /* NukeVideo */, 50C99B552A61D792005D57DD /* Dependencies */, CD4368C02AE23FD400BD8BD1 /* Semaphore */, + 03F6D4B82B951E21008235A0 /* SwiftUIIntrospect */, ); productName = Mlem; productReference = 6363D5C127EE196700E34822 /* Mlem.app */; @@ -3032,6 +3667,7 @@ B104A6D62A59BF3C00B3E725 /* XCRemoteSwiftPackageReference "Nuke" */, 50C99B542A61D792005D57DD /* XCRemoteSwiftPackageReference "swift-dependencies" */, CD4368BF2AE23FD400BD8BD1 /* XCRemoteSwiftPackageReference "Semaphore" */, + 03F6D4B62B951D64008235A0 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, ); productRefGroup = 6363D5C227EE196700E34822 /* Products */; projectDirPath = ""; @@ -3111,56 +3747,75 @@ buildActionMask = 2147483647; files = ( CDA77C322B6BD74400B8B8B4 /* CommunityListSection.swift in Sources */, + CDBA5FC82BCC477F00469C05 /* WarningView.swift in Sources */, + 039C59B12BAF272E00C18765 /* VotesTracker.swift in Sources */, CD16A0622B66F7FC000312D2 /* UserContentModel.swift in Sources */, CDDB2EDE2A85C2F1001D4B16 /* HapticPriority.swift in Sources */, CD6F29AA2A78003A00F20B6B /* PostRepository.swift in Sources */, 63F0C7A82A0522FC00A18C5D /* Saved Account Tracker.swift in Sources */, E449C5972B35239500E3BCF4 /* InboxReplyView.swift in Sources */, 6372186A2A3A2AAD008C4816 /* GetComment.swift in Sources */, + CDD0B8BB2BB4E51C003E7174 /* APIClient+Moderation.swift in Sources */, 03C905CA2B3C834C00B9082F /* AvatarBannerView.swift in Sources */, 03F76FA42B2F5F3500E2B54A /* UploadProgressView.swift in Sources */, + 0355A1DE2BB1F12500D54F9F /* ModerationSettingsView.swift in Sources */, 03EC92972AC069CE007BBE7E /* SearchResultListView.swift in Sources */, 637218472A3A2AAD008C4816 /* APICommentView.swift in Sources */, + CDD0B8BF2BB5B68F003E7174 /* EmbeddedCommentView.swift in Sources */, + 03CEE04F2B6ECFFC00D65B1B /* FediseerOpinionView.swift in Sources */, + CD2BFE742B9F5BE800717611 /* ModlogEntry.swift in Sources */, CDD55D1D2B23BA41002020C7 /* EasyTapLinkView.swift in Sources */, CDDCF6492A6641F0003DA3AC /* FancyTabItemLabelBuilder.swift in Sources */, 63344C542A07D193001BC616 /* FiltersSettingsView.swift in Sources */, E40E01902AABFC9300410B2C /* AnyNavigablePath.swift in Sources */, 6372185C2A3A2AAD008C4816 /* APICommunity.swift in Sources */, + CDA1E84F2B93FF83007953EF /* RemovedTag.swift in Sources */, + CD268C112B9A3CB30074DBEE /* SimpleCommunitySearchView.swift in Sources */, + 030245C62BA6138100D07747 /* RemoveCommunityRequest.swift in Sources */, + CD17C1E62BA369C700A0C8BC /* ModlogChildTracker.swift in Sources */, 6372185D2A3A2AAD008C4816 /* APICommunityAggregates.swift in Sources */, - 03B7AAF52ABEFA7A00068B23 /* UserResultView.swift in Sources */, + 03B7AAF52ABEFA7A00068B23 /* UserListRow.swift in Sources */, E47478152AAC3C19001CB1AC /* NavigationContext.swift in Sources */, + CDD0B8BD2BB4F3D3003E7174 /* InboxCommentReportView.swift in Sources */, 03C898012AC04EF9005F3403 /* SearchResultsView.swift in Sources */, CD04D5DB2A36154F008EF95B /* SaveButtonView.swift in Sources */, CDE6A80D2A45EAB30062D161 /* Embedded Post.swift in Sources */, B1955A212A6145C00056CF99 /* EnvironmentValues+EasterFlagSetter.swift in Sources */, 6386E02F2A03ED39006B3C1D /* Comment Tracker.swift in Sources */, + 03F6D4BD2B966D53008235A0 /* BodyEditorView.swift in Sources */, 6372185F2A3A2AAD008C4816 /* LoginRequest.swift in Sources */, + 03F0DF562B9E0E210018F239 /* PurgePostRequest.swift in Sources */, 637218442A3A2AAD008C4816 /* HierarchicalComment.swift in Sources */, CDDCF6472A663849003DA3AC /* EnvironmentValues+TabSelectionHashValue.swift in Sources */, 63D24EDE2A169F2A005CCA81 /* Markdown View.swift in Sources */, + 03C942962B648252002068A4 /* BanPerson.swift in Sources */, E453A1D02A81C2140004BB8A /* QuickLookPreviewController.swift in Sources */, B104A6E02A59C19400B3E725 /* OperationQueue+ConvenienceInit.swift in Sources */, + CD26981B2B9E17F70002B459 /* APIModAdd.swift in Sources */, CD2053122ACB72190000AA38 /* AccountTransitionView.swift in Sources */, 03B7AAF32ABEF85300068B23 /* UserModel.swift in Sources */, CDC1C9432A7AC24600072E3D /* ReadCheck.swift in Sources */, 63F0C7A22A0519BA00A18C5D /* PostSortType.swift in Sources */, 034C724F2A82B61200B8A4B8 /* LayoutWidgetTracker.swift in Sources */, 030D00852AD1B94F00953B1D /* UserFlair.swift in Sources */, + 03AFBEAF2B6EAB9C00F01F3C /* APISiteAggregates+Mock.swift in Sources */, 637218682A3A2AAD008C4816 /* CreateComment.swift in Sources */, 637218722A3A2AAD008C4816 /* HideCommunity.swift in Sources */, - 03EEEAF32AB8DCDF0087F8D8 /* CommunityResultView.swift in Sources */, + 03EEEAF32AB8DCDF0087F8D8 /* CommunityListRow.swift in Sources */, E47478132AAC350E001CB1AC /* NavigationLink+Helpers.swift in Sources */, 5064D0432A6E645D00B22EE3 /* Notifiable.swift in Sources */, 039439932A99098900463032 /* InternetConnectionManager.swift in Sources */, CD82A2592A71775E00111034 /* UnreadTracker.swift in Sources */, 0355DA4F2B5EB63600CDF5A5 /* InstanceStub.swift in Sources */, CD4368CA2AE2428C00BD8BD1 /* ContentIdentifiable.swift in Sources */, - CD3FBCE32A4A844800B2063F /* Replies Feed View.swift in Sources */, + CDD0B90C2BC08E21003E7174 /* InboxRegistrationApplicationBodyView.swift in Sources */, 637218652A3A2AAD008C4816 /* GetPosts.swift in Sources */, + CD2697F42B9E158E0002B459 /* APIModBanView.swift in Sources */, E4DDB4342A819C8000B3A7E0 /* QuickLookView.swift in Sources */, 6372185E2A3A2AAD008C4816 /* APICommunityModeratorView.swift in Sources */, CD18DC732A522A7C002C56BC /* CreatePrivateMessageRequest.swift in Sources */, CD525F652A4B6D8F00BCA794 /* CommunityLinkView.swift in Sources */, + 03A4330F2B7186C20004E743 /* WebView.swift in Sources */, 637218592A3A2AAD008C4816 /* APISiteAggregates.swift in Sources */, 50A881262A71A511003E3661 /* PersistenceRepository+Dependency.swift in Sources */, 032C1E062B5DBDB100FB4F23 /* LocalAccountSettingsView.swift in Sources */, @@ -3169,15 +3824,18 @@ CD863FBA2A6AEB5900A31ED9 /* View+FancyTabScrollCompatible.swift in Sources */, 637218772A3A2AAD008C4816 /* APIRequest.swift in Sources */, 63A09B69285F53E9004F0032 /* Error View.swift in Sources */, + CDA1E84D2B93FC7C007953EF /* BanUserView.swift in Sources */, CD2053172ACBBB5A0000AA38 /* DefaultAvatarView.swift in Sources */, - CD3FBCE72A4A8CE300B2063F /* Messages Feed View.swift in Sources */, + 03A4330D2B6FC0940004E743 /* FediseerInfoView.swift in Sources */, 63344C582A07DB9A001BC616 /* Array+MoveElements.swift in Sources */, + CD55FE262B97F37A0020EE24 /* AddModView.swift in Sources */, 03A18CBD2B1005A400BA69D2 /* SaveUserSettings.swift in Sources */, 03E0B9C82A61F0F400FED265 /* AdvancedSettingsView.swift in Sources */, 63344C622A08460D001BC616 /* View+Border.swift in Sources */, 03A54C352B533BC50064CCDE /* InstanceModel.swift in Sources */, + CD26981D2B9E18090002B459 /* APIAdminPurgePerson.swift in Sources */, 637218702A3A2AAD008C4816 /* ResolveObject.swift in Sources */, - CDE6A8162A490AE00062D161 /* InboxMessageBodyView.swift in Sources */, + 03AFBEA52B6EA90400F01F3C /* APIPersonView+Mock.swift in Sources */, CD04D5DD2A361564008EF95B /* ReplyButtonView.swift in Sources */, CD4368D22AE2460100BD8BD1 /* ReplyTracker.swift in Sources */, CD9A49D32B045B81001E18A0 /* ZoomableImageView.swift in Sources */, @@ -3185,22 +3843,27 @@ ADDC9E3A2A5CEAA100383D58 /* BlockPerson.swift in Sources */, CD6F29A82A77FF1700F20B6B /* MarkPostRead.swift in Sources */, 031A617E2B1CE90F00ABF23B /* ChangePasswordView.swift in Sources */, - CD4BAD3D2B4C6C8E00A1E726 /* FeedType.swift in Sources */, + CD4BAD3D2B4C6C8E00A1E726 /* PostFeedType.swift in Sources */, 6372186B2A3A2AAD008C4816 /* GetComments.swift in Sources */, + CDD0B8FE2BC07F4B003E7174 /* RegistrationApplicationModel.swift in Sources */, B1DD00BD2A62DDEC002A7B39 /* RecognizedLemmyInstances.swift in Sources */, + CDD0B8AD2BB4CD7D003E7174 /* CommentReportModel.swift in Sources */, 6DA61F892A575DF1001EA633 /* URL+WithIconSize.swift in Sources */, 50811B3E2A9205BA006BA3F2 /* GetCommunityResponse+Mock.swift in Sources */, E48DE4A22AC3F23F004E6291 /* DestinationValue.swift in Sources */, + 03F6D4BB2B952738008235A0 /* SelectTextView.swift in Sources */, E4DDB4322A81819300B3A7E0 /* Double+MaxZIndex.swift in Sources */, 5016A2B32A67EC0700B257E8 /* NotificationDisplayer.swift in Sources */, E42D9B5A2AD6802B0087693C /* OnboardingRoutes.swift in Sources */, CD1446212A5B328E00610EF1 /* Privacy Policy.swift in Sources */, 507573942A5AD59E00AA7ABD /* EquatableError.swift in Sources */, - CDE6A81A2A490B970062D161 /* Inbox ReplyBodyView.swift in Sources */, + CDE6A81A2A490B970062D161 /* InboxReplyBodyView.swift in Sources */, + CD5BB8172BADF7700027398F /* ChildSizeReader.swift in Sources */, 50811B3C2A92059C006BA3F2 /* BlockCommunityResponse+Mock.swift in Sources */, CD12627A2B4759BC007549F9 /* StandardPostTracker.swift in Sources */, CD391F9E2A539F1800E213B5 /* ReplyToMention.swift in Sources */, CD1446272A5B36DA00610EF1 /* EULA.swift in Sources */, + CDD0B8E82BBF7C8B003E7174 /* MessageReportTracker.swift in Sources */, 500C168E2A66FAAB006F243B /* HapticManager+Dependency.swift in Sources */, 038A16E52A7A97380087987E /* LayoutWidgetView.swift in Sources */, E40E018E2AABFBDE00410B2C /* AnyNavigationPath.swift in Sources */, @@ -3208,13 +3871,19 @@ 039C8DBB2B35B2EB0096BAAF /* AccountListView.swift in Sources */, CD1446252A5B357900610EF1 /* Document.swift in Sources */, CDEBC32C2A9A582500518D9D /* Votes Model.swift in Sources */, + 03E47AED2B66BC0000A3E4DB /* InstanceUptimeView.swift in Sources */, CDEBC3282A9A57F200518D9D /* Content Model Identifier.swift in Sources */, + 03CEE0512B6EED2C00D65B1B /* Array+IsNotEmpty.swift in Sources */, 6FF17D012B685C16007E1814 /* AppLockView.swift in Sources */, B1955A1D2A606B950056CF99 /* Easter Rewards.swift in Sources */, CD16A0642B66F81A000312D2 /* UserContentModel+TrackerItem.swift in Sources */, + CD4DD9DA2BABABB000085D41 /* InboxRoot.swift in Sources */, CDB0117F2A6F70A000D043EB /* Editor Tracker.swift in Sources */, 03ED5D5F2B6560FE005C245B /* PostModel+MenuFunctions.swift in Sources */, 030E86482AC6FD1D000283A6 /* _assignIfNotEqual.swift in Sources */, + CD17C1DD2BA3596000A0C8BC /* ModlogEntry+TrackerItem.swift in Sources */, + CD26980E2B9E16A70002B459 /* APIModFeaturePost.swift in Sources */, + CD2BFE822B9FA05300717611 /* ModlogLink.swift in Sources */, 03A54C372B545A430064CCDE /* InstanceModel+MenuFunctions.swift in Sources */, 6354F30A2A2E20040074C08D /* View+Alert.swift in Sources */, 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */, @@ -3222,28 +3891,35 @@ 6372186C2A3A2AAD008C4816 /* SaveComment.swift in Sources */, CD4368B62AE23F4700BD8BD1 /* ParentTracker.swift in Sources */, 03C898032AC04F61005F3403 /* RecentSearchesView.swift in Sources */, + CD55FE282B9A28D00020EE24 /* SimpleUserSearchView.swift in Sources */, 50811B382A920545006BA3F2 /* APICommunityModeratorView+Mock.swift in Sources */, 50F2851C2A5C5C1500CF8865 /* TokenRefreshView.swift in Sources */, 03F4DCA32B1A8B0400556C67 /* AccountGeneralSettingsView.swift in Sources */, 507573962A5AD5CF00AA7ABD /* ContextualError.swift in Sources */, 50C99B592A61D889005D57DD /* APIClient+Dependency.swift in Sources */, 031A617C2B1BDFD100ABF23B /* AdvancedAccountSettingsView.swift in Sources */, + CD2697F22B9E15860002B459 /* APIModRemoveCommunityView.swift in Sources */, CD3720EE2B2E8FE6004D7103 /* IconSettingsView.swift in Sources */, CDDCF6572A678298003DA3AC /* FancyTabBarSelection.swift in Sources */, CDB45C5E2AF1A96C00A1FF08 /* AssociatedColorProtocol.swift in Sources */, CD3FBCE92A4B482700B2063F /* Generic Merge.swift in Sources */, E47B2B762A902DE200629AF7 /* SettingsValues.swift in Sources */, CDBCBA242B54A5F40070F60D /* NoPostsView.swift in Sources */, + CD2698042B9E15E50002B459 /* APIModHideCommunityView.swift in Sources */, CDA145ED2A510AC100DDAFC9 /* MarkCommentReplyAsReadRequest.swift in Sources */, + 03F0DF542B9D129D0018F239 /* BanUserView+Logic.swift in Sources */, CD391F982A537E8E00E213B5 /* ReplyToComment.swift in Sources */, 5064D03D2A6DE0AA00B22EE3 /* Notifier.swift in Sources */, CD9A49D52B0587F1001E18A0 /* ImageDetailView.swift in Sources */, CDC65D912A86B830007205E5 /* DeleteAccountView.swift in Sources */, 039C8DBD2B361C160096BAAF /* AccountButtonView.swift in Sources */, CDC3E8002AEAFEAF008062CA /* InboxTracker.swift in Sources */, + CD17C1DB2BA358FD00A0C8BC /* ModlogTracker.swift in Sources */, + CD2698152B9E17AE0002B459 /* APIModBan.swift in Sources */, CD391F9C2A53980900E213B5 /* ReplyToCommentReply.swift in Sources */, 030E863D2AC6C49E000283A6 /* PictrsRepository+Dependency.swift in Sources */, 50811B402A9205EE006BA3F2 /* CommunityResponse+Mock.swift in Sources */, + 03A4330B2B6FB2C10004E743 /* FediseerOpinionListView.swift in Sources */, 03E0B9CC2A62CD5800FED265 /* ThemeSettingsView.swift in Sources */, 637218532A3A2AAD008C4816 /* APIMyUserInfo.swift in Sources */, 0317D46F2B558CB500EEE72C /* BadgeView.swift in Sources */, @@ -3251,26 +3927,30 @@ B11D72832A49FAA7009DC22F /* Cached Image.swift in Sources */, 637218752A3A2AAD008C4816 /* GetCommunity.swift in Sources */, 03A2767B2AFE560000C0D66B /* CommunityModel+SwipeActions.swift in Sources */, - 6DFF50432A48DED3001E648D /* Inbox View.swift in Sources */, + CD2697FE2B9E15C80002B459 /* APIAdminPurgeCommunityView.swift in Sources */, 03EEEAF72AB8ED3C0087F8D8 /* BubblePicker.swift in Sources */, + 039C59AD2BADFF6200C18765 /* ListPostLikesRequest.swift in Sources */, CD2053102AC878B50000AA38 /* UpdatedTimestampView.swift in Sources */, CD1446232A5B336900610EF1 /* LicensesView.swift in Sources */, CD4368D02AE245F400BD8BD1 /* MessageTracker.swift in Sources */, + CDB652532B8EABFC007B7797 /* FeaturePostRequest.swift in Sources */, 0304F58A2B44AF5B00537BFA /* CollapsibleSection.swift in Sources */, CDDCF6432A66343D003DA3AC /* FancyTabBar.swift in Sources */, - 505240E52A86E32700EA4558 /* CommunityListModel.swift in Sources */, CD05E77F2A4F263B0081D102 /* Menu Function.swift in Sources */, 036ED3BC2ABF1058009664BC /* SearchModel.swift in Sources */, CDDB08782A5DF1330075BFEE /* CommentSettingsView.swift in Sources */, 6386E02C2A03D1EC006B3C1D /* App State.swift in Sources */, 504106CD2A744D7F000AAEF8 /* CommentRepository+Dependency.swift in Sources */, CD4BAD432B507F2B00A1E726 /* AggregateFeedView.swift in Sources */, + CDD0B8E22BBF49DB003E7174 /* InboxPostReportView.swift in Sources */, 6372186F2A3A2AAD008C4816 /* SearchRequest.swift in Sources */, 03EC92952AC064AE007BBE7E /* SearchHomeView.swift in Sources */, CD46C1F62B0D0A5700065953 /* EnvironmentValues+TabReselectionHashValue.swift in Sources */, CD16A0602B66F724000312D2 /* UserContentTracker.swift in Sources */, 50811B362A920519006BA3F2 /* APISite+Mock.swift in Sources */, + CDD0EFA52B7E8B3200CA3504 /* ModeratorListView.swift in Sources */, 503422562AAB784000EFE88D /* EnvironmentValues+AppFlow.swift in Sources */, + CDA1E8512B940390007953EF /* LockedTag.swift in Sources */, CDDCF6512A677E1B003DA3AC /* FancyTabItemPreferenceKeys.swift in Sources */, CDEBC32A2A9A580B00518D9D /* PostModel.swift in Sources */, CDC6A8CA2A6F1C8D00CC11AC /* AssociatedIconProtocol.swift in Sources */, @@ -3278,26 +3958,40 @@ 50785F762A9A684300117245 /* SavedAccountTracker+Dependency.swift in Sources */, 504ECBAE2AB45B2A006C0B96 /* LemmyURL.swift in Sources */, CDA217EA2A63093E00BDA173 /* ReportComment.swift in Sources */, + CDC24EA62BC9B352009AA6D1 /* GetReportCountRequest.swift in Sources */, + CDD0B8E02BBF4689003E7174 /* PostReportModel+TrackerItem.swift in Sources */, CDA217E82A63029B00BDA173 /* ReportMention.swift in Sources */, 504ECBAA2AB27C73006C0B96 /* LandingPage.swift in Sources */, + CD2698062B9E15F10002B459 /* APIModlogActionType.swift in Sources */, 508845CF2A3641160088E483 /* JSONDecoder+Default.swift in Sources */, + 03F0DF5A2B9E28F30018F239 /* InstanceLabelView.swift in Sources */, 637218672A3A2AAD008C4816 /* GetPersonDetails.swift in Sources */, B1A26FE12A44AAB200B91A32 /* EnvironmentValues+NavigationPathWithRoutes.swift in Sources */, + 0300F2C62B9C7D4B0022F7C4 /* CloseButtonView.swift in Sources */, + 039C59AF2BAE029300C18765 /* VotesListView.swift in Sources */, CDD55D222B2674BD002020C7 /* String+ParseLinks.swift in Sources */, 6332FDC027EFB05F0009A98A /* Settings Item.swift in Sources */, 031A93D62AC847DA0077030C /* UploadConfirmationView.swift in Sources */, + CDD0B8EE2BBF7F21003E7174 /* MessageReportModel+TrackerItem.swift in Sources */, CD8C55342A95515C0060B75B /* Onboarding Text.swift in Sources */, CD4368CC2AE242AD00BD8BD1 /* InboxRepository.swift in Sources */, 50C99B602A6299D8005D57DD /* ErrorHandler.swift in Sources */, 030D4AE62AA1273200A3393D /* ErrorDetails.swift in Sources */, CD14461B2A5A4B6D00610EF1 /* PostSettingsView.swift in Sources */, + CD2698082B9E162A0002B459 /* APIModRemovePost.swift in Sources */, CD7B53B52A5F251400006E81 /* CreatePrivateMessageReportRequest.swift in Sources */, 50CC4A7F2AA0D3AA0074C845 /* InstanceMetadataParser.swift in Sources */, CD4368B82AE23F5400BD8BD1 /* ParentTrackerProtocol.swift in Sources */, 637218492A3A2AAD008C4816 /* APICommentReplyView.swift in Sources */, + CD2697E82B9E15580002B459 /* APIModRemovePostView.swift in Sources */, CD4368C82AE2426700BD8BD1 /* ReplyModel.swift in Sources */, CDC65D8F2A86B6DD007205E5 /* DeleteUser.swift in Sources */, + CDD0EFAD2B7EA73000CA3504 /* BanFromCommunity.swift in Sources */, + 03AFBEB32B6EB8B800F01F3C /* Line.swift in Sources */, CD6483382A3A0F2200EE6CA3 /* NSFW Tag.swift in Sources */, + CD2697FA2B9E15B90002B459 /* APIModAddView.swift in Sources */, + CD0D5A4A2B8ED320005E3365 /* BanFormButtonStyle.swift in Sources */, + CD17C1EA2BA3997000A0C8BC /* TrackerProtocol.swift in Sources */, 503422582AAB798600EFE88D /* AppFlow.swift in Sources */, 039C8DBF2B363DAD0096BAAF /* AccountListView+Logic.swift in Sources */, 637218642A3A2AAD008C4816 /* GetPost.swift in Sources */, @@ -3306,10 +4000,12 @@ 038A16DF2A75172C0087987E /* LayoutWidgetEditView.swift in Sources */, CD69F5732A4239D70028D4F7 /* Comment Item.swift in Sources */, 6FF17D062B685CF6007E1814 /* UIDevice+DynamicIsland.swift in Sources */, + 03C194082BA25B5200B00349 /* ProgressOverlayView.swift in Sources */, CD29ED412B2E867C006937CE /* UIApplication+TopMostViewController.swift in Sources */, CDB45C622AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift in Sources */, 6318EDC327EE4D7F00BFCAE8 /* Feed Post.swift in Sources */, 03F76FA22B2F5F1100E2B54A /* LinkAttachmentProxy.swift in Sources */, + CD26981F2B9E181D0002B459 /* APIAdminPurgeCommunity.swift in Sources */, CD05E7792A4E381A0081D102 /* PostSize.swift in Sources */, 6D7782362A48EED8008AC1BF /* APIPrivateMessage.swift in Sources */, CDE3BA892A8C64BD00B972E2 /* Collapsible Text Item.swift in Sources */, @@ -3318,28 +4014,47 @@ 505240E72A88D36D00EA4558 /* SectionIndexTitles.swift in Sources */, 5064D0452A71549C00B22EE3 /* NotificationMessage.swift in Sources */, E4F0B56F2ABD00A000BC3E4A /* View+PresentationBackgroundInteraction.swift in Sources */, + CDBA5FC62BC9C58300469C05 /* GetUnreadRegistrationApplicationCountRequest.swift in Sources */, + CDD0B8FC2BC07ED7003E7174 /* APIRegistrationApplication.swift in Sources */, 6D693A4C2A51B99E009E2D76 /* APICommentReport.swift in Sources */, 030E863B2AC6C3B1000283A6 /* PictrsRespository.swift in Sources */, + CD2697E32B9E13B70002B459 /* ModlogView.swift in Sources */, 63344C672A08D4E3001BC616 /* AppearanceSettingsView.swift in Sources */, + CDD0B8DA2BBF3C87003E7174 /* PostReportModel.swift in Sources */, E49F0E762A90395400BC4EE3 /* NavigationPath+Helpers.swift in Sources */, + CDD0B8CB2BB7844F003E7174 /* BanButtonView.swift in Sources */, + CDD0B8B42BB4DAFE003E7174 /* CommentReportModel+TrackerItem.swift in Sources */, + CD2697F02B9E157E0002B459 /* APIModBanFromCommunityView.swift in Sources */, B1955A1F2A606F010056CF99 /* EasterFlagsTracker.swift in Sources */, + CDD0B8FA2BC07EA9003E7174 /* APIRegistrationApplicationView.swift in Sources */, + CD0D5A482B8EC6F3005E3365 /* ReasonView.swift in Sources */, + 038142F22BB46FFF00856C9B /* CommentItem+MenuFunctions.swift in Sources */, 63D24ED92A169A5F005CCA81 /* UIApplication+FirstKeyWindow.swift in Sources */, 039439912A98FA6100463032 /* UserFeedView.swift in Sources */, 03EA79C42AC0D92C00BCDC91 /* PostComposerView+Logic.swift in Sources */, + CDD0B8EA2BBF7CAB003E7174 /* MessageReportModel.swift in Sources */, 03A18CBF2B1252BD00BA69D2 /* ListingType.swift in Sources */, + 03E47AEF2B66BD3C00A3E4DB /* InstanceModel+Uptime.swift in Sources */, 637218482A3A2AAD008C4816 /* APICommentReply.swift in Sources */, CD3720EC2B2E8F96004D7103 /* AlternativeIconCell.swift in Sources */, 032109472AA7C3FC00912DFC /* CommunityLabelView.swift in Sources */, 637218502A3A2AAD008C4816 /* APIPersonAggregates.swift in Sources */, + 03E47AEB2B66BADC00A3E4DB /* UptimeData.swift in Sources */, CD4368D72AE2464D00BD8BD1 /* ReplyModel+InboxItem.swift in Sources */, 6D693A422A5114DF009E2D76 /* APIPostReport.swift in Sources */, 5064D03F2A6DE0DB00B22EE3 /* Notifier+Dependency.swift in Sources */, + CDD0B8C92BB782CF003E7174 /* PurgeButtonView.swift in Sources */, 6D8003792A45FD1300363206 /* Bundle+VersionNumbers.swift in Sources */, + CD2698002B9E15CF0002B459 /* APIAdminPurgePostView.swift in Sources */, CDB45C642AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift in Sources */, + CD2698172B9E17C60002B459 /* APIModAddCommunity.swift in Sources */, + CD2697EE2B9E15740002B459 /* APIModRemoveCommentView.swift in Sources */, + CD2698252B9E18770002B459 /* ApiModHideCommunity.swift in Sources */, 6DE118392A4A20D600810C7E /* Lazy Load Post Link.swift in Sources */, CDF8426B2A4A2AB600723DA0 /* InboxItem.swift in Sources */, 637218572A3A2AAD008C4816 /* APISiteView.swift in Sources */, B14E93C02A45CA3400D6DA93 /* Post Link.swift in Sources */, + 03AFBEA72B6EA94900F01F3C /* APIPersonAggregates+Mock.swift in Sources */, CD2BD6782A79F55800ECFF89 /* ImageSize.swift in Sources */, 50785F712A98C4F600117245 /* SiteInformationTracker.swift in Sources */, CD46C1F82B0D0A8A00065953 /* View+ReselectAction.swift in Sources */, @@ -3348,14 +4063,15 @@ CD391F9A2A537EF900E213B5 /* CommentBodyView.swift in Sources */, 63344C562A07D81D001BC616 /* Array+Prepend.swift in Sources */, CDBCBA202B537A4B0070F60D /* PostFeedView.swift in Sources */, + 039C59A72BADA04100C18765 /* RemoveCommentView.swift in Sources */, CDDCF64F2A672C0A003DA3AC /* FancyTabBarLabel.swift in Sources */, + CD2698232B9E18450002B459 /* APIAdminPurgeComment.swift in Sources */, CD04D5D92A3614BE008EF95B /* Large Post.swift in Sources */, CDF8425E2A49E61A00723DA0 /* APIPersonMention.swift in Sources */, 50A881242A71A4CD003E3661 /* PersistenceRepository.swift in Sources */, 032109492AA7C41800912DFC /* AvatarView.swift in Sources */, CD4368B02AE23F1400BD8BD1 /* ChildTracker.swift in Sources */, CD863FBC2A6B026400A31ED9 /* DocumentView.swift in Sources */, - CD8461662A96F9EB0026A627 /* Website Indicator View.swift in Sources */, 038A16E92A7A9C640087987E /* LayoutWidget.swift in Sources */, CDEC95192B5D950D004BA288 /* PostFeedView+Logic.swift in Sources */, CD9A03C82B389F7000C16276 /* EnvironmentValues+FeedType.swift in Sources */, @@ -3363,38 +4079,56 @@ 03C905CC2B3C88F700B9082F /* SearchTab.swift in Sources */, 6FF17D032B685C55007E1814 /* AppLock.swift in Sources */, E409E16E2AFEFB8C0026FDC2 /* ImageDetailSheetState.swift in Sources */, - CD3FBCE52A4A89B900B2063F /* Mentions Feed View.swift in Sources */, CD391F962A535F5400E213B5 /* ResponseEditorView.swift in Sources */, 03EEEAF92ABB985D0087F8D8 /* CommunityModel.swift in Sources */, + 039B4FE92BD2D81D00E42114 /* BlockListView.swift in Sources */, CD391F8B2A53371300E213B5 /* ExpandedPostLogic.swift in Sources */, CD16A0682B670327000312D2 /* UserContentFeedView.swift in Sources */, CD4BAD352B4B2C0B00A1E726 /* FeedsView.swift in Sources */, CDCBD7242A8D62FF00387A2C /* InstanceMetadata.swift in Sources */, + CDB652572B8EAE15007B7797 /* APIPostResponse.swift in Sources */, + CD268C142B9A3DD80074DBEE /* CommunityListRowBody.swift in Sources */, CD18DC6B2A5202D4002C56BC /* MarkPersonMentionAsReadRequest.swift in Sources */, - CD16A06A2B670ABE000312D2 /* UserContentFeedView+Logic.swift in Sources */, + 03D89E722BB1BB0100F49DB3 /* ListCommentLikesRequest.swift in Sources */, + 030245C22BA60A2600D07747 /* ToolbarEllipsisMenu.swift in Sources */, + CDD0B8DC2BBF4238003E7174 /* ListPostReportsRequest.swift in Sources */, CD4BAD3B2B4C6C3200A1E726 /* FeedRowView.swift in Sources */, + 03B85A402BB38868003C4203 /* PostEllipsisMenus.swift in Sources */, CD1824402AA8E24100D9BEB5 /* View+DestructiveConfirmation.swift in Sources */, CD82A2502A7162D400111034 /* GetPersonUnreadCount.swift in Sources */, 0317D4712B55AE0700EEE72C /* Color+Hex.swift in Sources */, 030D00882AD1BB2600953B1D /* UserModel+ContentModel.swift in Sources */, CD82A24C2A70A26900111034 /* View+CustomBadge.swift in Sources */, B1CB6E752A4C729D00DA9675 /* Bundle+IconFileName.swift in Sources */, + 03C942922B6457B4002068A4 /* BanUserEditorModel.swift in Sources */, + CD2698212B9E18350002B459 /* APIAdminPurgePost.swift in Sources */, 030D4AE82AA1278400A3393D /* ErrorDetails+Mock.swift in Sources */, + CD2697F82B9E15AD0002B459 /* APIModTransferCommunityView.swift in Sources */, + 039C59AB2BADC85400C18765 /* RemoveCommentRequest.swift in Sources */, CD4368C62AE240BF00BD8BD1 /* MessageModel.swift in Sources */, + CD2BFE782B9F60AC00717611 /* APIClient+Instance.swift in Sources */, 6363D60427EE20A200E34822 /* Expanded Post.swift in Sources */, 6DE1183C2A4A217400810C7E /* Profile View.swift in Sources */, + CD2BFE7E2B9F670B00717611 /* ModlogEntryView.swift in Sources */, + CDD0B8B22BB4D990003E7174 /* CommentReportModel+InboxItem.swift in Sources */, CD04D5DF2A361585008EF95B /* Empty Button Style.swift in Sources */, + CD7798A62BB0E5B50067DF82 /* InboxView.swift in Sources */, CD69F55B2A400D820028D4F7 /* App Theme.swift in Sources */, CDEBC3392A9ADE6C00518D9D /* APIClient+Post.swift in Sources */, E4A7BFD12B35912500B95F56 /* InboxMessageView.swift in Sources */, + 030245C02BA607EA00D07747 /* FeedToolbarContent.swift in Sources */, + CD2697F62B9E15A60002B459 /* APIModAddCommunityView.swift in Sources */, CDF9EF332AB2845C003F885B /* Icons.swift in Sources */, B14E93C22A45D3B300D6DA93 /* Community Link.swift in Sources */, 637218712A3A2AAD008C4816 /* GetSite.swift in Sources */, CD29ED3D2B2E863C006937CE /* String+WithEscapedCharacters.swift in Sources */, + CDD0B8F22BBF8406003E7174 /* InboxMessageReportView.swift in Sources */, CD0BE42F2A65A73600314B24 /* Haptic Manager.swift in Sources */, + CDD0B8C52BB78056003E7174 /* ResolveButtonView.swift in Sources */, 50811B422A92061E006BA3F2 /* SavedAccount+Mock.swift in Sources */, + 03AFBEAB2B6EAA0C00F01F3C /* APILocalSite+Mock.swift in Sources */, + CD2697E52B9E14FB0002B459 /* GetModlogRequest.swift in Sources */, CD29ED392B2E860C006937CE /* String+Trimmed.swift in Sources */, - 03C897F62ABF49BD005F3403 /* Abbreviate Numbers.swift in Sources */, 637218522A3A2AAD008C4816 /* APIFederatedInstances.swift in Sources */, CD59E8A52A72C943005757F4 /* MarkAllAsReadRequest.swift in Sources */, 03A1B3F22A83F33900AB0DE0 /* DownvoteCounterView.swift in Sources */, @@ -3406,17 +4140,23 @@ 031BF9532AB24BAF00F4517F /* SiteVersion.swift in Sources */, 637218452A3A2AAD008C4816 /* APICommentAggregates.swift in Sources */, 6D80037B2A46458800363206 /* Lazy Load Expanded Post.swift in Sources */, + CDB652592B8EC024007B7797 /* LockPostRequest.swift in Sources */, 6372184F2A3A2AAD008C4816 /* APIPerson.swift in Sources */, + CDD0B8D82BBF3C5A003E7174 /* PostReportTracker.swift in Sources */, CD4368BC2AE23F6F00BD8BD1 /* TrackerSort.swift in Sources */, + CDD0B8E62BBF57D9003E7174 /* ResolvePostReportRequest.swift in Sources */, E46AF98E2B29A4AA0087FDF3 /* DismissAction.swift in Sources */, + CDD0EFA22B7D9E5800CA3504 /* UserListRowBody.swift in Sources */, CD45BCEE2A75CA7200A2899C /* Thumbnail Image View.swift in Sources */, E4A7BFD32B35913F00B95F56 /* InboxMentionView.swift in Sources */, 03A1B3F92A8400DD00AB0DE0 /* APIContentViewProtocol.swift in Sources */, 6322A5D027F8629700135D4F /* UserLinkView.swift in Sources */, 030E864C2AC7037F000283A6 /* SearchBarExtensions.swift in Sources */, 6372184B2A3A2AAD008C4816 /* APIPostAggregates.swift in Sources */, + CD876EC72B7736370075DC15 /* MarkReadBatcher+Dependency.swift in Sources */, 50A8812C2A72D727003E3661 /* CommunityRepository+Dependency.swift in Sources */, 0394398F2A98EB2300463032 /* APIComment+Mock.swift in Sources */, + 030245CA2BA70F5200D07747 /* LinksSettingsView.swift in Sources */, E453477E2A9DE37300D1B46F /* Array+SafeIndexing.swift in Sources */, CD4368BE2AE23FA600BD8BD1 /* LoadingState.swift in Sources */, CD9DD8852A62302A0044EA8E /* ConcreteEditorModel.swift in Sources */, @@ -3427,11 +4167,15 @@ 03F76FA02B2F5EF900E2B54A /* LinkAttachmentModel.swift in Sources */, 6372185A2A3A2AAD008C4816 /* APISubscribedStatus.swift in Sources */, CDDCF6452A66375E003DA3AC /* View+FancyTabItem.swift in Sources */, + CD2698192B9E17DE0002B459 /* APIModTransferCommunity.swift in Sources */, 6DA61F872A5720EA001EA633 /* RecentSearchesTracker.swift in Sources */, 03B15BED2B55CBBB00E7C30A /* MarkdownTheme.swift in Sources */, + 03CEE04B2B6EB9CD00D65B1B /* Fediseer.swift in Sources */, CD4368DD2AE24E1A00BD8BD1 /* InboxView+Logic.swift in Sources */, 03A276792AFD903600C0D66B /* CommunityModel+MenuFunctions.swift in Sources */, + CD0D5A432B8EC4DA005E3365 /* RemovePostRequest.swift in Sources */, 637218762A3A2AAD008C4816 /* BlockCommunity.swift in Sources */, + CDD0B8B92BB4E407003E7174 /* ListCommentReportsRequest.swift in Sources */, 03CB329E2A6D8E910021EF27 /* PostComposerView.swift in Sources */, CD69F5752A42479A0028D4F7 /* Comment Item Logic.swift in Sources */, 6D7782342A48EE8C008AC1BF /* APIPrivateMessageView.swift in Sources */, @@ -3443,9 +4187,12 @@ CD309C462A93FBD300988F95 /* Logo View.swift in Sources */, CD9A49D72B059303001E18A0 /* ImageSaver.swift in Sources */, 03E90FB12B3703ED00E5A802 /* AccountSortMode.swift in Sources */, + CD436F292BD325CB001711B9 /* String+StrippingDiacritics.swift in Sources */, CDC1C93C2A7AA76000072E3D /* InternetSpeed.swift in Sources */, 50EC39B22A346DDC00E014C2 /* URLHandler.swift in Sources */, + 03AFBEAD2B6EAAF000F01F3C /* APILocalSiteRateLimit+Mock.swift in Sources */, 63F0C7BF2A058EDE00A18C5D /* Get Correct URL to Endpoint.swift in Sources */, + CDD0EF9E2B7D6F3E00CA3504 /* ModToolSheet.swift in Sources */, 031F95572B5C7FF20069C244 /* InstanceDetailsView.swift in Sources */, 632E8EE827EE63DB007E8D75 /* DownvoteButtonView.swift in Sources */, 50D61E5B2AA32B9400A926EC /* APISession.swift in Sources */, @@ -3454,12 +4201,15 @@ 6372186D2A3A2AAD008C4816 /* EditComment.swift in Sources */, 6D693A4A2A51B98F009E2D76 /* APICommentReportView.swift in Sources */, 637218622A3A2AAD008C4816 /* DeletePost.swift in Sources */, + CDD0B9062BC089EA003E7174 /* RegistrationApplication+InboxItem.swift in Sources */, 505240E32A86916500EA4558 /* FavoriteCommunitiesTracker+Dependency.swift in Sources */, + CD17C1D92BA2660300A0C8BC /* ModlogNavigationLinkView.swift in Sources */, 6332FDC327EFCB5F0009A98A /* Color+Colors.swift in Sources */, E46AF9942B29AB270087FDF3 /* EnvironmentValues+ScrollViewReaderProxy.swift in Sources */, 637218432A3A2AAD008C4816 /* APIClient.swift in Sources */, CD82A2572A716D7C00111034 /* PersonRepository+Dependency.swift in Sources */, 63DF71F12A02999C002AC14E /* App Constants.swift in Sources */, + CDD0B8E42BBF4D4B003E7174 /* InboxPostReportBodyView.swift in Sources */, CD82A2532A716B8100111034 /* PersonRepository.swift in Sources */, CD69F55F2A40121D0028D4F7 /* Ellipsis Menu.swift in Sources */, CD4368D52AE2463900BD8BD1 /* MessageModel+InboxItem.swift in Sources */, @@ -3471,26 +4221,35 @@ 6D693A482A51B904009E2D76 /* CreateCommentReport.swift in Sources */, CD2E182B2A3B708500224F8A /* Settings Options.swift in Sources */, 50A881282A71D66B003E3661 /* APIClient+Community.swift in Sources */, + CDD0B8D52BBB41CC003E7174 /* InboxFeedView.swift in Sources */, 039C8DB72B35A32D0096BAAF /* AccountSwitcherSettingsView.swift in Sources */, CD29ED472B2E8785006937CE /* EnvironmentValues+NavigationPath.swift in Sources */, CD2053142ACBAF150000AA38 /* AvatarType.swift in Sources */, CD69F55D2A400DF50028D4F7 /* UIUserInterfaceStyle+SettingsOptions.swift in Sources */, CDF1EF182A6C40C9003594B6 /* Menu Button.swift in Sources */, + CD5050542B807BF800632C56 /* AddModToCommunity.swift in Sources */, 6D91D4552A415994006B8F9A /* CommunityListSidebarEntry.swift in Sources */, 50A8812A2A72D6BD003E3661 /* CommunityRepository.swift in Sources */, + CD0D5A452B8EC5D9005E3365 /* RemovePostView.swift in Sources */, 6FF17D082B685D0D007E1814 /* BiometricUnlock.swift in Sources */, 038A16E72A7A9C430087987E /* LayoutWidgetCollection.swift in Sources */, 6322A5D227F88CFD00135D4F /* Time Parser.swift in Sources */, + CD2697EC2B9E156D0002B459 /* APIModFeaturePostView.swift in Sources */, AD1B0D352A5F63F60006F554 /* AboutView.swift in Sources */, + 030245C82BA617FE00D07747 /* PurgeCommunityRequest.swift in Sources */, 50811B442A920945006BA3F2 /* APIPost+Mock.swift in Sources */, 030E86462AC6FC1B000283A6 /* DefaultTextInputType.swift in Sources */, + 03B85A3C2BB34D1F003C4203 /* PurgeContentView.swift in Sources */, + 030FF6862BCB218000F6BFAC /* Int+Abbreviated.swift in Sources */, 50811B3A2A920569006BA3F2 /* APIPerson+Mock.swift in Sources */, 03C897F82ABF652D005F3403 /* SearchRoot.swift in Sources */, + 03CEE04D2B6EBEA800D65B1B /* InstanceView+Logic.swift in Sources */, 637218612A3A2AAD008C4816 /* CreatePostLike.swift in Sources */, CD69F5712A422EDD0028D4F7 /* InteractionBarView.swift in Sources */, 50C99B5C2A61F5EB005D57DD /* CommentRepository.swift in Sources */, 637218732A3A2AAD008C4816 /* FollowCommunity.swift in Sources */, 03B7AAF12ABE404300068B23 /* ContentModel.swift in Sources */, + CDD0B8F02BBF7FB7003E7174 /* MessageReportModel+InboxItem.swift in Sources */, 637218742A3A2AAD008C4816 /* ListCommunities.swift in Sources */, CD1446182A58FC3B00610EF1 /* InfoStackView.swift in Sources */, CDE9CE4F2A7B0B1B002B97DD /* Haptic.swift in Sources */, @@ -3500,13 +4259,16 @@ CDE3BA872A8C25B000B972E2 /* OnboardingView.swift in Sources */, CD9A03C62B34D20500C16276 /* EnvironmentValues+Navigation.swift in Sources */, 5064D0412A6E63E000B22EE3 /* Task+Notifiable.swift in Sources */, + CDD0B8F62BC064F0003E7174 /* ResolvePrivateMessageReportRequest.swift in Sources */, 63F0C7BD2A058CD200A18C5D /* Check if Endpoint Exists.swift in Sources */, - E4D4DBA02A7C7B9D00C4F3DE /* Comments.swift in Sources */, + E4D4DBA02A7C7B9D00C4F3DE /* Animations.swift in Sources */, 03EF1D0C2B434CB10056175C /* CommunityDetailsView.swift in Sources */, 6363D5C727EE196700E34822 /* ContentView.swift in Sources */, 03F4DC9D2B193F4C00556C67 /* MatrixLinkView.swift in Sources */, + 03AFBEA92B6EA9BC00F01F3C /* APISiteView+Mock.swift in Sources */, 03A54C322B5331F30064CCDE /* InstanceView.swift in Sources */, 035EB0CA2A8687C200227859 /* JumpButtonView.swift in Sources */, + CD26980A2B9E166D0002B459 /* APIModLockPost.swift in Sources */, 5016A2B12A67EB8600B257E8 /* UIViewController+TopMostViewController.swift in Sources */, 6372184C2A3A2AAD008C4816 /* APIPostView.swift in Sources */, CD12627D2B475E45007549F9 /* PostModel+TrackerItem.swift in Sources */, @@ -3515,12 +4277,18 @@ 0308E1162B0EA42B000CA955 /* APILocalUserView.swift in Sources */, 030E863F2AC6C5E9000283A6 /* PictrsImageModel.swift in Sources */, 632E8EE627EE63D3007E8D75 /* UpvoteButtonView.swift in Sources */, + CDBA5FCA2BD17F3D00469C05 /* CommunityListModel.swift in Sources */, + CDD0B9022BC084A9003E7174 /* DenyApplicationView.swift in Sources */, B1A26FE32A45B11800B91A32 /* View+HandleLemmyLinks.swift in Sources */, 03B643572A6864CD00F65700 /* TabBarSettingsView.swift in Sources */, CDF842642A49EAFA00723DA0 /* GetPersonMentions.swift in Sources */, CD6A2A792B1A553500003E23 /* SuccessResponse.swift in Sources */, 031A61802B1CEA7300ABF23B /* ChangePassword.swift in Sources */, + 030245C42BA6123A00D07747 /* RemoveCommunityView.swift in Sources */, + CDB652552B8EAC3E007B7797 /* APIPostFeatureType.swift in Sources */, CD4368B42AE23F3500BD8BD1 /* ChildTrackerProtocol.swift in Sources */, + CDD0B90A2BC08E02003E7174 /* InboxRegistrationApplicationView.swift in Sources */, + 03AFBEA32B6EA86B00F01F3C /* SiteResponse+Mock.swift in Sources */, 0355DA512B5EB87700CDF5A5 /* InstanceResultView.swift in Sources */, CD4368D92AE2478300BD8BD1 /* MentionModel+InboxItem.swift in Sources */, CDB45C5A2AF0AEFE00A1FF08 /* AlternativeIconLabel.swift in Sources */, @@ -3528,12 +4296,16 @@ 0308E1182B0EA466000CA955 /* APILocalUser.swift in Sources */, CDA217F32A63202600BDA173 /* View+NsfwOverlay.swift in Sources */, CDB45C602AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift in Sources */, + 03B85A3E2BB36C4B003C4203 /* UserRemovalWalker.swift in Sources */, + CDD0B8F42BBF843C003E7174 /* InboxMessageReportBodyView.swift in Sources */, 6372184E2A3A2AAD008C4816 /* APIPersonView.swift in Sources */, 6363D5FA27EE1BDA00E34822 /* SettingsView.swift in Sources */, + CDD0B8C72BB78260003E7174 /* RemoveButtonView.swift in Sources */, CDDCF6532A677F45003DA3AC /* TabSelection.swift in Sources */, CD4368C42AE240B100BD8BD1 /* MentionModel.swift in Sources */, 03A2767D2AFE656700C0D66B /* UserModel+MenuFunctions.swift in Sources */, 0308E1142B0EA32A000CA955 /* AccountSettingsView.swift in Sources */, + CD2697EA2B9E15610002B459 /* APIModLockPostView.swift in Sources */, 6372184D2A3A2AAD008C4816 /* APIErrorResponse.swift in Sources */, CD7B53B92A5F263D00006E81 /* APIPrivateMessageReport.swift in Sources */, 637218602A3A2AAD008C4816 /* EditPost.swift in Sources */, @@ -3541,16 +4313,21 @@ CD29ED372B2E85EA006937CE /* String+Alphabet.swift in Sources */, 637218562A3A2AAD008C4816 /* APILocalSite.swift in Sources */, CDA217EE2A630F3300BDA173 /* ReportPost.swift in Sources */, + 039C59A92BADA5DA00C18765 /* PurgeCommentRequest.swift in Sources */, 03E79F3F2AE3E7100006700D /* SortingSettingsView.swift in Sources */, 039C8DB92B35A81C0096BAAF /* AccountIconStack.swift in Sources */, + CD2698022B9E15D60002B459 /* APIAdminPurgeCommentView.swift in Sources */, CDCBD7262A8D69A200387A2C /* Instance Picker View.swift in Sources */, 03C905CE2B3C8DC400B9082F /* UserView+Logic.swift in Sources */, + CDD0B9082BC08A6D003E7174 /* RegistrationApplicationTracker.swift in Sources */, CDEC95122B5B318B004BA288 /* CommunityFeedView.swift in Sources */, 6372185B2A3A2AAD008C4816 /* APICommunityView.swift in Sources */, 030E86442AC6F6D5000283A6 /* SearchBar+NavigationView.swift in Sources */, 637218552A3A2AAD008C4816 /* APITagline.swift in Sources */, + CD9395272BA7CF92008F6C4C /* ModlogAction.swift in Sources */, 6322A5CB27F77A4D00135D4F /* Loading View.swift in Sources */, 03A1B3F72A84000400AB0DE0 /* APIContentAggregatesProtocol.swift in Sources */, + CD26980C2B9E16810002B459 /* APIModRemoveComment.swift in Sources */, 032C1E042B5D7DAC00FB4F23 /* QuickSwitcherSettingsView.swift in Sources */, CD4DBC032A6F803C001A1E61 /* ReplyToPost.swift in Sources */, CD6483302A38D31C00EE6CA3 /* UpvoteCounterView.swift in Sources */, @@ -3559,9 +4336,13 @@ 88B165B82A8643F4007C9115 /* View+NavigationBarColor.swift in Sources */, 030AC0522A64666C00037155 /* UserSettingsView.swift in Sources */, CDA2C5262A705D6000649D5A /* PostEditor.swift in Sources */, + CDD0B9042BC08987003E7174 /* RegistrationApplication+TrackerItem.swift in Sources */, + 03F0DF582B9E24EF0018F239 /* PurgePersonRequest.swift in Sources */, E449C5912B2AA8A300E3BCF4 /* AccountDiscussionLanguagesView.swift in Sources */, 6372184A2A3A2AAD008C4816 /* APIPost.swift in Sources */, 6D693A3E2A5113DF009E2D76 /* CreatePostReport.swift in Sources */, + CDD0B9002BC080C7003E7174 /* ApproveRegistrationApplicationRequest.swift in Sources */, + 033EC0AF2BD3030A00AA238F /* BlockListView+Logic.swift in Sources */, E46AF9922B29AA350087FDF3 /* ScrollToView.swift in Sources */, CD391F942A533B7700E213B5 /* EditorModelProtocol.swift in Sources */, CD69F56F2A41EDF50028D4F7 /* View+SwipeyActions.swift in Sources */, @@ -3571,21 +4352,25 @@ CD6483322A38D3A600EE6CA3 /* ScoreCounterView.swift in Sources */, 50CC4A7A2A9CC45D0074C845 /* InstanceMetadata+Mock.swift in Sources */, 6318DE5427FB958800CC2AD6 /* Stickied Tag.swift in Sources */, + CDD0B8DE2BBF4601003E7174 /* PostReportModel+InboxItem.swift in Sources */, CD7B53B72A5F258B00006E81 /* APIPrivateMessageReportView.swift in Sources */, 030AC04F2A6464DA00037155 /* CommunitySettingsView.swift in Sources */, 6386E03A2A0455BC006B3C1D /* String+Contains.swift in Sources */, 63F0C7A62A05225100A18C5D /* Saved Account.swift in Sources */, 637218462A3A2AAD008C4816 /* APIComment.swift in Sources */, CDCBD7282A8D6B7700387A2C /* Instance Picker View Logic.swift in Sources */, + CD5F76BC2B75BE700013A827 /* MarkReadBatcher.swift in Sources */, 637457102A18CB6600B69C03 /* Custom Text Field.swift in Sources */, 038A16E12A75AA880087987E /* LayoutWidgetModel.swift in Sources */, 63344C4F2A07BD2A001BC616 /* Filters Tracker.swift in Sources */, 03B7AAEF2ABCB9DC00068B23 /* ContentTracker.swift in Sources */, CDEC95142B5CBC42004BA288 /* AggregateFeedView+Logic.swift in Sources */, + CDD0B8B62BB4DC12003E7174 /* CommentReportTracker.swift in Sources */, 50811B2C2A920443006BA3F2 /* Date+Mock.swift in Sources */, CD391FA02A545F8600E213B5 /* Compact Post.swift in Sources */, B1A5A8152A4C882F00F203DB /* AlternativeIcon.swift in Sources */, 637218512A3A2AAD008C4816 /* APILocalSiteRateLimit.swift in Sources */, + 03AFBEB12B6EAD5B00F01F3C /* InstanceSafetyView.swift in Sources */, 50BC1ABB2A8D6A5A00E3C48B /* ScoringOperation.swift in Sources */, CD4368BA2AE23F6400BD8BD1 /* TrackerItem.swift in Sources */, 6386E0362A042C59006B3C1D /* Contributor.swift in Sources */, @@ -3594,32 +4379,45 @@ CD82A2552A716C7C00111034 /* APIPersonUnreadCounts.swift in Sources */, CD04D5E72A3636FB008EF95B /* Headline Post.swift in Sources */, 50A8812E2A72D76C003E3661 /* APIClient+Comment.swift in Sources */, + CDD0B8CF2BBA0D31003E7174 /* ResolveCommentReportRequest.swift in Sources */, + 030FF6882BCEE58900F6BFAC /* BlockInstance.swift in Sources */, 6363D5C527EE196700E34822 /* MlemApp.swift in Sources */, CDF1EF162A6C3BC2003594B6 /* End Of Feed View.swift in Sources */, + CDD0B8D32BBB4158003E7174 /* InboxView+Feeds.swift in Sources */, + CDD0B8CD2BB9E53F003E7174 /* Removable.swift in Sources */, 637218542A3A2AAD008C4816 /* APILanguage.swift in Sources */, + CDD0EF9C2B7D6B9100CA3504 /* ModToolTracker.swift in Sources */, CD963FCB2B5F0388002352FD /* DefaultFeedType.swift in Sources */, 50CC4A722A9CB07F0074C845 /* TimeInterval+Period.swift in Sources */, + CDD0B8C32BB5BCFD003E7174 /* InboxCommentReportBodyView.swift in Sources */, AD1B0D372A5F7A260006F554 /* Licenses.swift in Sources */, 6372186E2A3A2AAD008C4816 /* DeleteComment.swift in Sources */, CDB45C5C2AF1A1D800A1FF08 /* CoreTracker.swift in Sources */, 507573982A5AD60100AA7ABD /* ErrorAlert.swift in Sources */, CD4368AE2AE23ED400BD8BD1 /* StandardTracker.swift in Sources */, + CDD0B8F82BC07E6A003E7174 /* ListRegistrationApplicationsRequest.swift in Sources */, + CD50504D2B80065300632C56 /* Date+DaysFromNow.swift in Sources */, CDE6A8182A490AF20062D161 /* InboxMentionBodyView.swift in Sources */, CD3FBCDD2A4A6F0600B2063F /* GetReplies.swift in Sources */, CD4368DB2AE247B700BD8BD1 /* MentionTracker.swift in Sources */, + CD2697FC2B9E15C10002B459 /* APIAdminPurgePersonView.swift in Sources */, E49E01F42ABD99D300E42BB3 /* Routable.swift in Sources */, 03F4DC9F2B1A8AD500556C67 /* SignInAndSecuritySettingsView.swift in Sources */, 03F76FA62B2F5F4700E2B54A /* LinkUploadOptionsView.swift in Sources */, 6D15D74C2A44DC240061B5CB /* Date+Formatter.swift in Sources */, + CD2698122B9E17660002B459 /* APIModRemoveCommunity.swift in Sources */, CDA217E62A63016A00BDA173 /* ReportMessage.swift in Sources */, + CDD0B8C12BB5BC51003E7174 /* InboxMessageBodyView.swift in Sources */, CD9DD8832A622A6C0044EA8E /* ReportCommentReply.swift in Sources */, 6FB4A4DE2B47860B00A7CD82 /* CollapsedCommentReplies.swift in Sources */, - CD3FBCE12A4A836000B2063F /* AllItemsFeedView.swift in Sources */, + CDD0B8AB2BB37B3D003E7174 /* WebsiteIndicatorView.swift in Sources */, 6D91D4582A4159D8006B8F9A /* FavoriteStarButtonStyle.swift in Sources */, 63F0C7B92A0533C700A18C5D /* Add Account View.swift in Sources */, 63E5D3922A13CF2300EC1FBD /* Favorite Community Tracker.swift in Sources */, B1B78D642A51D53900F72485 /* AppDelegate.swift in Sources */, 03A1B3F42A83F46200AB0DE0 /* ShareButtonView.swift in Sources */, + CD2698102B9E174C0002B459 /* APIModBanFromCommunity.swift in Sources */, + CDD0B8EC2BBF7D0F003E7174 /* ListPrivateMessageReportsRequest.swift in Sources */, 03FD64FF2AE53D0E00957AA9 /* CommunityModel+ContentModel.swift in Sources */, 6DA61F812A55B83F001EA633 /* SearchView.swift in Sources */, ); @@ -3799,6 +4597,7 @@ INFOPLIST_FILE = Mlem/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Mlem; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "$(PRODUCT_NAME) requires Face ID permissions for app locking feature."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -3811,7 +4610,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.Mlem; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -3841,6 +4640,7 @@ INFOPLIST_FILE = Mlem/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Mlem; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "$(PRODUCT_NAME) requires Face ID permissions for app locking feature."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -3853,7 +4653,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = com.hanners.Mlem; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -3990,6 +4790,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 03F6D4B62B951D64008235A0 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.3; + }; + }; 50C99B542A61D792005D57DD /* XCRemoteSwiftPackageReference "swift-dependencies" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; @@ -4033,6 +4841,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 03F6D4B82B951E21008235A0 /* SwiftUIIntrospect */ = { + isa = XCSwiftPackageProductDependency; + package = 03F6D4B62B951D64008235A0 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = SwiftUIIntrospect; + }; 50C99B552A61D792005D57DD /* Dependencies */ = { isa = XCSwiftPackageProductDependency; package = 50C99B542A61D792005D57DD /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/Mlem.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mlem.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d9b5f5c17..acfdc223d 100644 --- a/Mlem.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mlem.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -72,6 +72,15 @@ "version" : "2.1.0" } }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/SwiftUI-Introspect", + "state" : { + "revision" : "0cd2a5a5895306bc21d54a2254302d24a9a571e4", + "version" : "1.1.3" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Mlem/API/APIClient/APIClient+Comment.swift b/Mlem/API/APIClient/APIClient+Comment.swift index 99c6430af..2b41804e8 100644 --- a/Mlem/API/APIClient/APIClient+Comment.swift +++ b/Mlem/API/APIClient/APIClient+Comment.swift @@ -101,4 +101,28 @@ extension APIClient { let request = try CreateCommentReportRequest(session: session, commentId: id, reason: reason) return try await perform(request: request) } + + func removeComment(id: Int, shouldRemove: Bool, reason: String?) async throws -> CommentResponse { + let request = try RemoveCommentRequest(session: session, commentId: id, removed: shouldRemove, reason: reason) + return try await perform(request: request) + } + + func purgeComment(id: Int, reason: String?) async throws -> SuccessResponse { + let request = try PurgeCommentRequest(session: session, commentId: id, reason: reason) + return try await perform(request: request) + } + + func getCommentLikes( + id: Int, + page: Int, + limit: Int? + ) async throws -> APIListCommentLikesResponse { + let request = try ListCommentLikesRequest( + session: session, + commentId: id, + page: page, + limit: limit + ) + return try await perform(request: request) + } } diff --git a/Mlem/API/APIClient/APIClient+Community.swift b/Mlem/API/APIClient/APIClient+Community.swift index c98904398..f5913c6c0 100644 --- a/Mlem/API/APIClient/APIClient+Community.swift +++ b/Mlem/API/APIClient/APIClient+Community.swift @@ -57,4 +57,74 @@ extension APIClient { return try await perform(request: request) } + + /// Bans the given user from the given community, provided the current user has permissions to do so + /// - Parameters: + /// - userId: id of the user to ban + /// - communityId: id of the community to ban the user from + /// - ban: true if user should be banned, false if unbanned + /// - removeData: true if user data should be removed from community, false or nil otherwise + /// - reason: reason for ban + /// - expires: expiration date of ban (unit???) + /// - Returns: updated ban status of user (true if banned, false otherwise) + func banFromCommunity( + userId: Int, + communityId: Int, + ban: Bool, + removeData: Bool? = nil, + reason: String? = nil, + expires: Int? = nil + ) async throws -> Bool { + let request = try BanFromCommunityRequest( + session: session, + communityId: communityId, + personId: userId, + ban: ban, + removeData: removeData, + reason: reason, + expires: expires + ) + + let response = try await perform(request: request) + + return response.banned + } + + func removeCommunity(id: Int, shouldRemove: Bool, reason: String?) async throws -> CommunityResponse { + let request = try RemoveCommunityRequest(session: session, communityId: id, removed: shouldRemove, reason: reason) + return try await perform(request: request) + } + + func purgeCommunity(id: Int, reason: String?) async throws -> SuccessResponse { + let request = try PurgeCommunityRequest(session: session, communityId: id, reason: reason) + return try await perform(request: request) + } + + /// Adds or removes the given user from the mod list of the given community + /// - Parameters: + /// - of: id of user to add/remove + /// - in: id of the community to add/remove to/from + /// - status: whether to add (true) or remove (false) + /// - Returns: new list of moderators + /// - Throws: error upon failed update + func updateModStatus(of userId: Int, in communityId: Int, status: Bool) async throws -> [UserModel] { + // perform update + let request = try AddModToCommunityRequest( + session: session, + communityId: communityId, + personId: userId, + added: status + ) + print(request) + let response = try await perform(request: request) + + // validate response + let isMod = response.moderators.contains(where: { $0.moderator.id == userId }) + if isMod != status { + throw ContextualError(title: "Failed to add mod", underlyingError: APIClientError.unexpectedResponse) + } + + // return new mod list + return response.moderators.map { UserModel(from: $0.moderator) } + } } diff --git a/Mlem/API/APIClient/APIClient+Instance.swift b/Mlem/API/APIClient/APIClient+Instance.swift new file mode 100644 index 000000000..a444d7081 --- /dev/null +++ b/Mlem/API/APIClient/APIClient+Instance.swift @@ -0,0 +1,93 @@ +// +// APIClient+Instance.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-11. +// + +import Foundation + +extension APIClient { + // swiftlint:disable:next function_body_length + func getModlog( + for instanceUrl: URL? = nil, + modPersonId: Int? = nil, + communityId: Int? = nil, + page: Int, + limit: Int, + type: APIModlogActionType? = nil, + otherPersonId: Int? = nil + ) async throws -> [ModlogEntry] { + var useSession: APISession + + if let instanceUrl { + useSession = .unauthenticated(instanceUrl.appending(path: "/api/v3")) + } else { + useSession = session + } + + #if DEBUG + if let host = instanceUrl?.host(), ["lemmy-alpha", "lemmy-beta", "lemmy-delta"].contains(host) { + useSession = .unauthenticated(.init(string: "http://localhost:8536/api/v3")!) + } + #endif + + let request = try GetModlogRequest( + session: useSession, + modPersonId: nil, + communityId: communityId, + page: page, + limit: limit, + type_: type, + otherPersonId: otherPersonId + ) + + let response = try await perform(request: request) + + func isAdmin(for actorId: URL?) -> Bool { + // can only view admin actions if the logged in user is an admin and the modlog is sourced from their instance + siteInformation.isAdmin && actorId?.host() == siteInformation.instance?.url.host() + } + + func canViewRemovedPost(in community: APICommunity) -> Bool { + siteInformation.isMod(communityActorId: community.actorId) || + isAdmin(for: community.actorId) + } + + var ret: [ModlogEntry] = .init() + ret.append(contentsOf: response.removedPosts.map { ModlogEntry( + from: $0, + canViewRemovedPost: canViewRemovedPost(in: $0.community) + ) }) + ret.append(contentsOf: response.lockedPosts.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.featuredPosts.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.removedComments.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.removedCommunities.map { ModlogEntry( + from: $0, + canViewRemovedCommunity: isAdmin(for: $0.community.actorId) + ) }) + ret.append(contentsOf: response.bannedFromCommunity.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.banned.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.addedToCommunity.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.transferredToCommunity.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.added.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.adminPurgedPersons.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.adminPurgedCommunities.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.adminPurgedPosts.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.adminPurgedComments.map { ModlogEntry(from: $0) }) + ret.append(contentsOf: response.hiddenCommunities.map { ModlogEntry(from: $0) }) + + return ret.sorted(by: { $0.date > $1.date }) + } + + @discardableResult + func blockSite(id: Int, shouldBlock: Bool) async throws -> BlockInstanceResponse { + let request = try BlockInstanceRequest( + session: session, + instanceId: id, + block: shouldBlock + ) + + return try await perform(request: request) + } +} diff --git a/Mlem/API/APIClient/APIClient+Moderation.swift b/Mlem/API/APIClient/APIClient+Moderation.swift new file mode 100644 index 000000000..9b4699288 --- /dev/null +++ b/Mlem/API/APIClient/APIClient+Moderation.swift @@ -0,0 +1,268 @@ +// +// APIClient+Moderation.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-27. +// + +import Foundation + +extension APIClient { + // MARK: - Comment Reports + + func loadCommentReports( + page: Int, + limit: Int, + unresolvedOnly: Bool, + communityId: Int? + ) async throws -> [CommentReportModel] { + // the request throws an error if the calling user is not mod or admin--should never be called + guard siteInformation.isAdmin || !siteInformation.moderatedCommunities.isEmpty else { + assertionFailure("loadCommentReports called by non-moderator user!") + return .init() + } + + let request = try ListCommentReportsRequest( + session: session, + page: page, + limit: limit, + unresolvedOnly: unresolvedOnly, + communityId: communityId + ) + let response = try await perform(request: request) + + return response.commentReports.map { report in + var resolver: UserModel? + if let apiResolver = report.resolver { + resolver = UserModel(from: apiResolver) + } + + return CommentReportModel( + reporter: UserModel(from: report.creator), + resolver: resolver, + commentCreator: UserModel(from: report.commentCreator), + community: CommunityModel(from: report.community), + commentReport: report.commentReport, + comment: report.comment, + votes: VotesModel(from: report.counts, myVote: report.myVote ?? .resetVote), + numReplies: report.counts.childCount, + commentCreatorBannedFromCommunity: report.creatorBannedFromCommunity + ) + } + } + + func markCommentReportResolved( + reportId: Int, + resolved: Bool + ) async throws -> CommentReportModel { + let request = try ResolveCommentReportRequest(session: session, reportId: reportId, resolved: resolved) + let response = try await perform(request: request) + + var resolver: UserModel? + if let apiResolver = response.commentReportView.resolver { + resolver = UserModel(from: apiResolver) + } + + return CommentReportModel( + reporter: UserModel(from: response.commentReportView.creator), + resolver: resolver, + commentCreator: UserModel(from: response.commentReportView.commentCreator), + community: CommunityModel(from: response.commentReportView.community), + commentReport: response.commentReportView.commentReport, + comment: response.commentReportView.comment, + votes: VotesModel(from: response.commentReportView.counts, myVote: response.commentReportView.myVote), + numReplies: response.commentReportView.counts.childCount, + commentCreatorBannedFromCommunity: response.commentReportView.creatorBannedFromCommunity + ) + } + + // MARK: - Post Reports + + func loadPostReports( + page: Int, + limit: Int, + unresolvedOnly: Bool, + communityId: Int? + ) async throws -> [PostReportModel] { + // the request throws an error if the calling user is not mod or admin--should never be called + guard siteInformation.isAdmin || !siteInformation.moderatedCommunities.isEmpty else { + assertionFailure("loadPostReports called by non-moderator user!") + return .init() + } + + let request = try ListPostReportsRequest( + session: session, + page: page, + limit: limit, + unresolvedOnly: unresolvedOnly, + communityId: communityId + ) + let response = try await perform(request: request) + + return response.postReports.map { report in + var resolver: UserModel? + if let apiResolver = report.resolver { + resolver = UserModel(from: apiResolver) + } + + return PostReportModel( + reporter: UserModel(from: report.creator), + resolver: resolver, + postCreator: UserModel(from: report.postCreator), + community: CommunityModel(from: report.community), + postReport: report.postReport, + post: report.post, + votes: VotesModel(from: report.counts, myVote: report.myVote ?? .resetVote), + numReplies: report.counts.comments, + postCreatorBannedFromCommunity: report.creatorBannedFromCommunity + ) + } + } + + func markPostReportResolved( + reportId: Int, + resolved: Bool + ) async throws -> PostReportModel { + let request = try ResolvePostReportRequest(session: session, reportId: reportId, resolved: resolved) + let response = try await perform(request: request) + + var resolver: UserModel? + if let apiResolver = response.postReportView.resolver { + resolver = UserModel(from: apiResolver) + } + + return PostReportModel( + reporter: UserModel(from: response.postReportView.creator), + resolver: resolver, + postCreator: UserModel(from: response.postReportView.postCreator), + community: CommunityModel(from: response.postReportView.community), + postReport: response.postReportView.postReport, + post: response.postReportView.post, + votes: VotesModel(from: response.postReportView.counts, myVote: response.postReportView.myVote ?? .resetVote), + numReplies: response.postReportView.counts.comments, + postCreatorBannedFromCommunity: response.postReportView.creatorBannedFromCommunity + ) + } + + // MARK: - Message Reports + + func loadMessageReports( + page: Int, + limit: Int, + unresolvedOnly: Bool + ) async throws -> [MessageReportModel] { + let request = try ListPrivateMessageReportsRequest(session: session, page: page, limit: limit, unresolvedOnly: unresolvedOnly) + let response = try await perform(request: request) + + return response.privateMessageReports.map { report in + var resolver: UserModel? + if let apiResolver = report.resolver { + resolver = UserModel(from: apiResolver) + } + + return MessageReportModel( + reporter: UserModel(from: report.creator), + resolver: resolver, + messageCreator: UserModel(from: report.privateMessageCreator), + messageReport: report.privateMessageReport + ) + } + } + + func markPrivateMessageReportResolved( + reportId: Int, + resolved: Bool + ) async throws -> MessageReportModel { + let request = try ResolvePrivateMessageReportRequest(session: session, reportId: reportId, resolved: resolved) + let response = try await perform(request: request).privateMessageReportView + + var resolver: UserModel? + if let apiResolver = response.resolver { + resolver = UserModel(from: apiResolver) + } + + return MessageReportModel( + reporter: UserModel(from: response.creator), + resolver: resolver, + messageCreator: UserModel(from: response.privateMessageCreator), + messageReport: response.privateMessageReport + ) + } + + // MARK: - Registration Applications + + func loadRegistrationApplications( + page: Int, + limit: Int, + unresolvedOnly: Bool + ) async throws -> [RegistrationApplicationModel] { + let request = try ListRegistrationApplicationsRequest(session: session, unreadOnly: unresolvedOnly, page: page, limit: limit) + let response = try await perform(request: request) + + return response.registrationApplications.map { registrationApplication in + var resolver: UserModel? + if let apiResolver = registrationApplication.admin { + resolver = UserModel(from: apiResolver) + } + + return RegistrationApplicationModel( + application: registrationApplication.registrationApplication, + creator: UserModel(from: registrationApplication.creator), + resolver: resolver, + approved: resolver != nil ? registrationApplication.creatorLocalUser.acceptedApplication : nil + ) + } + } + + func approveRegistrationApplication( + applicationId: Int, + approve: Bool, + denyReason: String? + ) async throws -> RegistrationApplicationModel { + let request = try ApproveRegistrationApplicationRequest( + session: session, + id: applicationId, + approve: approve, + denyReason: denyReason + ) + let response = try await perform(request: request).registrationApplication + + var resolver: UserModel? + if let apiResolver = response.admin { + resolver = UserModel(from: apiResolver) + } + + return RegistrationApplicationModel( + application: response.registrationApplication, + creator: UserModel(from: response.creator), + resolver: resolver, + approved: resolver != nil ? response.creatorLocalUser.acceptedApplication : nil + ) + } + + // MARK: - Unread Counts + + func getUnreadReports(for communityId: Int?) async throws -> APIGetReportCountResponse { + // the request throws an error if the calling user is not mod or admin--should never be called + guard siteInformation.isAdmin || !siteInformation.moderatedCommunities.isEmpty else { + assertionFailure("getUnreadReports called by non-moderator user!") + return .init(communityId: communityId, commentReports: 0, postReports: 0, privateMessageReports: 0) + } + + let request = try GetReportCountRequest(session: session, communityId: communityId) + let response = try await perform(request: request) + return response + } + + func getUnreadRegistrationApplications() async throws -> APIGetUnreadRegistrationApplicationCountResponse { + // the request throws an error if the calling user is not an admin--should never be called + guard siteInformation.isAdmin else { + assertionFailure("getUnreadRegistrationApplications called by non-admin user!") + return .init(registrationApplications: 0) + } + + let request = try GetUnreadRegistrationApplicationCountRequest(session: session) + let response = try await perform(request: request) + return response + } +} diff --git a/Mlem/API/APIClient/APIClient+Post.swift b/Mlem/API/APIClient/APIClient+Post.swift index 055ba6fe7..79bfe4c82 100644 --- a/Mlem/API/APIClient/APIClient+Post.swift +++ b/Mlem/API/APIClient/APIClient+Post.swift @@ -43,6 +43,13 @@ extension APIClient { return SuccessResponse(from: compatibilityResponse) } + func markPostsAsRead(for postIds: [Int], read: Bool) async throws -> SuccessResponse { + let request = try MarkPostReadRequest(session: session, postIds: postIds, read: read) + // TODO: 0.18 deprecation simply return result of perform + let compatibilityResponse = try await perform(request: request) + return SuccessResponse(from: compatibilityResponse) + } + func loadPost(id: Int, commentId: Int? = nil) async throws -> APIPostView { let request = try GetPostRequest(session: session, id: id, commentId: commentId) return try await perform(request: request).postView @@ -111,4 +118,44 @@ extension APIClient { let request = try SavePostRequest(session: session, postId: id, save: shouldSave) return try await perform(request: request).postView } + + func featurePost(id: Int, shouldFeature: Bool, featureType: APIPostFeatureType) async throws -> APIPostView { + let request = try FeaturePostRequest( + session: session, + postId: id, + featured: shouldFeature, + featureType: featureType + ) + return try await perform(request: request).postView + } + + func lockPost(id: Int, shouldLock: Bool) async throws -> APIPostView { + let request = try LockPostRequest(session: session, postId: id, locked: shouldLock) + return try await perform(request: request).postView + } + + func removePost(id: Int, shouldRemove: Bool, reason: String?) async throws -> PostModel { + let request = try RemovePostRequest(session: session, postId: id, removed: shouldRemove, reason: reason) + let response = try await perform(request: request).postView + return PostModel(from: response) + } + + func purgePost(id: Int, reason: String?) async throws -> SuccessResponse { + let request = try PurgePostRequest(session: session, postId: id, reason: reason) + return try await perform(request: request) + } + + func getPostLikes( + id: Int, + page: Int, + limit: Int? + ) async throws -> APIListPostLikesResponse { + let request = try ListPostLikesRequest( + session: session, + postId: id, + page: page, + limit: limit + ) + return try await perform(request: request) + } } diff --git a/Mlem/API/APIClient/APIClient.swift b/Mlem/API/APIClient/APIClient.swift index 4ac211de8..80bd255a3 100644 --- a/Mlem/API/APIClient/APIClient.swift +++ b/Mlem/API/APIClient/APIClient.swift @@ -5,6 +5,7 @@ // Created by Nicholas Lawson on 04/06/2023. // +import Dependencies import Foundation // swiftlint:disable file_length @@ -21,6 +22,7 @@ enum APIClientError: Error { case cancelled case invalidSession case decoding(Data, Error?) + case unexpectedResponse } extension APIClientError: CustomStringConvertible { @@ -49,11 +51,15 @@ extension APIClientError: CustomStringConvertible { } return "Unable to decode: \(string)" + case .unexpectedResponse: + return "Unexpected response" } } } class APIClient { + @Dependency(\.siteInformation) var siteInformation + let urlSession: URLSession let decoder: JSONDecoder let transport: (URLSession, URLRequest) async throws -> (Data, URLResponse) @@ -173,6 +179,8 @@ class APIClient { } else if let putDefinition = definition as? any APIPutRequest { urlRequest.httpMethod = "PUT" urlRequest.httpBody = try createBodyData(for: putDefinition) + } else if let deleteDefinition = definition as? any APIDeleteRequest { + urlRequest.httpMethod = "DELETE" } return urlRequest @@ -256,6 +264,23 @@ extension APIClient { return try await perform(request: request) } + func banPerson(id: Int, shouldBan: Bool, expires: Int?, reason: String?, removeData: Bool) async throws -> BanPersonResponse { + let request = try BanPersonRequest( + session: session, + personId: id, + ban: shouldBan, + expires: expires, + reason: reason, + removeData: removeData + ) + return try await perform(request: request) + } + + func purgePerson(id: Int, reason: String?) async throws -> SuccessResponse { + let request = try PurgePersonRequest(session: session, personId: id, reason: reason) + return try await perform(request: request) + } + func markPersonMentionAsRead(mentionId: Int, isRead: Bool) async throws -> APIPersonMentionView { let request = try MarkPersonMentionAsRead(session: session, personMentionId: mentionId, read: isRead) return try await perform(request: request).personMentionView diff --git a/Mlem/API/APIRequest.swift b/Mlem/API/APIRequest.swift index 6e5286289..b30fbada2 100644 --- a/Mlem/API/APIRequest.swift +++ b/Mlem/API/APIRequest.swift @@ -23,6 +23,8 @@ protocol APIRequest { var headers: [String: String] { get } } +protocol APIDeleteRequest: APIRequest {} + extension APIRequest { var headers: [String: String] { defaultHeaders } diff --git a/Mlem/API/Internal/HierarchicalComment.swift b/Mlem/API/Internal/HierarchicalComment.swift index 9883b7544..9ca461d5e 100644 --- a/Mlem/API/Internal/HierarchicalComment.swift +++ b/Mlem/API/Internal/HierarchicalComment.swift @@ -5,11 +5,19 @@ // Created by Nicholas Lawson on 08/06/2023. // +import Dependencies import Foundation +import SwiftUI /// A model which represents a comment and it's child relationships -class HierarchicalComment: ObservableObject { +class HierarchicalComment: Purgable, ObservableObject { + @Dependency(\.apiClient) var apiClient + @Dependency(\.hapticManager) var hapticManager + @Dependency(\.errorHandler) var errorHandler + @Published var commentView: APICommentView + @Published var purged: Bool = false + var children: [HierarchicalComment] /// Indicates comment's position in a post's parent/child comment thread. /// Values range from `0...Int.max`, where 0 indicates the parent comment. @@ -37,6 +45,26 @@ class HierarchicalComment: ObservableObject { self.isCollapsed = shouldCollapseChildren && depth == 1 || collapsed self.links = comment.comment.content.parseLinks() } + + func purge(reason: String?) async -> Bool { + DispatchQueue.main.async { + self.purged = true + } + do { + let response = try await apiClient.purgeComment(id: commentView.id, reason: reason) + if !response.success { + throw APIClientError.unexpectedResponse + } + return true + } catch { + DispatchQueue.main.async { + self.hapticManager.play(haptic: .failure, priority: .high) + self.errorHandler.handle(error) + self.purged = false + } + } + return false + } } extension HierarchicalComment: Identifiable { @@ -158,9 +186,30 @@ extension HierarchicalComment { } } +extension HierarchicalComment: Removable { + func remove(reason: String?, shouldRemove: Bool) async -> Bool { + do { + let response = try await apiClient.removeComment( + id: commentView.comment.id, + shouldRemove: shouldRemove, + reason: reason + ) + DispatchQueue.main.async { + self.commentView = response.commentView + } + return true + } catch { + errorHandler.handle(error) + } + return false + } +} + extension [APICommentView] { /// A representation of this array of `APICommentView` in a hierarchy that is suitable for rendering the UI with parent/child relationships var hierarchicalRepresentation: [HierarchicalComment] { + @AppStorage("collapseChildComments") var collapseChildComments = false + var allComments = self let childrenStartIndex = allComments.partition(by: { $0.comment.parentId != nil }) @@ -173,7 +222,6 @@ extension [APICommentView] { } let identifiedComments = Dictionary(uniqueKeysWithValues: allComments.lazy.map { ($0.id, $0) }) - let collapseChildComments = UserDefaults.standard.bool(forKey: "collapseChildComments") /// Recursively populates child comments by looking up IDs from `childrenById` func populateChildren(_ comment: APICommentView) -> HierarchicalComment { diff --git a/Mlem/API/Models/Comments/APIComment.swift b/Mlem/API/Models/Comments/APIComment.swift index 355332713..6c8b006e5 100644 --- a/Mlem/API/Models/Comments/APIComment.swift +++ b/Mlem/API/Models/Comments/APIComment.swift @@ -13,7 +13,7 @@ struct APIComment: Decodable, Identifiable { let creatorId: Int let postId: Int let content: String - let removed: Bool + var removed: Bool let deleted: Bool let published: Date let updated: Date? diff --git a/Mlem/API/Models/Comments/APICommentView.swift b/Mlem/API/Models/Comments/APICommentView.swift index 56c515db7..15aa8f55a 100644 --- a/Mlem/API/Models/Comments/APICommentView.swift +++ b/Mlem/API/Models/Comments/APICommentView.swift @@ -9,12 +9,12 @@ import Foundation // lemmy_db_views::structs::CommentView struct APICommentView: Decodable, APIContentViewProtocol { - let comment: APIComment - let creator: APIPerson + var comment: APIComment + var creator: APIPerson let post: APIPost let community: APICommunity let counts: APICommentAggregates - let creatorBannedFromCommunity: Bool + var creatorBannedFromCommunity: Bool let creatorIsModerator: Bool? // TODO: 0.18 deprecation make this field non-optional let creatorIsAdmin: Bool? // TODO: 0.18 deprecation make this field non-optional let subscribed: APISubscribedStatus diff --git a/Mlem/API/Models/Common/SuccessResponse.swift b/Mlem/API/Models/Common/SuccessResponse.swift index b732efe93..e11e6d82f 100644 --- a/Mlem/API/Models/Common/SuccessResponse.swift +++ b/Mlem/API/Models/Common/SuccessResponse.swift @@ -29,8 +29,8 @@ struct SuccessResponse: Decodable { } struct MarkReadCompatibilityResponse: Decodable { - let success: Bool? - let postView: APIPostView? + let success: Bool? // 0.19+ response + let postView: APIPostView? // 0.18- response } struct SaveUserSettingsCompatibilityResponse: Decodable { diff --git a/Mlem/API/Models/Person/APIPerson.swift b/Mlem/API/Models/Person/APIPerson.swift index 278f2c02c..e5eb44bc5 100644 --- a/Mlem/API/Models/Person/APIPerson.swift +++ b/Mlem/API/Models/Person/APIPerson.swift @@ -13,7 +13,7 @@ struct APIPerson: Decodable, Identifiable, Hashable, Equatable { let name: String var displayName: String? var avatar: String? - let banned: Bool + var banned: Bool let published: Date let updated: Date? let actorId: URL diff --git a/Mlem/API/Models/Posts/APIPost.swift b/Mlem/API/Models/Posts/APIPost.swift index f520ea20b..39aa6ceab 100644 --- a/Mlem/API/Models/Posts/APIPost.swift +++ b/Mlem/API/Models/Posts/APIPost.swift @@ -19,15 +19,15 @@ struct APIPost: Decodable { let embedDescription: String? let embedTitle: String? let embedVideoUrl: String? - let featuredCommunity: Bool + var featuredCommunity: Bool let featuredLocal: Bool let languageId: Int - let apId: String + let apId: URL let local: Bool let locked: Bool let nsfw: Bool let published: Date - let removed: Bool + var removed: Bool let thumbnailUrl: String? let updated: Date? } diff --git a/Mlem/API/Models/Posts/APIPostResponse.swift b/Mlem/API/Models/Posts/APIPostResponse.swift new file mode 100644 index 000000000..179db3320 --- /dev/null +++ b/Mlem/API/Models/Posts/APIPostResponse.swift @@ -0,0 +1,13 @@ +// +// APIPostResponse.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-25 +// + +import Foundation + +// PostResponse.ts +struct APIPostResponse: Decodable { + let postView: APIPostView +} diff --git a/Mlem/API/Models/Comments/APICommentReport.swift b/Mlem/API/Models/Reports/APICommentReport.swift similarity index 83% rename from Mlem/API/Models/Comments/APICommentReport.swift rename to Mlem/API/Models/Reports/APICommentReport.swift index 6e8b577cb..3ad1f4315 100644 --- a/Mlem/API/Models/Comments/APICommentReport.swift +++ b/Mlem/API/Models/Reports/APICommentReport.swift @@ -8,13 +8,13 @@ import Foundation // lemmy_db_schema::source::comment::CommentReport -struct APICommentReport: Decodable { +struct APICommentReport: Hashable, Decodable { let id: Int let creatorId: Int let commentId: Int let originalCommentText: String let reason: String - let resolved: Bool + var resolved: Bool let resolverId: Int? let published: Date let updated: Date? diff --git a/Mlem/API/Models/Comments/APICommentReportView.swift b/Mlem/API/Models/Reports/APICommentReportView.swift similarity index 93% rename from Mlem/API/Models/Comments/APICommentReportView.swift rename to Mlem/API/Models/Reports/APICommentReportView.swift index fb1419b63..61a19cf5d 100644 --- a/Mlem/API/Models/Comments/APICommentReportView.swift +++ b/Mlem/API/Models/Reports/APICommentReportView.swift @@ -17,6 +17,6 @@ struct APICommentReportView: Decodable { let commentCreator: APIPerson let counts: APICommentAggregates let creatorBannedFromCommunity: Bool - let myVote: Int? + let myVote: ScoringOperation? let resolver: APIPerson? } diff --git a/Mlem/API/Models/Posts/APIPostReport.swift b/Mlem/API/Models/Reports/APIPostReport.swift similarity index 88% rename from Mlem/API/Models/Posts/APIPostReport.swift rename to Mlem/API/Models/Reports/APIPostReport.swift index 5a15e0392..ef5fd3dd0 100644 --- a/Mlem/API/Models/Posts/APIPostReport.swift +++ b/Mlem/API/Models/Reports/APIPostReport.swift @@ -8,7 +8,7 @@ import Foundation // lemmy_db_schema::source::post::PostReport -struct APIPostReport: Decodable { +struct APIPostReport: Hashable, Decodable { let id: Int let creatorId: Int let postId: Int @@ -16,7 +16,7 @@ struct APIPostReport: Decodable { let originalPostUrl: String? let originalPostBody: String? let reason: String - let resolved: Bool + var resolved: Bool let resolverId: Int? let published: Date let updated: Date? diff --git a/Mlem/API/Models/Posts/APIPostReportView.swift b/Mlem/API/Models/Reports/APIPostReportView.swift similarity index 92% rename from Mlem/API/Models/Posts/APIPostReportView.swift rename to Mlem/API/Models/Reports/APIPostReportView.swift index a53f003f9..8d551de08 100644 --- a/Mlem/API/Models/Posts/APIPostReportView.swift +++ b/Mlem/API/Models/Reports/APIPostReportView.swift @@ -14,7 +14,7 @@ struct APIPostReportView: Decodable { let creator: APIPerson let postCreator: APIPerson let creatorBannedFromCommunity: Bool - let myVote: Int? + let myVote: ScoringOperation? let counts: APIPostAggregates let resolver: APIPerson? } diff --git a/Mlem/API/Models/Messages/APIPrivateMessageReport.swift b/Mlem/API/Models/Reports/APIPrivateMessageReport.swift similarity index 88% rename from Mlem/API/Models/Messages/APIPrivateMessageReport.swift rename to Mlem/API/Models/Reports/APIPrivateMessageReport.swift index 6a93ea8a6..93587b5e9 100644 --- a/Mlem/API/Models/Messages/APIPrivateMessageReport.swift +++ b/Mlem/API/Models/Reports/APIPrivateMessageReport.swift @@ -8,7 +8,7 @@ import Foundation // crates/db_schema/src/source/private_message_report.rs PrivateMessageReport -struct APIPrivateMessageReport: Decodable { +struct APIPrivateMessageReport: Decodable, Hashable { let id: Int let creatorId: Int let privateMessageId: Int diff --git a/Mlem/API/Models/Messages/APIPrivateMessageReportView.swift b/Mlem/API/Models/Reports/APIPrivateMessageReportView.swift similarity index 100% rename from Mlem/API/Models/Messages/APIPrivateMessageReportView.swift rename to Mlem/API/Models/Reports/APIPrivateMessageReportView.swift diff --git a/Mlem/API/Models/Site/APIMyUserInfo.swift b/Mlem/API/Models/Site/APIMyUserInfo.swift index 163c5ba5e..2adab3d60 100644 --- a/Mlem/API/Models/Site/APIMyUserInfo.swift +++ b/Mlem/API/Models/Site/APIMyUserInfo.swift @@ -11,5 +11,30 @@ import Foundation struct APIMyUserInfo: Decodable { // Some properties aren't implemented yet: https://join-lemmy.org/api/interfaces/MyUserInfo.html var localUserView: APILocalUserView + let moderates: [APICommunityModeratorView] var discussionLanguages: [Int] + var communityBlocks: [APICommunityBlockView] + var personBlocks: [APIUserBlockView] + var instanceBlocks: [APIInstanceBlockView]? // Nil pre-0.19.0 +} + +struct APICommunityBlockView: Decodable { + let community: APICommunity + let person: APIPerson +} + +struct APIUserBlockView: Decodable { + let target: APIPerson + let person: APIPerson +} + +struct APIInstanceBlockView: Decodable { + let instance: APIInstance + let person: APIPerson +} + +struct APIInstance: Decodable, Identifiable { + // Not all properties implemented yet https://join-lemmy.org/api/interfaces/Instance.html + let id: Int + let domain: String } diff --git a/Mlem/API/Models/Site/APIRegistrationApplication.swift b/Mlem/API/Models/Site/APIRegistrationApplication.swift new file mode 100644 index 000000000..558761ad0 --- /dev/null +++ b/Mlem/API/Models/Site/APIRegistrationApplication.swift @@ -0,0 +1,18 @@ +// +// APIRegistrationApplication.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// RegistrationApplication.ts +struct APIRegistrationApplication: Decodable, Hashable { + let id: Int + let localUserId: Int + let answer: String + let adminId: Int? + let denyReason: String? + let published: Date +} diff --git a/Mlem/API/Models/Site/APIRegistrationApplicationView.swift b/Mlem/API/Models/Site/APIRegistrationApplicationView.swift new file mode 100644 index 000000000..afd91f3ef --- /dev/null +++ b/Mlem/API/Models/Site/APIRegistrationApplicationView.swift @@ -0,0 +1,16 @@ +// +// APIRegistrationApplicationView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// RegistrationApplicationView.ts +struct APIRegistrationApplicationView: Decodable { + let registrationApplication: APIRegistrationApplication + let creatorLocalUser: APILocalUser + let creator: APIPerson + let admin: APIPerson? +} diff --git a/Mlem/API/Models/Site/Modlog/APIAdminPurgeComment.swift b/Mlem/API/Models/Site/Modlog/APIAdminPurgeComment.swift new file mode 100644 index 000000000..ea892f4a5 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIAdminPurgeComment.swift @@ -0,0 +1,17 @@ +// +// APIAdminPurgeComment.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// AdminPurgeComment.ts +struct APIAdminPurgeComment: Decodable { + let id: Int + let adminPersonId: Int + let postId: Int + let reason: String? + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIAdminPurgeCommentView.swift b/Mlem/API/Models/Site/Modlog/APIAdminPurgeCommentView.swift new file mode 100644 index 000000000..042fb24ad --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIAdminPurgeCommentView.swift @@ -0,0 +1,15 @@ +// +// APIAdminPurgeCommentView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// AdminPurgeCommentView.ts +struct APIAdminPurgeCommentView: Decodable { + let adminPurgeComment: APIAdminPurgeComment + let admin: APIPerson? + let post: APIPost +} diff --git a/Mlem/API/Models/Site/Modlog/APIAdminPurgeCommunity.swift b/Mlem/API/Models/Site/Modlog/APIAdminPurgeCommunity.swift new file mode 100644 index 000000000..47c2939bd --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIAdminPurgeCommunity.swift @@ -0,0 +1,16 @@ +// +// APIAdminPurgeCommunity.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// AdminPurgeCommunity.ts +struct APIAdminPurgeCommunity: Decodable { + let id: Int + let adminPersonId: Int + let reason: String? + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIAdminPurgeCommunityView.swift b/Mlem/API/Models/Site/Modlog/APIAdminPurgeCommunityView.swift new file mode 100644 index 000000000..1e1e1ef69 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIAdminPurgeCommunityView.swift @@ -0,0 +1,14 @@ +// +// APIAdminPurgeCommunityView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// AdminPurgeCommunityView.ts +struct APIAdminPurgeCommunityView: Decodable { + let adminPurgeCommunity: APIAdminPurgeCommunity + let admin: APIPerson? +} diff --git a/Mlem/API/Models/Site/Modlog/APIAdminPurgePerson.swift b/Mlem/API/Models/Site/Modlog/APIAdminPurgePerson.swift new file mode 100644 index 000000000..ff1b684e6 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIAdminPurgePerson.swift @@ -0,0 +1,16 @@ +// +// APIAdminPurgePerson.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// AdminPurgePerson.ts +struct APIAdminPurgePerson: Decodable { + let id: Int + let adminPersonId: Int + let reason: String? + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIAdminPurgePersonView.swift b/Mlem/API/Models/Site/Modlog/APIAdminPurgePersonView.swift new file mode 100644 index 000000000..6acd68676 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIAdminPurgePersonView.swift @@ -0,0 +1,14 @@ +// +// APIAdminPurgePersonView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// AdminPurgePersonView.ts +struct APIAdminPurgePersonView: Decodable { + let adminPurgePerson: APIAdminPurgePerson + let admin: APIPerson? +} diff --git a/Mlem/API/Models/Site/Modlog/APIAdminPurgePost.swift b/Mlem/API/Models/Site/Modlog/APIAdminPurgePost.swift new file mode 100644 index 000000000..c292b8423 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIAdminPurgePost.swift @@ -0,0 +1,17 @@ +// +// APIAdminPurgePost.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// AdminPurgePost.ts +struct APIAdminPurgePost: Decodable { + let id: Int + let adminPersonId: Int + let communityId: Int + let reason: String? + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIAdminPurgePostView.swift b/Mlem/API/Models/Site/Modlog/APIAdminPurgePostView.swift new file mode 100644 index 000000000..1265580e9 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIAdminPurgePostView.swift @@ -0,0 +1,15 @@ +// +// APIAdminPurgePostView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// AdminPurgePostView.ts +struct APIAdminPurgePostView: Decodable { + let adminPurgePost: APIAdminPurgePost + let admin: APIPerson? + let community: APICommunity +} diff --git a/Mlem/API/Models/Site/Modlog/APIModAdd.swift b/Mlem/API/Models/Site/Modlog/APIModAdd.swift new file mode 100644 index 000000000..c321df8a0 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModAdd.swift @@ -0,0 +1,17 @@ +// +// APIModAdd.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModAdd.ts +struct APIModAdd: Decodable { + let id: Int + let modPersonId: Int + let otherPersonId: Int + let removed: Bool + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIModAddCommunity.swift b/Mlem/API/Models/Site/Modlog/APIModAddCommunity.swift new file mode 100644 index 000000000..41bb5e2e3 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModAddCommunity.swift @@ -0,0 +1,18 @@ +// +// APIModAddCommunity.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModAddCommunity.ts +struct APIModAddCommunity: Decodable { + let id: Int + let modPersonId: Int + let otherPersonId: Int + let communityId: Int + let removed: Bool + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIModAddCommunityView.swift b/Mlem/API/Models/Site/Modlog/APIModAddCommunityView.swift new file mode 100644 index 000000000..cd534abcf --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModAddCommunityView.swift @@ -0,0 +1,16 @@ +// +// APIModAddCommunityView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModAddCommunityView.ts +struct APIModAddCommunityView: Decodable { + let modAddCommunity: APIModAddCommunity + let moderator: APIPerson? + let community: APICommunity + let moddedPerson: APIPerson +} diff --git a/Mlem/API/Models/Site/Modlog/APIModAddView.swift b/Mlem/API/Models/Site/Modlog/APIModAddView.swift new file mode 100644 index 000000000..a65cd215e --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModAddView.swift @@ -0,0 +1,15 @@ +// +// APIModAddView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModAddView.ts +struct APIModAddView: Decodable { + let modAdd: APIModAdd + let moderator: APIPerson? + let moddedPerson: APIPerson +} diff --git a/Mlem/API/Models/Site/Modlog/APIModBan.swift b/Mlem/API/Models/Site/Modlog/APIModBan.swift new file mode 100644 index 000000000..5f2267758 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModBan.swift @@ -0,0 +1,19 @@ +// +// APIModBan.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModBan.ts +struct APIModBan: Decodable { + let id: Int + let modPersonId: Int + let otherPersonId: Int + let reason: String? + let banned: Bool + let expires: Date? + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIModBanFromCommunity.swift b/Mlem/API/Models/Site/Modlog/APIModBanFromCommunity.swift new file mode 100644 index 000000000..a34b2ec2c --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModBanFromCommunity.swift @@ -0,0 +1,20 @@ +// +// APIModBanFromCommunity.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModBanFromCommunity.ts +struct APIModBanFromCommunity: Decodable { + let id: Int + let modPersonId: Int + let otherPersonId: Int + let communityId: Int + let reason: String? + let banned: Bool + let expires: Date? + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIModBanFromCommunityView.swift b/Mlem/API/Models/Site/Modlog/APIModBanFromCommunityView.swift new file mode 100644 index 000000000..38cbdefc1 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModBanFromCommunityView.swift @@ -0,0 +1,16 @@ +// +// APIModBanFromCommunityView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModBanFromCommunityView.ts +struct APIModBanFromCommunityView: Decodable { + let modBanFromCommunity: APIModBanFromCommunity + let moderator: APIPerson? + let community: APICommunity + let bannedPerson: APIPerson +} diff --git a/Mlem/API/Models/Site/Modlog/APIModBanView.swift b/Mlem/API/Models/Site/Modlog/APIModBanView.swift new file mode 100644 index 000000000..05871fcfd --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModBanView.swift @@ -0,0 +1,15 @@ +// +// APIModBanView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModBanView.ts +struct APIModBanView: Decodable { + let modBan: APIModBan + let moderator: APIPerson? + let bannedPerson: APIPerson +} diff --git a/Mlem/API/Models/Site/Modlog/APIModFeaturePost.swift b/Mlem/API/Models/Site/Modlog/APIModFeaturePost.swift new file mode 100644 index 000000000..2e6594a43 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModFeaturePost.swift @@ -0,0 +1,18 @@ +// +// APIModFeaturePost.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModFeaturePost.ts +struct APIModFeaturePost: Decodable { + let id: Int + let modPersonId: Int + let postId: Int + let featured: Bool + let when_: Date + let isFeaturedCommunity: Bool +} diff --git a/Mlem/API/Models/Site/Modlog/APIModFeaturePostView.swift b/Mlem/API/Models/Site/Modlog/APIModFeaturePostView.swift new file mode 100644 index 000000000..606edc346 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModFeaturePostView.swift @@ -0,0 +1,16 @@ +// +// APIModFeaturePostView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModFeaturePostView.ts +struct APIModFeaturePostView: Decodable { + let modFeaturePost: APIModFeaturePost + let moderator: APIPerson? + let post: APIPost + let community: APICommunity +} diff --git a/Mlem/API/Models/Site/Modlog/APIModHideCommunityView.swift b/Mlem/API/Models/Site/Modlog/APIModHideCommunityView.swift new file mode 100644 index 000000000..4f53e48a3 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModHideCommunityView.swift @@ -0,0 +1,15 @@ +// +// APIModHideCommunityView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModHideCommunityView.ts +struct APIModHideCommunityView: Decodable { + let modHideCommunity: APIModHideCommunity + let admin: APIPerson? + let community: APICommunity +} diff --git a/Mlem/API/Models/Site/Modlog/APIModLockPost.swift b/Mlem/API/Models/Site/Modlog/APIModLockPost.swift new file mode 100644 index 000000000..3ddd8ff0b --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModLockPost.swift @@ -0,0 +1,17 @@ +// +// APIModLockPost.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModLockPost.ts +struct APIModLockPost: Decodable { + let id: Int + let modPersonId: Int + let postId: Int + let locked: Bool + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIModLockPostView.swift b/Mlem/API/Models/Site/Modlog/APIModLockPostView.swift new file mode 100644 index 000000000..0461414ee --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModLockPostView.swift @@ -0,0 +1,16 @@ +// +// APIModLockPostView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModLockPostView.ts +struct APIModLockPostView: Decodable { + let modLockPost: APIModLockPost + let moderator: APIPerson? + let post: APIPost + let community: APICommunity +} diff --git a/Mlem/API/Models/Site/Modlog/APIModRemoveComment.swift b/Mlem/API/Models/Site/Modlog/APIModRemoveComment.swift new file mode 100644 index 000000000..654c2cc9d --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModRemoveComment.swift @@ -0,0 +1,18 @@ +// +// APIModRemoveComment.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModRemoveComment.ts +struct APIModRemoveComment: Decodable { + let id: Int + let modPersonId: Int + let commentId: Int + let reason: String? + let removed: Bool + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIModRemoveCommentView.swift b/Mlem/API/Models/Site/Modlog/APIModRemoveCommentView.swift new file mode 100644 index 000000000..afc163d28 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModRemoveCommentView.swift @@ -0,0 +1,18 @@ +// +// APIModRemoveCommentView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModRemoveCommentView.ts +struct APIModRemoveCommentView: Decodable { + let modRemoveComment: APIModRemoveComment + let moderator: APIPerson? + let comment: APIComment + let commenter: APIPerson + let post: APIPost + let community: APICommunity +} diff --git a/Mlem/API/Models/Site/Modlog/APIModRemoveCommunity.swift b/Mlem/API/Models/Site/Modlog/APIModRemoveCommunity.swift new file mode 100644 index 000000000..b82f6a4c5 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModRemoveCommunity.swift @@ -0,0 +1,19 @@ +// +// APIModRemoveCommunity.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModRemoveCommunity.ts +struct APIModRemoveCommunity: Codable { + let id: Int + let modPersonId: Int + let communityId: Int + let reason: String? + let removed: Bool + let expires: String? + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIModRemoveCommunityView.swift b/Mlem/API/Models/Site/Modlog/APIModRemoveCommunityView.swift new file mode 100644 index 000000000..800a488b7 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModRemoveCommunityView.swift @@ -0,0 +1,15 @@ +// +// APIModRemoveCommunityView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModRemoveCommunityView.ts +struct APIModRemoveCommunityView: Decodable { + let modRemoveCommunity: APIModRemoveCommunity + let moderator: APIPerson? + let community: APICommunity +} diff --git a/Mlem/API/Models/Site/Modlog/APIModRemovePost.swift b/Mlem/API/Models/Site/Modlog/APIModRemovePost.swift new file mode 100644 index 000000000..3a3586c77 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModRemovePost.swift @@ -0,0 +1,18 @@ +// +// APIModRemovePost.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModRemovePost.ts +struct APIModRemovePost: Decodable { + let id: Int + let modPersonId: Int + let postId: Int + let reason: String? + let removed: Bool + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIModRemovePostView.swift b/Mlem/API/Models/Site/Modlog/APIModRemovePostView.swift new file mode 100644 index 000000000..43dbbce3e --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModRemovePostView.swift @@ -0,0 +1,16 @@ +// +// APIModRemovePostView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModRemovePostView.ts +struct APIModRemovePostView: Decodable { + let modRemovePost: APIModRemovePost + let moderator: APIPerson? + let post: APIPost + let community: APICommunity +} diff --git a/Mlem/API/Models/Site/Modlog/APIModTransferCommunity.swift b/Mlem/API/Models/Site/Modlog/APIModTransferCommunity.swift new file mode 100644 index 000000000..8c95903f3 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModTransferCommunity.swift @@ -0,0 +1,17 @@ +// +// APIModTransferCommunity.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModTransferCommunity.ts +struct APIModTransferCommunity: Decodable { + let id: Int + let modPersonId: Int + let otherPersonId: Int + let communityId: Int + let when_: Date +} diff --git a/Mlem/API/Models/Site/Modlog/APIModTransferCommunityView.swift b/Mlem/API/Models/Site/Modlog/APIModTransferCommunityView.swift new file mode 100644 index 000000000..2d0bce122 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModTransferCommunityView.swift @@ -0,0 +1,16 @@ +// +// APIModTransferCommunityView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModTransferCommunityView.ts +struct APIModTransferCommunityView: Decodable { + let modTransferCommunity: APIModTransferCommunity + let moderator: APIPerson? + let community: APICommunity + let moddedPerson: APIPerson +} diff --git a/Mlem/API/Models/Site/Modlog/APIModlogActionType.swift b/Mlem/API/Models/Site/Modlog/APIModlogActionType.swift new file mode 100644 index 000000000..e31c79a93 --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/APIModlogActionType.swift @@ -0,0 +1,28 @@ +// +// APIModlogActionType.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModlogActionType.ts +enum APIModlogActionType: String, Decodable { + case all = "All" + case modRemovePost = "ModRemovePost" + case modLockPost = "ModLockPost" + case modFeaturePost = "ModFeaturePost" + case modRemoveComment = "ModRemoveComment" + case modRemoveCommunity = "ModRemoveCommunity" + case modBanFromCommunity = "ModBanFromCommunity" + case modAddCommunity = "ModAddCommunity" + case modTransferCommunity = "ModTransferCommunity" + case modAdd = "ModAdd" + case modBan = "ModBan" + case modHideCommunity = "ModHideCommunity" + case adminPurgePerson = "AdminPurgePerson" + case adminPurgeCommunity = "AdminPurgeCommunity" + case adminPurgePost = "AdminPurgePost" + case adminPurgeComment = "AdminPurgeComment" +} diff --git a/Mlem/API/Models/Site/Modlog/ApiModHideCommunity.swift b/Mlem/API/Models/Site/Modlog/ApiModHideCommunity.swift new file mode 100644 index 000000000..0ec85115d --- /dev/null +++ b/Mlem/API/Models/Site/Modlog/ApiModHideCommunity.swift @@ -0,0 +1,18 @@ +// +// APIModHideCommunity.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// ModHideCommunity.ts +struct APIModHideCommunity: Codable { + let id: Int + let communityId: Int + let modPersonId: Int + let when_: Date + let reason: String? + let hidden: Bool +} diff --git a/Mlem/API/Requests/Comment/ListCommentLikesRequest.swift b/Mlem/API/Requests/Comment/ListCommentLikesRequest.swift new file mode 100644 index 000000000..f05898eb5 --- /dev/null +++ b/Mlem/API/Requests/Comment/ListCommentLikesRequest.swift @@ -0,0 +1,35 @@ +// +// ListCommentLikesRequest.swift +// Mlem +// +// Created by Sjmarf on 25/03/2024. +// + +import Foundation + +struct ListCommentLikesRequest: APIGetRequest { + typealias Response = APIListCommentLikesResponse + + var instanceURL: URL + let path = "comment/like/list" + let queryItems: [URLQueryItem] + + init( + session: APISession, + commentId: Int, + page: Int?, + limit: Int? + ) throws { + self.instanceURL = try session.instanceUrl + self.queryItems = try [ + .init(name: "auth", value: session.token), + .init(name: "comment_id", value: String(commentId)), + .init(name: "page", value: page.map(String.init)), + .init(name: "limit", value: limit.map(String.init)) + ] + } +} + +struct APIListCommentLikesResponse: Decodable { + let commentLikes: [APIVoteView] +} diff --git a/Mlem/API/Requests/Comment/PurgeCommentRequest.swift b/Mlem/API/Requests/Comment/PurgeCommentRequest.swift new file mode 100644 index 000000000..ff8f56b2b --- /dev/null +++ b/Mlem/API/Requests/Comment/PurgeCommentRequest.swift @@ -0,0 +1,35 @@ +// +// PurgeCommentRequest.swift +// Mlem +// +// Created by Sjmarf on 22/03/2024. +// + +import Foundation + +struct PurgeCommentRequest: APIPostRequest { + typealias Response = SuccessResponse + + var instanceURL: URL + let path = "admin/purge/comment" + let body: Body + + struct Body: Codable { + let comment_id: Int + let reason: String? + let auth: String + } + + init( + session: APISession, + commentId: Int, + reason: String? + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + comment_id: commentId, + reason: reason, + auth: session.token + ) + } +} diff --git a/Mlem/API/Requests/Comment/RemoveCommentRequest.swift b/Mlem/API/Requests/Comment/RemoveCommentRequest.swift new file mode 100644 index 000000000..ac63a730f --- /dev/null +++ b/Mlem/API/Requests/Comment/RemoveCommentRequest.swift @@ -0,0 +1,38 @@ +// +// RemoveCommentRequest.swift +// Mlem +// +// Created by Sjmarf on 22/03/2024. +// + +import Foundation + +struct RemoveCommentRequest: APIPostRequest { + typealias Response = CommentResponse + + var instanceURL: URL + let path = "comment/remove" + let body: Body + + struct Body: Codable { + let comment_id: Int + let removed: Bool + let reason: String? + let auth: String + } + + init( + session: APISession, + commentId: Int, + removed: Bool, + reason: String? + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + comment_id: commentId, + removed: removed, + reason: reason, + auth: session.token + ) + } +} diff --git a/Mlem/API/Requests/Community/AddModToCommunity.swift b/Mlem/API/Requests/Community/AddModToCommunity.swift new file mode 100644 index 000000000..07a6a25ea --- /dev/null +++ b/Mlem/API/Requests/Community/AddModToCommunity.swift @@ -0,0 +1,42 @@ +// +// AddModToCommunity.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-17. +// + +import Foundation + +struct AddModToCommunityRequest: APIPostRequest { + typealias Response = AddModToCommunityResponse + + let instanceURL: URL + let path = "community/mod" + let body: Body + + struct Body: Encodable { + let community_id: Int + let person_id: Int + let added: Bool + let auth: String + } + + init( + session: APISession, + communityId: Int, + personId: Int, + added: Bool + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + community_id: communityId, + person_id: personId, + added: added, + auth: session.token + ) + } +} + +struct AddModToCommunityResponse: Decodable { + let moderators: [APICommunityModeratorView] +} diff --git a/Mlem/API/Requests/Community/BanFromCommunity.swift b/Mlem/API/Requests/Community/BanFromCommunity.swift new file mode 100644 index 000000000..df21c1c6c --- /dev/null +++ b/Mlem/API/Requests/Community/BanFromCommunity.swift @@ -0,0 +1,52 @@ +// +// BanFromCommunity.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-15. +// + +import Foundation + +struct BanFromCommunityRequest: APIPostRequest { + typealias Response = BanFromCommunityResponse + + let instanceURL: URL + let path = "community/ban_user" + let body: Body + + struct Body: Encodable { + let community_id: Int + let person_id: Int + let ban: Bool + let remove_data: Bool? + let reason: String? + let expires: Int? + let auth: String + } + + init( + session: APISession, + communityId: Int, + personId: Int, + ban: Bool, + removeData: Bool?, + reason: String?, + expires: Int? + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + community_id: communityId, + person_id: personId, + ban: ban, + remove_data: removeData, + reason: reason, + expires: expires, + auth: session.token + ) + } +} + +struct BanFromCommunityResponse: Decodable { + let personView: APIPersonView + let banned: Bool +} diff --git a/Mlem/API/Requests/Community/ListCommunities.swift b/Mlem/API/Requests/Community/ListCommunities.swift index e30536f10..3fb3cf5b7 100644 --- a/Mlem/API/Requests/Community/ListCommunities.swift +++ b/Mlem/API/Requests/Community/ListCommunities.swift @@ -24,7 +24,7 @@ struct ListCommunitiesRequest: APIGetRequest { ) throws { self.instanceURL = try session.instanceUrl var queryItems: [URLQueryItem] = [ - .init(name: "sort", value: sort), + .init(name: "sort", value: sort ?? "Old"), // provide explicit sort if not provided to ensure consistent pagination .init(name: "limit", value: limit?.description), .init(name: "page", value: page?.description), .init(name: "type_", value: type) diff --git a/Mlem/API/Requests/Community/PurgeCommunityRequest.swift b/Mlem/API/Requests/Community/PurgeCommunityRequest.swift new file mode 100644 index 000000000..d68d5138d --- /dev/null +++ b/Mlem/API/Requests/Community/PurgeCommunityRequest.swift @@ -0,0 +1,35 @@ +// +// PurgePostRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct PurgeCommunityRequest: APIPostRequest { + typealias Response = SuccessResponse + + var instanceURL: URL + let path = "admin/purge/community" + let body: Body + + struct Body: Codable { + let community_id: Int + let reason: String? + let auth: String + } + + init( + session: APISession, + communityId: Int, + reason: String? + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + community_id: communityId, + reason: reason, + auth: session.token + ) + } +} diff --git a/Mlem/API/Requests/Community/RemoveCommunityRequest.swift b/Mlem/API/Requests/Community/RemoveCommunityRequest.swift new file mode 100644 index 000000000..cb6e4c82b --- /dev/null +++ b/Mlem/API/Requests/Community/RemoveCommunityRequest.swift @@ -0,0 +1,38 @@ +// +// RemoveCommunityRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct RemoveCommunityRequest: APIPostRequest { + typealias Response = CommunityResponse + + struct Body: Codable { + let community_id: Int + let removed: Bool + let reason: String? + let auth: String + } + + let instanceURL: URL + let path = "community/remove" + let body: Body + + init( + session: APISession, + communityId: Int, + removed: Bool, + reason: String? + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + community_id: communityId, + removed: removed, + reason: reason, + auth: session.token + ) + } +} diff --git a/Mlem/API/Requests/Moderation/ApproveRegistrationApplicationRequest.swift b/Mlem/API/Requests/Moderation/ApproveRegistrationApplicationRequest.swift new file mode 100644 index 000000000..a301b8703 --- /dev/null +++ b/Mlem/API/Requests/Moderation/ApproveRegistrationApplicationRequest.swift @@ -0,0 +1,42 @@ +// +// ApproveRegistrationApplicationRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct ApproveRegistrationApplicationRequest: APIPutRequest { + typealias Response = APIRegistrationApplicationResponse + + let instanceURL: URL + let path = "admin/registration_application/approve" + let body: Body + + struct Body: Encodable { + let id: Int + let approve: Bool + let deny_reason: String? + let auth: String + } + + init( + session: APISession, + id: Int, + approve: Bool, + denyReason: String? + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + id: id, + approve: approve, + deny_reason: denyReason, + auth: session.token + ) + } +} + +struct APIRegistrationApplicationResponse: Decodable { + let registrationApplication: APIRegistrationApplicationView +} diff --git a/Mlem/API/Requests/Moderation/GetReportCountRequest.swift b/Mlem/API/Requests/Moderation/GetReportCountRequest.swift new file mode 100644 index 000000000..01ac04078 --- /dev/null +++ b/Mlem/API/Requests/Moderation/GetReportCountRequest.swift @@ -0,0 +1,35 @@ +// +// GetReportCountRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct GetReportCountRequest: APIGetRequest { + typealias Response = APIGetReportCountResponse + + let instanceURL: URL + let path = "user/report_count" + let queryItems: [URLQueryItem] + + init( + session: APISession, + communityId: Int? + ) throws { + self.instanceURL = try session.instanceUrl + self.queryItems = try [ + .init(name: "community_id", value: communityId.map(String.init)), + .init(name: "auth", value: session.token) + ] + } +} + +// GetReportCountResponse.ts +struct APIGetReportCountResponse: Decodable { + let communityId: Int? + let commentReports: Int + let postReports: Int + let privateMessageReports: Int? +} diff --git a/Mlem/API/Requests/Moderation/GetUnreadRegistrationApplicationCountRequest.swift b/Mlem/API/Requests/Moderation/GetUnreadRegistrationApplicationCountRequest.swift new file mode 100644 index 000000000..d61caa9e3 --- /dev/null +++ b/Mlem/API/Requests/Moderation/GetUnreadRegistrationApplicationCountRequest.swift @@ -0,0 +1,30 @@ +// +// GetUnreadRegistrationApplicationCountRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +// swiftlint:disable:next type_name +struct GetUnreadRegistrationApplicationCountRequest: APIGetRequest { + typealias Response = APIGetUnreadRegistrationApplicationCountResponse + + let instanceURL: URL + let path = "admin/registration_application/count" + let queryItems: [URLQueryItem] + + init(session: APISession) throws { + self.instanceURL = try session.instanceUrl + self.queryItems = try [ + .init(name: "auth", value: session.token) + ] + } +} + +// GetUnreadRegistrationApplicationCountResponse.ts +// swiftlint:disable:next type_name +struct APIGetUnreadRegistrationApplicationCountResponse: Decodable { + let registrationApplications: Int +} diff --git a/Mlem/API/Requests/Moderation/ListCommentReportsRequest.swift b/Mlem/API/Requests/Moderation/ListCommentReportsRequest.swift new file mode 100644 index 000000000..a447ae3fd --- /dev/null +++ b/Mlem/API/Requests/Moderation/ListCommentReportsRequest.swift @@ -0,0 +1,37 @@ +// +// ListCommentReportsRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct ListCommentReportsRequest: APIGetRequest { + typealias Response = APIListCommentReportsResponse + + let instanceURL: URL + let path = "comment/report/list" + let queryItems: [URLQueryItem] + + init( + session: APISession, + page: Int?, + limit: Int?, + unresolvedOnly: Bool?, + communityId: Int? + ) throws { + self.instanceURL = try session.instanceUrl + self.queryItems = try [ + .init(name: "page", value: page.map(String.init)), + .init(name: "limit", value: limit.map(String.init)), + .init(name: "unresolved_only", value: unresolvedOnly.map(String.init)), + .init(name: "community_id", value: communityId.map(String.init)), + .init(name: "auth", value: session.token) + ] + } +} + +struct APIListCommentReportsResponse: Decodable { + let commentReports: [APICommentReportView] +} diff --git a/Mlem/API/Requests/Moderation/ListPostReportsRequest.swift b/Mlem/API/Requests/Moderation/ListPostReportsRequest.swift new file mode 100644 index 000000000..c6fece037 --- /dev/null +++ b/Mlem/API/Requests/Moderation/ListPostReportsRequest.swift @@ -0,0 +1,37 @@ +// +// ListPostReportsRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct ListPostReportsRequest: APIGetRequest { + typealias Response = APIListPostReportsResponse + + let instanceURL: URL + let path = "post/report/list" + let queryItems: [URLQueryItem] + + init( + session: APISession, + page: Int?, + limit: Int?, + unresolvedOnly: Bool?, + communityId: Int? + ) throws { + self.instanceURL = try session.instanceUrl + self.queryItems = try [ + .init(name: "page", value: page.map(String.init)), + .init(name: "limit", value: limit.map(String.init)), + .init(name: "unresolved_only", value: unresolvedOnly.map(String.init)), + .init(name: "community_id", value: communityId.map(String.init)), + .init(name: "auth", value: session.token) + ] + } +} + +struct APIListPostReportsResponse: Decodable { + let postReports: [APIPostReportView] +} diff --git a/Mlem/API/Requests/Moderation/ListPrivateMessageReportsRequest.swift b/Mlem/API/Requests/Moderation/ListPrivateMessageReportsRequest.swift new file mode 100644 index 000000000..2130174de --- /dev/null +++ b/Mlem/API/Requests/Moderation/ListPrivateMessageReportsRequest.swift @@ -0,0 +1,35 @@ +// +// ListPrivateMessageReportsRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct ListPrivateMessageReportsRequest: APIGetRequest { + typealias Response = APIListPrivateMessageReportsResponse + + let instanceURL: URL + let path = "private_message/report/list" + let queryItems: [URLQueryItem] + + init( + session: APISession, + page: Int?, + limit: Int?, + unresolvedOnly: Bool? + ) throws { + self.instanceURL = try session.instanceUrl + self.queryItems = try [ + .init(name: "page", value: page.map(String.init)), + .init(name: "limit", value: limit.map(String.init)), + .init(name: "unresolved_only", value: unresolvedOnly.map(String.init)), + .init(name: "auth", value: session.token) + ] + } +} + +struct APIListPrivateMessageReportsResponse: Decodable { + let privateMessageReports: [APIPrivateMessageReportView] +} diff --git a/Mlem/API/Requests/Moderation/ListRegistrationApplicationsRequest.swift b/Mlem/API/Requests/Moderation/ListRegistrationApplicationsRequest.swift new file mode 100644 index 000000000..f6b12335a --- /dev/null +++ b/Mlem/API/Requests/Moderation/ListRegistrationApplicationsRequest.swift @@ -0,0 +1,35 @@ +// +// ListRegistrationApplicationsRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct ListRegistrationApplicationsRequest: APIGetRequest { + typealias Response = APIListRegistrationApplicationsResponse + + let instanceURL: URL + let path = "admin/registration_application/list" + let queryItems: [URLQueryItem] + + init( + session: APISession, + unreadOnly: Bool?, + page: Int?, + limit: Int? + ) throws { + self.instanceURL = try session.instanceUrl + self.queryItems = try [ + .init(name: "unread_only", value: unreadOnly.map(String.init)), + .init(name: "page", value: page.map(String.init)), + .init(name: "limit", value: limit.map(String.init)), + .init(name: "auth", value: session.token) + ] + } +} + +struct APIListRegistrationApplicationsResponse: Decodable { + let registrationApplications: [APIRegistrationApplicationView] +} diff --git a/Mlem/API/Requests/Moderation/ResolveCommentReportRequest.swift b/Mlem/API/Requests/Moderation/ResolveCommentReportRequest.swift new file mode 100644 index 000000000..1566aec1c --- /dev/null +++ b/Mlem/API/Requests/Moderation/ResolveCommentReportRequest.swift @@ -0,0 +1,39 @@ +// +// ResolveCommentReportRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct ResolveCommentReportRequest: APIPutRequest { + typealias Response = APICommentReportResponse + + let instanceURL: URL + let path = "comment/report/resolve" + let body: Body + + struct Body: Encodable { + let auth: String + let report_id: Int + let resolved: Bool + } + + init( + session: APISession, + reportId: Int, + resolved: Bool + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + auth: session.token, + report_id: reportId, + resolved: resolved + ) + } +} + +struct APICommentReportResponse: Decodable { + let commentReportView: APICommentReportView +} diff --git a/Mlem/API/Requests/Moderation/ResolvePostReportRequest.swift b/Mlem/API/Requests/Moderation/ResolvePostReportRequest.swift new file mode 100644 index 000000000..7f3bc985f --- /dev/null +++ b/Mlem/API/Requests/Moderation/ResolvePostReportRequest.swift @@ -0,0 +1,39 @@ +// +// ResolvePostReportRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct ResolvePostReportRequest: APIPutRequest { + typealias Response = APIPostReportResponse + + let instanceURL: URL + let path = "post/report/resolve" + let body: Body + + struct Body: Encodable { + let auth: String + let report_id: Int + let resolved: Bool + } + + init( + session: APISession, + reportId: Int, + resolved: Bool + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + auth: session.token, + report_id: reportId, + resolved: resolved + ) + } +} + +struct APIPostReportResponse: Decodable { + let postReportView: APIPostReportView +} diff --git a/Mlem/API/Requests/Moderation/ResolvePrivateMessageReportRequest.swift b/Mlem/API/Requests/Moderation/ResolvePrivateMessageReportRequest.swift new file mode 100644 index 000000000..addf85c06 --- /dev/null +++ b/Mlem/API/Requests/Moderation/ResolvePrivateMessageReportRequest.swift @@ -0,0 +1,39 @@ +// +// ResolvePrivateMessageReportRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct ResolvePrivateMessageReportRequest: APIPutRequest { + typealias Response = APIPrivateMessageReportResponse + + let instanceURL: URL + let path = "private_message/report/resolve" + let body: Body + + struct Body: Encodable { + let auth: String + let report_id: Int + let resolved: Bool + } + + init( + session: APISession, + reportId: Int, + resolved: Bool + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + auth: session.token, + report_id: reportId, + resolved: resolved + ) + } +} + +struct APIPrivateMessageReportResponse: Decodable { + let privateMessageReportView: APIPrivateMessageReportView +} diff --git a/Mlem/API/Requests/Person/BanPerson.swift b/Mlem/API/Requests/Person/BanPerson.swift new file mode 100644 index 000000000..41d1a0688 --- /dev/null +++ b/Mlem/API/Requests/Person/BanPerson.swift @@ -0,0 +1,50 @@ +// +// BanPerson.swift +// Mlem +// +// Created by Sjmarf on 27/01/2024. +// + +import Foundation + +struct BanPersonRequest: APIPostRequest { + typealias Response = BanPersonResponse + + let instanceURL: URL + let path = "user/ban" + let body: Body + + struct Body: Encodable { + let personId: Int + let ban: Bool + let expires: Int? + let reason: String? + let removeData: Bool + let auth: String + } + + init( + session: APISession, + personId: Int, + ban: Bool, + expires: Int?, + reason: String?, + removeData: Bool + ) throws { + self.instanceURL = try session.instanceUrl + + self.body = try .init( + personId: personId, + ban: ban, + expires: expires, + reason: reason, + removeData: removeData, + auth: session.token + ) + } +} + +struct BanPersonResponse: Decodable { + let banned: Bool + let personView: APIPersonView +} diff --git a/Mlem/API/Requests/Person/PurgePersonRequest.swift b/Mlem/API/Requests/Person/PurgePersonRequest.swift new file mode 100644 index 000000000..d60018ba4 --- /dev/null +++ b/Mlem/API/Requests/Person/PurgePersonRequest.swift @@ -0,0 +1,33 @@ +// +// PurgePersonRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct PurgePersonRequest: APIPostRequest { + typealias Response = SuccessResponse + + var instanceURL: URL + let path = "admin/purge/person" + let body: Body + + struct Body: Codable { + let person_id: Int + let reason: String? + } + + init( + session: APISession, + personId: Int, + reason: String? + ) throws { + self.instanceURL = try session.instanceUrl + self.body = .init( + person_id: personId, + reason: reason + ) + } +} diff --git a/Mlem/API/Requests/Post/DeletePictrsFile.swift b/Mlem/API/Requests/Post/DeletePictrsFile.swift index fbd486fe4..ca5834c27 100644 --- a/Mlem/API/Requests/Post/DeletePictrsFile.swift +++ b/Mlem/API/Requests/Post/DeletePictrsFile.swift @@ -7,7 +7,7 @@ import Foundation -struct ImageDeleteRequest: APIRequest { +struct ImageDeleteRequest: APIDeleteRequest { var path: String var instanceURL: URL @@ -33,4 +33,4 @@ struct ImageDeleteRequest: APIRequest { } } -struct ImageDeleteResponse: Decodable { } +struct ImageDeleteResponse: Decodable {} diff --git a/Mlem/API/Requests/Post/FeaturePostRequest.swift b/Mlem/API/Requests/Post/FeaturePostRequest.swift new file mode 100644 index 000000000..753ba1dee --- /dev/null +++ b/Mlem/API/Requests/Post/FeaturePostRequest.swift @@ -0,0 +1,39 @@ +// +// FeaturePostRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-25 +// + +import Foundation + +struct FeaturePostRequest: APIPostRequest { + typealias Response = APIPostResponse + + var instanceURL: URL + let path = "post/feature" + let body: Body + + struct Body: Encodable { + let post_id: Int + let featured: Bool + let feature_type: String + let auth: String + } + + init( + session: APISession, + postId: Int, + featured: Bool, + featureType: APIPostFeatureType + ) throws { + self.instanceURL = try session.instanceUrl + + self.body = try .init( + post_id: postId, + featured: featured, + feature_type: featureType.rawValue, + auth: session.token + ) + } +} diff --git a/Mlem/API/Requests/Post/ListPostLikesRequest.swift b/Mlem/API/Requests/Post/ListPostLikesRequest.swift new file mode 100644 index 000000000..2d58c1029 --- /dev/null +++ b/Mlem/API/Requests/Post/ListPostLikesRequest.swift @@ -0,0 +1,44 @@ +// +// ListPostLikesRequest.swift +// Mlem +// +// Created by Sjmarf on 22/03/2024. +// + +import Foundation + +struct ListPostLikesRequest: APIGetRequest { + typealias Response = APIListPostLikesResponse + + var instanceURL: URL + let path = "post/like/list" + let queryItems: [URLQueryItem] + + init( + session: APISession, + postId: Int, + page: Int?, + limit: Int? + ) throws { + self.instanceURL = try session.instanceUrl + self.queryItems = try [ + .init(name: "auth", value: session.token), + .init(name: "post_id", value: String(postId)), + .init(name: "page", value: page.map(String.init)), + .init(name: "limit", value: limit.map(String.init)) + ] + } +} + +struct APIVoteView: Decodable { + let creator: APIPerson + + // Not in a live version yet as of the time of writing, but was merged in https://github.com/LemmyNet/lemmy/pull/4568 + let creatorBannedFromCommunity: Bool? + + let score: ScoringOperation +} + +struct APIListPostLikesResponse: Decodable { + let postLikes: [APIVoteView] +} diff --git a/Mlem/API/Requests/Post/LockPostRequest.swift b/Mlem/API/Requests/Post/LockPostRequest.swift new file mode 100644 index 000000000..2de37de33 --- /dev/null +++ b/Mlem/API/Requests/Post/LockPostRequest.swift @@ -0,0 +1,35 @@ +// +// LockPostRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct LockPostRequest: APIPostRequest { + typealias Response = APIPostResponse + + var instanceURL: URL + let path = "post/lock" + let body: Body + + struct Body: Codable { + let post_id: Int + let locked: Bool + let auth: String + } + + init( + session: APISession, + postId: Int, + locked: Bool + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + post_id: postId, + locked: locked, + auth: session.token + ) + } +} diff --git a/Mlem/API/Requests/Post/MarkPostRead.swift b/Mlem/API/Requests/Post/MarkPostRead.swift index fd899dd0d..1af50cb3e 100644 --- a/Mlem/API/Requests/Post/MarkPostRead.swift +++ b/Mlem/API/Requests/Post/MarkPostRead.swift @@ -15,12 +15,13 @@ struct MarkPostReadRequest: APIPostRequest { let body: Body struct Body: Encodable { - let post_id: Int + let post_id: Int? + let post_ids: [Int]? let read: Bool let auth: String - // TODO: 0.19 support add post_ids? field } + /// Create a request to mark a single post as read init( session: APISession, postId: Int, @@ -30,6 +31,23 @@ struct MarkPostReadRequest: APIPostRequest { self.body = try .init( post_id: postId, + post_ids: nil, + read: read, + auth: session.token + ) + } + + /// Create a request to mark multiple posts as read + init( + session: APISession, + postIds: [Int], + read: Bool + ) throws { + self.instanceURL = try session.instanceUrl + + self.body = try .init( + post_id: nil, + post_ids: postIds, read: read, auth: session.token ) diff --git a/Mlem/API/Requests/Post/PurgePostRequest.swift b/Mlem/API/Requests/Post/PurgePostRequest.swift new file mode 100644 index 000000000..bfaf6a4ad --- /dev/null +++ b/Mlem/API/Requests/Post/PurgePostRequest.swift @@ -0,0 +1,35 @@ +// +// PurgePostRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct PurgePostRequest: APIPostRequest { + typealias Response = SuccessResponse + + var instanceURL: URL + let path = "admin/purge/post" + let body: Body + + struct Body: Codable { + let post_id: Int + let reason: String? + let auth: String + } + + init( + session: APISession, + postId: Int, + reason: String? + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + post_id: postId, + reason: reason, + auth: session.token + ) + } +} diff --git a/Mlem/API/Requests/Post/RemovePostRequest.swift b/Mlem/API/Requests/Post/RemovePostRequest.swift new file mode 100644 index 000000000..d992d7152 --- /dev/null +++ b/Mlem/API/Requests/Post/RemovePostRequest.swift @@ -0,0 +1,38 @@ +// +// RemovePostRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct RemovePostRequest: APIPostRequest { + typealias Response = APIPostResponse + + var instanceURL: URL + let path = "post/remove" + let body: Body + + struct Body: Codable { + let post_id: Int + let removed: Bool + let reason: String? + let auth: String + } + + init( + session: APISession, + postId: Int, + removed: Bool, + reason: String? + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + post_id: postId, + removed: removed, + reason: reason, + auth: session.token + ) + } +} diff --git a/Mlem/API/Requests/Site/BlockInstance.swift b/Mlem/API/Requests/Site/BlockInstance.swift new file mode 100644 index 000000000..28b778ba1 --- /dev/null +++ b/Mlem/API/Requests/Site/BlockInstance.swift @@ -0,0 +1,41 @@ +// +// BlockInstance.swift +// Mlem +// +// Created by Sjmarf on 16/04/2024. +// + +import Foundation + +struct BlockInstanceRequest: APIPostRequest { + typealias Response = BlockInstanceResponse + + let instanceURL: URL + let path = "site/block" + let body: Body + + // lemmy_api_common::community::BlockCommunity + struct Body: Encodable { + let instance_id: Int + let block: Bool + + let auth: String + } + + init( + session: APISession, + instanceId: Int, + block: Bool + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init( + instance_id: instanceId, + block: block, + auth: session.token + ) + } +} + +struct BlockInstanceResponse: Decodable { + let blocked: Bool +} diff --git a/Mlem/API/Requests/Site/GetModlogRequest.swift b/Mlem/API/Requests/Site/GetModlogRequest.swift new file mode 100644 index 000000000..1e7ff2939 --- /dev/null +++ b/Mlem/API/Requests/Site/GetModlogRequest.swift @@ -0,0 +1,61 @@ +// +// GetModlogRequest.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27 +// + +import Foundation + +struct GetModlogRequest: APIGetRequest { + typealias Response = APIGetModlogResponse + + let instanceURL: URL + let path = "modlog" + let queryItems: [URLQueryItem] + + init( + session: APISession, + modPersonId: Int?, + communityId: Int?, + page: Int?, + limit: Int?, + type_: APIModlogActionType?, + otherPersonId: Int? + ) throws { + self.instanceURL = try session.instanceUrl + + var queryItems: [URLQueryItem] = [ + .init(name: "mod_person_id", value: modPersonId.map(String.init)), + .init(name: "community_id", value: communityId.map(String.init)), + .init(name: "page", value: page.map(String.init)), + .init(name: "limit", value: limit.map(String.init)), + .init(name: "type_", value: type_?.rawValue), + .init(name: "other_person_id", value: otherPersonId.map(String.init)) + ] + if let token = try? session.token { + queryItems.append( + .init(name: "auth", value: token) + ) + } + self.queryItems = queryItems + } +} + +struct APIGetModlogResponse: Decodable { + let removedPosts: [APIModRemovePostView] + let lockedPosts: [APIModLockPostView] + let featuredPosts: [APIModFeaturePostView] + let removedComments: [APIModRemoveCommentView] + let removedCommunities: [APIModRemoveCommunityView] + let bannedFromCommunity: [APIModBanFromCommunityView] + let banned: [APIModBanView] + let addedToCommunity: [APIModAddCommunityView] + let transferredToCommunity: [APIModTransferCommunityView] + let added: [APIModAddView] + let adminPurgedPersons: [APIAdminPurgePersonView] + let adminPurgedCommunities: [APIAdminPurgeCommunityView] + let adminPurgedPosts: [APIAdminPurgePostView] + let adminPurgedComments: [APIAdminPurgeCommentView] + let hiddenCommunities: [APIModHideCommunityView] +} diff --git a/Mlem/Animations/Comments.swift b/Mlem/Animations/Animations.swift similarity index 67% rename from Mlem/Animations/Comments.swift rename to Mlem/Animations/Animations.swift index ec88237d6..4392b041c 100644 --- a/Mlem/Animations/Comments.swift +++ b/Mlem/Animations/Animations.swift @@ -9,7 +9,8 @@ import SwiftUI extension Animation { /// Animation for expanding or collapsing a comment and its child comments. - static func showHideComment(_ collapse: Bool) -> Animation { + static func showHideComment(_ collapse: Bool) -> Animation? { + if UIAccessibility.isReduceMotionEnabled { return nil } let standard = (0.4, 1.0, collapse ? 0.25 : 0.3) let animationValues = standard return .interactiveSpring( @@ -18,6 +19,11 @@ extension Animation { blendDuration: animationValues.2 ) } + + static var showHidePost: Animation? { + if UIAccessibility.isReduceMotionEnabled { return nil } + return .interactiveSpring(response: 0.4, dampingFraction: 1, blendDuration: 0.25) + } } extension AnyTransition { diff --git a/Mlem/App Constants.swift b/Mlem/App Constants.swift index d849bf529..b87459cd3 100644 --- a/Mlem/App Constants.swift +++ b/Mlem/App Constants.swift @@ -38,9 +38,13 @@ enum AppConstants { static let smallAvatarSize: CGFloat = 16 static let defaultAvatarSize: CGFloat = 24 static let largeAvatarSpacing: CGFloat = 10 - static let postAndCommentSpacing: CGFloat = 10 // standard spacing for the app + static let doubleSpacing: CGFloat = 20 + static let standardSpacing: CGFloat = 10 // standard spacing for the app + static let halfSpacing: CGFloat = 5 + @available(*, deprecated, message: "prefer standardSpacing") + static let postAndCommentSpacing: CGFloat = 10 static let compactSpacing: CGFloat = 6 // standard spacing for compact things - static let appIconCornerRadius: CGFloat = 10 + static let appIconCornerRadius: CGFloat = 14 static let largeItemCornerRadius: CGFloat = 8 // posts, website previews, etc static let smallItemCornerRadius: CGFloat = 6 // settings items, compact thumbnails static let tinyItemCornerRadius: CGFloat = 4 // buttons @@ -63,7 +67,5 @@ enum AppConstants { static let blockUserPrompt: String = "Really block this user?" static let blockCommunityPrompt: String = "Really block this community?" - static let reportPostPrompt: String = "Really report this post?" - static let reportCommentPrompt: String = "Really report this comment?" - static let reportMessagePrompt: String = "Really report this message?" + static let blockInstancePrompt: String = "Really block this instance?" } diff --git a/Mlem/Assets.xcassets/Icons/icon.sjmarf.ocean.appiconset/Contents.json b/Mlem/Assets.xcassets/Icons/icon.sjmarf.ocean.appiconset/Contents.json new file mode 100644 index 000000000..f4344003c --- /dev/null +++ b/Mlem/Assets.xcassets/Icons/icon.sjmarf.ocean.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "logo.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mlem/Assets.xcassets/Icons/icon.sjmarf.ocean.appiconset/logo.png b/Mlem/Assets.xcassets/Icons/icon.sjmarf.ocean.appiconset/logo.png new file mode 100644 index 000000000..3af197544 Binary files /dev/null and b/Mlem/Assets.xcassets/Icons/icon.sjmarf.ocean.appiconset/logo.png differ diff --git a/Mlem/ContentView.swift b/Mlem/ContentView.swift index b6b64a6db..c9c710772 100644 --- a/Mlem/ContentView.swift +++ b/Mlem/ContentView.swift @@ -16,13 +16,27 @@ struct ContentView: View { @Dependency(\.hapticManager) var hapticManager @Dependency(\.siteInformation) var siteInformation @Dependency(\.accountsTracker) var accountsTracker + @Dependency(\.markReadBatcher) var markReadBatcher @Environment(\.setAppFlow) private var setFlow @EnvironmentObject var appState: AppState @StateObject var editorTracker: EditorTracker = .init() - @StateObject var unreadTracker: UnreadTracker = .init() + @StateObject var modToolTracker: ModToolTracker = .init() + @StateObject var unreadTracker: UnreadTracker = { + @AppStorage("showUnreadPersonal") var showUnreadPersonal = true + @AppStorage("showUnreadModerator") var showUnreadModerator = true + @AppStorage("showUnreadMessageReports") var showUnreadMessageReports = true + @AppStorage("showUnreadApplications") var showUnreadApplications = true + + return .init( + sumPersonal: showUnreadPersonal, + sumModerator: showUnreadModerator, + sumMessageReports: showUnreadMessageReports, + sumRegistrationApplications: showUnreadApplications + ) + }() @State private var errorAlert: ErrorAlert? @@ -34,7 +48,11 @@ struct ContentView: View { @State private var isPresentingAccountSwitcher: Bool = false @State private var tokenRefreshAccount: SavedAccount? - @AppStorage("showInboxUnreadBadge") var showInboxUnreadBadge: Bool = true + @AppStorage("showUnreadPersonal") var showUnreadPersonal: Bool = true + @AppStorage("showUnreadModerator") var showUnreadModerator: Bool = true + @AppStorage("showUnreadMessageReports") var showUnreadMessageReports: Bool = true + @AppStorage("showUnreadApplications") var showUnreadApplications: Bool = true + @AppStorage("homeButtonExists") var homeButtonExists: Bool = false @AppStorage("allowTabBarSwipeUpGesture") var allowTabBarSwipeUpGesture: Bool = true @AppStorage("appLock") var appLock: AppLock = .disabled @@ -48,6 +66,8 @@ struct ContentView: View { appLock != .disabled && !biometricUnlock.isUnlocked } + @State var displayedInboxBadgeCount: Int? + var body: some View { FancyTabBar(selection: $tabSelection, navigationSelection: $tabNavigation, dragUpGestureCallback: showAccountSwitcherDragCallback) { Group { @@ -63,12 +83,12 @@ struct ContentView: View { // but when guest mode arrives we'll either omit these entirely, or replace them with a // guest mode specific tab for sign in / change instance screen. if appState.currentActiveAccount != nil { - InboxView() + InboxRoot() .fancyTabItem(tag: TabSelection.inbox) { FancyTabBarLabel( tag: TabSelection.inbox, symbolConfiguration: .inbox, - badgeCount: showInboxUnreadBadge ? unreadTracker.total : 0 + badgeCount: unreadTracker.inboxBadgeCount ) } } @@ -107,6 +127,19 @@ struct ContentView: View { .task(id: appState.currentActiveAccount) { accountChanged() } + // these onChange handlers propagage AppState into the model layer so it can immediately update the badge + .onChange(of: showUnreadPersonal) { newValue in + unreadTracker.sumPersonal = newValue + } + .onChange(of: showUnreadModerator) { newValue in + unreadTracker.sumModerator = newValue + } + .onChange(of: showUnreadMessageReports) { newValue in + unreadTracker.sumMessageReports = newValue + } + .onChange(of: showUnreadApplications) { newValue in + unreadTracker.sumRegistrationApplications = newValue + } .onReceive(errorHandler.$sessionExpired) { expired in if expired { tokenRefreshAccount = appState.currentActiveAccount @@ -138,11 +171,15 @@ struct ContentView: View { } } .sheet(item: $editorTracker.editResponse) { editing in - NavigationStack { - ResponseEditorView(concreteEditorModel: editing) - } - .presentationDetents([.medium, .large], selection: .constant(.large)) - ._presentationBackgroundInteraction(enabledUpThrough: .medium) + ResponseEditorView(concreteEditorModel: editing) + .presentationDetents([.medium, .large], selection: .constant(.large)) + ._presentationBackgroundInteraction(enabledUpThrough: .medium) + } + .sheet(item: $editorTracker.selectText) { selectText in + SelectTextView(text: selectText.text) + .presentationDetents([.medium]) + ._presentationCornerRadius(20) + ._presentationBackgroundInteraction(enabledUpThrough: .medium) } .sheet(item: $editorTracker.editPost) { editing in NavigationStack { @@ -157,17 +194,31 @@ struct ContentView: View { ImageDetailView(url: url) } } + .sheet(item: $modToolTracker.openTool) { tool in + NavigationStack { + ModToolSheet(tool: tool) + } + .handleLemmyViews() + } .environment(\.openURL, OpenURLAction(handler: didReceiveURL)) .environmentObject(editorTracker) .environmentObject(unreadTracker) .environmentObject(quickLookState) + .environmentObject(modToolTracker) .onChange(of: scenePhase) { phase in - // when app moves into background, hide the account switcher. This prevents the app from reopening with the switcher enabled. if phase != .active { + // prevents the app from reopening with the switcher enabled. isPresentingAccountSwitcher = false - } - if phase == .background || phase == .inactive, appLock != .disabled { - biometricUnlock.isUnlocked = false + + // flush batcher(s) to avoid batches being lost on quit + Task { + await markReadBatcher.flush() + } + + // activate biometric lock + if appLock != .disabled { + biometricUnlock.isUnlocked = false + } } } .fullScreenCover(isPresented: .constant(isAppLocked)) { @@ -181,12 +232,7 @@ struct ContentView: View { func accountChanged() { // refresh unread count Task(priority: .background) { - do { - let unreadCounts = try await personRepository.getUnreadCounts() - unreadTracker.update(with: unreadCounts) - } catch { - errorHandler.handle(error) - } + await unreadTracker.update() } } diff --git a/Mlem/Dependency/MarkReadBatcher+Dependency.swift b/Mlem/Dependency/MarkReadBatcher+Dependency.swift new file mode 100644 index 000000000..8fe75bf42 --- /dev/null +++ b/Mlem/Dependency/MarkReadBatcher+Dependency.swift @@ -0,0 +1,20 @@ +// +// MarkReadBatcher+Dependency.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-09. +// + +import Dependencies +import Foundation + +extension MarkReadBatcher: DependencyKey { + static let liveValue = MarkReadBatcher() +} + +extension DependencyValues { + var markReadBatcher: MarkReadBatcher { + get { self[MarkReadBatcher.self] } + set { self[MarkReadBatcher.self] = newValue } + } +} diff --git a/Mlem/Enums/APIPostFeatureType.swift b/Mlem/Enums/APIPostFeatureType.swift new file mode 100644 index 000000000..d0784e3e8 --- /dev/null +++ b/Mlem/Enums/APIPostFeatureType.swift @@ -0,0 +1,14 @@ +// +// APIPostFeatureType.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-25 +// + +import Foundation + +// PostFeatureType.ts +enum APIPostFeatureType: String, Codable { + case local = "Local" + case community = "Community" +} diff --git a/Mlem/Enums/Content Type.swift b/Mlem/Enums/Content Type.swift index 670f7351a..6bc54259c 100644 --- a/Mlem/Enums/Content Type.swift +++ b/Mlem/Enums/Content Type.swift @@ -8,5 +8,6 @@ import Foundation enum ContentType: Int, Codable { - case post, comment, community, user, message, mention, reply, instance + case post, comment, community, user, message, mention, reply, instance, modlog, commentReport, postReport, messageReport, + registrationApplication } diff --git a/Mlem/Enums/FeedType.swift b/Mlem/Enums/PostFeedType.swift similarity index 62% rename from Mlem/Enums/FeedType.swift rename to Mlem/Enums/PostFeedType.swift index f33052fde..495f8f7ee 100644 --- a/Mlem/Enums/FeedType.swift +++ b/Mlem/Enums/PostFeedType.swift @@ -5,31 +5,55 @@ // Created by Eric Andrews on 2024-01-08. // +import Dependencies import Foundation import SwiftUI -enum FeedType { - case all, local, subscribed, saved +// maybe a slight misnomer because .saved can include comments? +enum PostFeedType: FeedType { + case all, local, subscribed, moderated, saved case community(CommunityModel) - static var allAggregateFeedCases: [FeedType] = [.all, .local, .subscribed, .saved] + static var allShortcutFeedCases: [PostFeedType] = [.all, .local, .subscribed, .saved] + static var allAggregateFeedCases: [PostFeedType] = [.all, .local, .subscribed, .moderated, .saved] var label: String { switch self { case .all: "All" case .local: "Local" case .subscribed: "Subscribed" + case .moderated: "Moderated" case .saved: "Saved" case let .community(communityModel): communityModel.name } } + var subtitle: String { + @Dependency(\.siteInformation) var siteInformation + switch self { + case .all: + return "Posts from all federated instances" + case .local: + return "Posts from \(siteInformation.instance?.url.host() ?? "your instance's") communities" + case .subscribed: + return "Posts from all subscribed communities" + case .moderated: + return "Posts from communities you moderate" + case .saved: + return "Your saved posts and comments" + default: + assertionFailure("We shouldn't be here...") + return "" + } + } + /// Maps FeedType to APIListingType var toApiListingType: APIListingType { switch self { case .all: .all case .local: .local case .subscribed: .subscribed + case .moderated: .moderatorView case .saved: .all // TODO: change this? case .community: .subscribed } @@ -41,12 +65,13 @@ enum FeedType { case .all: "All" case .local: "Local" case .subscribed: "Subscribed" + case .moderated: "Moderated" case .saved: "Saved" // TODO: change this? case .community: "Subscribed" } } - static func fromShortcutString(shortcut: String?) -> FeedType? { + static func fromShortcutString(shortcut: String?) -> PostFeedType? { switch shortcut { case "All": return .all @@ -54,6 +79,8 @@ enum FeedType { return .local case "Subscribed": return .subscribed + case "Moderated": + return .moderated case "Saved": return .saved default: @@ -69,7 +96,7 @@ enum FeedType { } } -extension FeedType: Hashable, Identifiable { +extension PostFeedType: Hashable, Identifiable { func hash(into hasher: inout Hasher) { switch self { case .all: @@ -78,6 +105,8 @@ extension FeedType: Hashable, Identifiable { hasher.combine("local") case .subscribed: hasher.combine("subscribed") + case .moderated: + hasher.combine("moderated") case .saved: hasher.combine("saved") case let .community(communityModel): @@ -89,12 +118,13 @@ extension FeedType: Hashable, Identifiable { var id: Int { hashValue } } -extension FeedType: AssociatedIcon { +extension PostFeedType: AssociatedIcon { var iconName: String { switch self { case .all: Icons.federatedFeed case .local: Icons.localFeed case .subscribed: Icons.subscribedFeed + case .moderated: Icons.moderation case .saved: Icons.savedFeed case .community: Icons.community } @@ -105,39 +135,49 @@ extension FeedType: AssociatedIcon { case .all: Icons.federatedFeedFill case .local: Icons.localFeedFill case .subscribed: Icons.subscribedFeedFill + case .moderated: Icons.moderationFill case .saved: Icons.savedFeedFill case .community: Icons.communityFill } } - var iconNameCircle: String { - switch self { - case .all: Icons.federatedFeedCircle - case .local: Icons.localFeedCircle - case .subscribed: Icons.subscribedFeedCircle - case .saved: Icons.savedFeedCircle - case .community: Icons.community - } - } - /// Icon to use in system settings. This should be removed when the "unified symbol handling" is closed var settingsIconName: String { switch self { case .all: "circle.hexagongrid" case .local: "house" case .subscribed: "newspaper" + case .moderated: Icons.moderation case .saved: Icons.save case .community: Icons.community } } + + var iconScaleFactor: CGFloat { + switch self { + case .all: + 0.6 + case .local: + 0.6 + case .subscribed: + 0.5 + case .moderated: + 0.5 + case .saved: + 0.55 + default: + 0.5 + } + } } -extension FeedType: AssociatedColor { +extension PostFeedType: AssociatedColor { var color: Color? { switch self { case .all: .blue case .local: .purple case .subscribed: .red + case .moderated: .moderation case .saved: .green case .community: .blue } diff --git a/Mlem/Enums/Settings/CommentSortType.swift b/Mlem/Enums/Settings/CommentSortType.swift index 31fc11bd2..60b7be7f9 100644 --- a/Mlem/Enums/Settings/CommentSortType.swift +++ b/Mlem/Enums/Settings/CommentSortType.swift @@ -6,6 +6,7 @@ // import Foundation +import SwiftUI // lemmy_db_schema::CommentSortType // TODO: this is not accurate to the Lemmy enum, "controversial" is missing @@ -48,8 +49,8 @@ extension CommentSortType: SettingsOptions { } extension CommentSortType { - static func appStorageValue(store: UserDefaults = .standard) -> Self { - let defaultValue = store.string(forKey: "defaultCommentSorting") ?? "" - return CommentSortType(rawValue: defaultValue) ?? .top + static func appStorageValue() -> Self { + @AppStorage("defaultCommentSorting") var defaultCommentSorting: CommentSortType = .top + return defaultCommentSorting } } diff --git a/Mlem/Enums/Settings/DefaultFeedType.swift b/Mlem/Enums/Settings/DefaultFeedType.swift index ff1ba0eb8..21fc49378 100644 --- a/Mlem/Enums/Settings/DefaultFeedType.swift +++ b/Mlem/Enums/Settings/DefaultFeedType.swift @@ -21,7 +21,7 @@ enum DefaultFeedType: String, SettingsOptions, CaseIterable { } } - var toFeedType: FeedType { + var toFeedType: PostFeedType { switch self { case .all: .all case .local: .local diff --git a/Mlem/Enums/Settings/InternetSpeed.swift b/Mlem/Enums/Settings/InternetSpeed.swift index 705a74c1c..6b5be2ad4 100644 --- a/Mlem/Enums/Settings/InternetSpeed.swift +++ b/Mlem/Enums/Settings/InternetSpeed.swift @@ -8,7 +8,7 @@ import Foundation enum InternetSpeed: String, SettingsOptions { - case slow, fast + case debug, slow, fast var label: String { rawValue.capitalized } @@ -16,6 +16,7 @@ enum InternetSpeed: String, SettingsOptions { var pageSize: Int { switch self { + case .debug: return 11 case .slow: return 25 case .fast: return 50 } diff --git a/Mlem/Enums/Settings/PostSize.swift b/Mlem/Enums/Settings/PostSize.swift index fef6d99be..3ddf5fb10 100644 --- a/Mlem/Enums/Settings/PostSize.swift +++ b/Mlem/Enums/Settings/PostSize.swift @@ -17,6 +17,14 @@ extension PostSize: SettingsOptions { } var id: Self { self } + + var markReadThreshold: Int { + switch self { + case .compact: 4 + case .headline: 2 + case .large: 1 + } + } } extension PostSize: AssociatedIcon { diff --git a/Mlem/Extensions/Array/Array+IsNotEmpty.swift b/Mlem/Extensions/Array/Array+IsNotEmpty.swift new file mode 100644 index 000000000..8f90bdde2 --- /dev/null +++ b/Mlem/Extensions/Array/Array+IsNotEmpty.swift @@ -0,0 +1,14 @@ +// +// Array+isNotEmpty.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import Foundation + +extension Array { + var isNotEmpty: Bool { + !isEmpty + } +} diff --git a/Mlem/Extensions/Color/Color+Colors.swift b/Mlem/Extensions/Color/Color+Colors.swift index 68a9f8c18..aae0bc45d 100644 --- a/Mlem/Extensions/Color/Color+Colors.swift +++ b/Mlem/Extensions/Color/Color+Colors.swift @@ -18,4 +18,6 @@ extension Color { static let upvoteColor = Color.blue static let downvoteColor = Color.red static let saveColor = Color.green + + static let moderation = Color.cyan } diff --git a/Mlem/Extensions/Date/Date+DaysFromNow.swift b/Mlem/Extensions/Date/Date+DaysFromNow.swift new file mode 100644 index 000000000..637d8f6f3 --- /dev/null +++ b/Mlem/Extensions/Date/Date+DaysFromNow.swift @@ -0,0 +1,15 @@ +// +// Date+DaysFromNow.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-16. +// + +import Foundation + +extension Date { + static func getEpochDate(daysFromNow: Int) -> Int { + let targetDate = Date.now.advanced(by: .days(Double(daysFromNow))) + return Int(targetDate.timeIntervalSince1970) + } +} diff --git a/Mlem/Extensions/EnvironmentValues/EnvironmentValues+FeedType.swift b/Mlem/Extensions/EnvironmentValues/EnvironmentValues+FeedType.swift index 82574d125..4dfe1f685 100644 --- a/Mlem/Extensions/EnvironmentValues/EnvironmentValues+FeedType.swift +++ b/Mlem/Extensions/EnvironmentValues/EnvironmentValues+FeedType.swift @@ -9,11 +9,11 @@ import Foundation import SwiftUI private struct FeedTypeEnvironmentKey: EnvironmentKey { - static let defaultValue: FeedType? = nil + static let defaultValue: PostFeedType? = nil } extension EnvironmentValues { - var feedType: FeedType? { + var feedType: PostFeedType? { get { self[FeedTypeEnvironmentKey.self] } set { self[FeedTypeEnvironmentKey.self] = newValue } } diff --git a/Mlem/Extensions/Int/Int+Abbreviated.swift b/Mlem/Extensions/Int/Int+Abbreviated.swift new file mode 100644 index 000000000..48d9c3920 --- /dev/null +++ b/Mlem/Extensions/Int/Int+Abbreviated.swift @@ -0,0 +1,26 @@ +// +// Int+Abbreviated.swift +// Mlem +// +// Created by Sjmarf on 13/04/2024. +// + +import Foundation + +extension Int { + var abbreviated: String { + if self >= 10_000_000 { + return "\(Int(floor(Double(self) / 1_000_000)))M" + } + if self >= 1_000_000 { + return "\(Double(floor(Double(self) / 100_000) / 10))M" + } + if self >= 10000 { + return "\(Int(floor(Double(self) / 1000)))K" + } + if self >= 1000 { + return "\(Double(floor(Double(self) / 100) / 10))K" + } + return String(self) + } +} diff --git a/Mlem/Extensions/Mocks/APILocalSite+Mock.swift b/Mlem/Extensions/Mocks/APILocalSite+Mock.swift new file mode 100644 index 000000000..819d33cc7 --- /dev/null +++ b/Mlem/Extensions/Mocks/APILocalSite+Mock.swift @@ -0,0 +1,48 @@ +// +// APILocalSite+Mock.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import Foundation + +extension APILocalSite { + static func mock( + enableDownvotes: Bool = true, + enableNsfw: Bool = true, + communityCreationAdminOnly: Bool = false, + requireEmailVerification: Bool = false, + privateInstance: Bool = false, + defaultPostListingType: APIListingType = .local, + hideModlogModNames: Bool = false, + applicationEmailAdmins: Bool = false, + slurFilterRegex: String? = nil, + federationEnabled: Bool = true, + federationSignedFetch: Bool = false, + captchaEnabled: Bool = true, + captchaDifficulty: APICaptchaDifficulty = .medium, + registrationMode: APIRegistrationMode = .open, + reportsEmailAdmins: Bool = false, + published: Date = .mock + ) -> APILocalSite { + .init( + enableDownvotes: enableDownvotes, + enableNsfw: enableNsfw, + communityCreationAdminOnly: communityCreationAdminOnly, + requireEmailVerification: requireEmailVerification, + privateInstance: privateInstance, + defaultPostListingType: defaultPostListingType, + hideModlogModNames: hideModlogModNames, + applicationEmailAdmins: applicationEmailAdmins, + slurFilterRegex: slurFilterRegex, + federationEnabled: federationEnabled, + federationSignedFetch: federationSignedFetch, + captchaEnabled: captchaEnabled, + captchaDifficulty: captchaDifficulty, + registrationMode: registrationMode, + reportsEmailAdmins: reportsEmailAdmins, + published: published + ) + } +} diff --git a/Mlem/Extensions/Mocks/APILocalSiteRateLimit+Mock.swift b/Mlem/Extensions/Mocks/APILocalSiteRateLimit+Mock.swift new file mode 100644 index 000000000..88591297f --- /dev/null +++ b/Mlem/Extensions/Mocks/APILocalSiteRateLimit+Mock.swift @@ -0,0 +1,47 @@ +// +// APILocalSiteRateLimit+Mock.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import Foundation + +extension APILocalSiteRateLimit { + static func mock( + localSiteId: Int = 0, + message: Int = 60, + messagePerSecond: Int = 600, + post: Int = 60, + postPerSecond: Int = 600, + register: Int = 60, + registerPerSecond: Int = 600, + image: Int = 60, + imagePerSecond: Int = 600, + comment: Int = 60, + commentPerSecond: Int = 600, + search: Int = 60, + searchPerSecond: Int = 600, + published: Date = .mock, + updated: Date? = nil + ) -> APILocalSiteRateLimit { + .init( + id: nil, + localSiteId: localSiteId, + message: message, + messagePerSecond: messagePerSecond, + post: post, + postPerSecond: postPerSecond, + register: register, + registerPerSecond: registerPerSecond, + image: image, + imagePerSecond: imagePerSecond, + comment: comment, + commentPerSecond: commentPerSecond, + search: search, + searchPerSecond: searchPerSecond, + published: published, + updated: updated + ) + } +} diff --git a/Mlem/Extensions/Mocks/APIPersonAggregates+Mock.swift b/Mlem/Extensions/Mocks/APIPersonAggregates+Mock.swift new file mode 100644 index 000000000..9a6b146df --- /dev/null +++ b/Mlem/Extensions/Mocks/APIPersonAggregates+Mock.swift @@ -0,0 +1,25 @@ +// +// APIPersonAggregates+Mock.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import Foundation + +extension APIPersonAggregates { + static func mock( + personId: Int = 0, + postCount: Int = 5, + commentCount: Int = 20 + ) -> APIPersonAggregates { + .init( + id: nil, + personId: personId, + postCount: postCount, + postScore: nil, + commentCount: commentCount, + commentScore: nil + ) + } +} diff --git a/Mlem/Extensions/Mocks/APIPersonView+Mock.swift b/Mlem/Extensions/Mocks/APIPersonView+Mock.swift new file mode 100644 index 000000000..e80a78ee3 --- /dev/null +++ b/Mlem/Extensions/Mocks/APIPersonView+Mock.swift @@ -0,0 +1,22 @@ +// +// APIPersonView+Mock.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import Foundation + +extension APIPersonView { + static func mock( + person: APIPerson = .mock(), + counts: APIPersonAggregates = .mock(), + isAdmin: Bool = false + ) -> APIPersonView { + .init( + person: person, + counts: counts, + isAdmin: isAdmin + ) + } +} diff --git a/Mlem/Extensions/Mocks/APIPost+Mock.swift b/Mlem/Extensions/Mocks/APIPost+Mock.swift index 418d961d1..cdc08aeae 100644 --- a/Mlem/Extensions/Mocks/APIPost+Mock.swift +++ b/Mlem/Extensions/Mocks/APIPost+Mock.swift @@ -23,7 +23,7 @@ extension APIPost { featuredCommunity: Bool = false, featuredLocal: Bool = false, languageId: Int = 0, - apId: String = "mock.apId", + apId: URL = URL(string: "mock.apId")!, local: Bool = false, locked: Bool = false, nsfw: Bool = false, diff --git a/Mlem/Extensions/Mocks/APISite+Mock.swift b/Mlem/Extensions/Mocks/APISite+Mock.swift index 81e8cb5a9..0b669571c 100644 --- a/Mlem/Extensions/Mocks/APISite+Mock.swift +++ b/Mlem/Extensions/Mocks/APISite+Mock.swift @@ -12,14 +12,14 @@ extension APISite { static func mock( id: Int = 0, name: String = "Mock Site", - sidebar: String? = nil, + sidebar: String? = "Lorem Ipsum", published: Date = .mock, icon: String? = nil, banner: String? = nil, description: String? = nil, actorId: String? = nil, lastRefreshedAt: Date = .mock, - inboxUrl: String = "", + inboxUrl: String = "https://mock.site", publicKey: String = "", instanceId: Int = 0 ) -> APISite { diff --git a/Mlem/Extensions/Mocks/APISiteAggregates+Mock.swift b/Mlem/Extensions/Mocks/APISiteAggregates+Mock.swift new file mode 100644 index 000000000..6b3f7e964 --- /dev/null +++ b/Mlem/Extensions/Mocks/APISiteAggregates+Mock.swift @@ -0,0 +1,35 @@ +// +// APISiteAggregates+Mock.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import Foundation + +extension APISiteAggregates { + static func mock( + siteId: Int = 0, + users: Int = 39453, + posts: Int = 1856, + comments: Int = 20371, + communities: Int = 183, + usersActiveDay: Int = 284, + usersActiveWeek: Int = 4038, + usersActiveMonth: Int = 8079, + usersActiveHalfYear: Int = 10200 + ) -> APISiteAggregates { + .init( + id: nil, + siteId: siteId, + users: users, + posts: posts, + comments: comments, + communities: communities, + usersActiveDay: usersActiveDay, + usersActiveWeek: usersActiveWeek, + usersActiveMonth: usersActiveMonth, + usersActiveHalfYear: usersActiveHalfYear + ) + } +} diff --git a/Mlem/Extensions/Mocks/APISiteView+Mock.swift b/Mlem/Extensions/Mocks/APISiteView+Mock.swift new file mode 100644 index 000000000..ef1ee9578 --- /dev/null +++ b/Mlem/Extensions/Mocks/APISiteView+Mock.swift @@ -0,0 +1,24 @@ +// +// APISiteView+Mock.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import Foundation + +extension APISiteView { + static func mock( + site: APISite = .mock(), + localSite: APILocalSite = .mock(), + localSiteRateLimit: APILocalSiteRateLimit = .mock(), + counts: APISiteAggregates = .mock() + ) -> APISiteView { + .init( + site: site, + localSite: localSite, + localSiteRateLimit: localSiteRateLimit, + counts: counts + ) + } +} diff --git a/Mlem/Extensions/Mocks/SiteResponse+Mock.swift b/Mlem/Extensions/Mocks/SiteResponse+Mock.swift new file mode 100644 index 000000000..308ca5c1b --- /dev/null +++ b/Mlem/Extensions/Mocks/SiteResponse+Mock.swift @@ -0,0 +1,32 @@ +// +// SiteResponse+Mock.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import Foundation + +extension SiteResponse { + static func mock( + siteView: APISiteView = .mock(), + admins: [APIPersonView] = [.mock()], + version: String = "0.19.0", + myUser: APIMyUserInfo? = nil, + federatedInstances: APIFederatedInstances? = nil, + allLanguages: [APILanguage] = [], + discussionLanguages: [Int] = [0], + tagLines: [APITagline]? = nil + ) -> SiteResponse { + .init( + siteView: siteView, + admins: admins, + version: version, + myUser: myUser, + federatedInstances: federatedInstances, + allLanguages: allLanguages, + discussionLanguages: discussionLanguages, + tagLines: tagLines + ) + } +} diff --git a/Mlem/Extensions/String/String+Alphabet.swift b/Mlem/Extensions/String/String+Alphabet.swift index 5ee481575..fe236c4d6 100644 --- a/Mlem/Extensions/String/String+Alphabet.swift +++ b/Mlem/Extensions/String/String+Alphabet.swift @@ -11,4 +11,9 @@ extension [String] { static var alphabet: Self { ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"] } + + static var labelAlphabet: Self { + ["#", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", + "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"] + } } diff --git a/Mlem/Extensions/String/String+StrippingDiacritics.swift b/Mlem/Extensions/String/String+StrippingDiacritics.swift new file mode 100644 index 000000000..0ccd4c6aa --- /dev/null +++ b/Mlem/Extensions/String/String+StrippingDiacritics.swift @@ -0,0 +1,14 @@ +// +// String+StrippingDiacritics.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-19. +// + +import Foundation + +extension StringProtocol { + var strippingDiacritics: String { + applyingTransform(.stripDiacritics, reverse: false)! + } +} diff --git a/Mlem/Extensions/Tracker Items/CommentReportModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/CommentReportModel+TrackerItem.swift new file mode 100644 index 000000000..935fa8c55 --- /dev/null +++ b/Mlem/Extensions/Tracker Items/CommentReportModel+TrackerItem.swift @@ -0,0 +1,17 @@ +// +// CommentReportModel+TrackerItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-27. +// + +import Foundation + +extension CommentReportModel: TrackerItem { + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { + switch sortType { + case .new: .new(commentReport.published) + case .old: .old(commentReport.published) + } + } +} diff --git a/Mlem/Extensions/Tracker Items/HierarchicalComment+TrackerItem.swift b/Mlem/Extensions/Tracker Items/HierarchicalComment+TrackerItem.swift index 6cb13e942..ef1f4de7c 100644 --- a/Mlem/Extensions/Tracker Items/HierarchicalComment+TrackerItem.swift +++ b/Mlem/Extensions/Tracker Items/HierarchicalComment+TrackerItem.swift @@ -8,9 +8,10 @@ import Foundation extension HierarchicalComment: TrackerItem { - func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { switch sortType { - case .published: .published(commentView.comment.published) + case .new: .new(commentView.comment.published) + case .old: .old(commentView.comment.published) } } } diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/CommentReportModel+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/CommentReportModel+InboxItem.swift new file mode 100644 index 000000000..cda593d59 --- /dev/null +++ b/Mlem/Extensions/Tracker Items/Inbox Items/CommentReportModel+InboxItem.swift @@ -0,0 +1,38 @@ +// +// CommentReportModel+InboxItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-27. +// + +import Foundation + +extension CommentReportModel: InboxItem { + typealias ParentType = AnyInboxItem + + var published: Date { commentReport.published } + + var creatorId: Int { reporter.userId } + + var banStatusCreatorId: Int { comment.creatorId } + + var creatorBannedFromCommunity: Bool { commentCreatorBannedFromCommunity } + + var creatorBannedFromInstance: Bool { commentCreator.banned } + + var read: Bool { commentReport.resolved } + + var id: Int { commentReport.id } + + func toAnyInboxItem() -> AnyInboxItem { .commentReport(self) } + + @MainActor + func setCreatorBannedFromCommunity(_ newBanned: Bool) { + commentCreatorBannedFromCommunity = newBanned + } + + @MainActor + func setCreatorBannedFromInstance(_ newBanned: Bool) { + commentCreator.banned = newBanned + } +} diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/MentionModel+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/MentionModel+InboxItem.swift index 5663572be..0de888a01 100644 --- a/Mlem/Extensions/Tracker Items/Inbox Items/MentionModel+InboxItem.swift +++ b/Mlem/Extensions/Tracker Items/Inbox Items/MentionModel+InboxItem.swift @@ -14,5 +14,21 @@ extension MentionModel: InboxItem { var creatorId: Int { comment.creatorId } - var read: Bool { personMention.read } + var banStatusCreatorId: Int { comment.creatorId } + + var creatorBannedFromCommunity: Bool { commentCreatorBannedFromCommunity } + + var creatorBannedFromInstance: Bool { creator.banned } + + func toAnyInboxItem() -> AnyInboxItem { .mention(self) } + + @MainActor + func setCreatorBannedFromCommunity(_ newBanned: Bool) { + commentCreatorBannedFromCommunity = newBanned + } + + @MainActor + func setCreatorBannedFromInstance(_ newBanned: Bool) { + creator.banned = newBanned + } } diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/MessageModel+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/MessageModel+InboxItem.swift index d1f4aabb8..8e3452c26 100644 --- a/Mlem/Extensions/Tracker Items/Inbox Items/MessageModel+InboxItem.swift +++ b/Mlem/Extensions/Tracker Items/Inbox Items/MessageModel+InboxItem.swift @@ -13,5 +13,23 @@ extension MessageModel: InboxItem { var creatorId: Int { privateMessage.creatorId } - var read: Bool { privateMessage.read } + var banStatusCreatorId: Int { privateMessage.creatorId } + + var creatorBannedFromCommunity: Bool { false } + + var creatorBannedFromInstance: Bool { creator.banned } + + var read: Bool { privateMessage.read || siteInformation.userId == privateMessage.creatorId } + + func toAnyInboxItem() -> AnyInboxItem { .message(self) } + + @MainActor + func setCreatorBannedFromCommunity(_ newBanned: Bool) { + // noop + } + + @MainActor + func setCreatorBannedFromInstance(_ newBanned: Bool) { + creator.banned = newBanned + } } diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/MessageReportModel+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/MessageReportModel+InboxItem.swift new file mode 100644 index 000000000..55b289b79 --- /dev/null +++ b/Mlem/Extensions/Tracker Items/Inbox Items/MessageReportModel+InboxItem.swift @@ -0,0 +1,38 @@ +// +// MessageReportModel+InboxItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Foundation + +extension MessageReportModel: InboxItem { + typealias ParentType = AnyInboxItem + + var published: Date { messageReport.published } + + var creatorId: Int { messageReport.creatorId } + + var banStatusCreatorId: Int { messageCreator.userId } + + var creatorBannedFromCommunity: Bool { false } + + var creatorBannedFromInstance: Bool { messageCreator.banned } + + var read: Bool { messageReport.resolved } + + var id: Int { messageReport.id } + + func toAnyInboxItem() -> AnyInboxItem { .messageReport(self) } + + @MainActor + func setCreatorBannedFromCommunity(_ newBanned: Bool) { + // noop + } + + @MainActor + func setCreatorBannedFromInstance(_ newBanned: Bool) { + messageCreator.banned = newBanned + } +} diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/PostReportModel+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/PostReportModel+InboxItem.swift new file mode 100644 index 000000000..48ca44046 --- /dev/null +++ b/Mlem/Extensions/Tracker Items/Inbox Items/PostReportModel+InboxItem.swift @@ -0,0 +1,37 @@ +// +// PostReportModel+InboxItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Foundation + +extension PostReportModel: InboxItem { + typealias ParentType = AnyInboxItem + + var published: Date { postReport.published } + + var creatorId: Int { reporter.userId } + + var banStatusCreatorId: Int { post.creatorId } + + var creatorBannedFromCommunity: Bool { postCreatorBannedFromCommunity } + + var creatorBannedFromInstance: Bool { postCreator.banned } + + var read: Bool { postReport.resolved } + + var id: Int { postReport.id } + + func toAnyInboxItem() -> AnyInboxItem { .postReport(self) } + + @MainActor + func setCreatorBannedFromCommunity(_ newBanned: Bool) { + postCreatorBannedFromCommunity = newBanned + } + + func setCreatorBannedFromInstance(_ newBanned: Bool) { + postCreator.banned = newBanned + } +} diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/RegistrationApplication+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/RegistrationApplication+InboxItem.swift new file mode 100644 index 000000000..008b191d0 --- /dev/null +++ b/Mlem/Extensions/Tracker Items/Inbox Items/RegistrationApplication+InboxItem.swift @@ -0,0 +1,34 @@ +// +// RegistrationApplication+InboxItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-05. +// + +import Foundation + +extension RegistrationApplicationModel: InboxItem { + var published: Date { application.published } + + var creatorId: Int { creator.userId } + + var banStatusCreatorId: Int { creator.userId } + + var creatorBannedFromInstance: Bool { false } + + var creatorBannedFromCommunity: Bool { false } + + var read: Bool { resolver != nil } + + var id: Int { application.id } + + func toAnyInboxItem() -> AnyInboxItem { .registrationApplication(self) } + + func setCreatorBannedFromCommunity(_ newBanned: Bool) { + // noop + } + + func setCreatorBannedFromInstance(_ newBanned: Bool) { + // noop + } +} diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/ReplyModel+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/ReplyModel+InboxItem.swift index d1aaaba55..f4cc04e52 100644 --- a/Mlem/Extensions/Tracker Items/Inbox Items/ReplyModel+InboxItem.swift +++ b/Mlem/Extensions/Tracker Items/Inbox Items/ReplyModel+InboxItem.swift @@ -13,5 +13,21 @@ extension ReplyModel: InboxItem { var creatorId: Int { comment.creatorId } - var read: Bool { commentReply.read } + var banStatusCreatorId: Int { comment.creatorId } + + var creatorBannedFromCommunity: Bool { commentCreatorBannedFromCommunity } + + var creatorBannedFromInstance: Bool { creator.banned } + + func toAnyInboxItem() -> AnyInboxItem { .reply(self) } + + @MainActor + func setCreatorBannedFromCommunity(_ newBanned: Bool) { + commentCreatorBannedFromCommunity = newBanned + } + + @MainActor + func setCreatorBannedFromInstance(_ newBanned: Bool) { + creator.banned = newBanned + } } diff --git a/Mlem/Extensions/Tracker Items/MentionModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/MentionModel+TrackerItem.swift index b76fbb167..3b79888fd 100644 --- a/Mlem/Extensions/Tracker Items/MentionModel+TrackerItem.swift +++ b/Mlem/Extensions/Tracker Items/MentionModel+TrackerItem.swift @@ -8,10 +8,10 @@ import Foundation extension MentionModel: TrackerItem { - func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { switch sortType { - case .published: - return .published(personMention.published) + case .new: .new(personMention.published) + case .old: .old(personMention.published) } } } diff --git a/Mlem/Extensions/Tracker Items/MessageModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/MessageModel+TrackerItem.swift index 773fad89f..54e6d9a72 100644 --- a/Mlem/Extensions/Tracker Items/MessageModel+TrackerItem.swift +++ b/Mlem/Extensions/Tracker Items/MessageModel+TrackerItem.swift @@ -8,10 +8,10 @@ import Foundation extension MessageModel: TrackerItem { - func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { switch sortType { - case .published: - return .published(privateMessage.published) + case .new: .new(privateMessage.published) + case .old: .old(privateMessage.published) } } } diff --git a/Mlem/Extensions/Tracker Items/MessageReportModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/MessageReportModel+TrackerItem.swift new file mode 100644 index 000000000..15a89921d --- /dev/null +++ b/Mlem/Extensions/Tracker Items/MessageReportModel+TrackerItem.swift @@ -0,0 +1,17 @@ +// +// MessageReportModel+TrackerItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Foundation + +extension MessageReportModel: TrackerItem { + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { + switch sortType { + case .new: .new(messageReport.published) + case .old: .old(messageReport.published) + } + } +} diff --git a/Mlem/Extensions/Tracker Items/ModlogEntry+TrackerItem.swift b/Mlem/Extensions/Tracker Items/ModlogEntry+TrackerItem.swift new file mode 100644 index 000000000..186d08f3f --- /dev/null +++ b/Mlem/Extensions/Tracker Items/ModlogEntry+TrackerItem.swift @@ -0,0 +1,19 @@ +// +// ModlogEntry+TrackerItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-14. +// + +import Foundation + +extension ModlogEntry: TrackerItem { + var uid: ContentModelIdentifier { .init(contentType: .modlog, contentId: hashValue) } + + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { + switch sortType { + case .new: .new(date) + case .old: .old(date) + } + } +} diff --git a/Mlem/Extensions/Tracker Items/PostModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/PostModel+TrackerItem.swift index c0e60c70b..f26912998 100644 --- a/Mlem/Extensions/Tracker Items/PostModel+TrackerItem.swift +++ b/Mlem/Extensions/Tracker Items/PostModel+TrackerItem.swift @@ -8,10 +8,10 @@ import Foundation extension PostModel: TrackerItem { - func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { switch sortType { - case .published: - return .published(published) + case .new: .new(published) + case .old: .old(published) } } } diff --git a/Mlem/Extensions/Tracker Items/PostReportModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/PostReportModel+TrackerItem.swift new file mode 100644 index 000000000..3f74ec90e --- /dev/null +++ b/Mlem/Extensions/Tracker Items/PostReportModel+TrackerItem.swift @@ -0,0 +1,17 @@ +// +// PostReportModel+TrackerItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Foundation + +extension PostReportModel: TrackerItem { + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { + switch sortType { + case .new: .new(postReport.published) + case .old: .old(postReport.published) + } + } +} diff --git a/Mlem/Extensions/Tracker Items/RegistrationApplication+TrackerItem.swift b/Mlem/Extensions/Tracker Items/RegistrationApplication+TrackerItem.swift new file mode 100644 index 000000000..c6270e97d --- /dev/null +++ b/Mlem/Extensions/Tracker Items/RegistrationApplication+TrackerItem.swift @@ -0,0 +1,19 @@ +// +// RegistrationApplication+TrackerItem.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-05. +// + +import Foundation + +extension RegistrationApplicationModel: TrackerItem { + var uid: ContentModelIdentifier { .init(contentType: .registrationApplication, contentId: application.id) } + + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { + switch sortType { + case .new: .new(application.published) + case .old: .old(application.published) + } + } +} diff --git a/Mlem/Extensions/Tracker Items/ReplyModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/ReplyModel+TrackerItem.swift index bd4acb795..c1f532724 100644 --- a/Mlem/Extensions/Tracker Items/ReplyModel+TrackerItem.swift +++ b/Mlem/Extensions/Tracker Items/ReplyModel+TrackerItem.swift @@ -8,10 +8,10 @@ import Foundation extension ReplyModel: TrackerItem { - func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { switch sortType { - case .published: - return .published(commentReply.published) + case .new: .new(commentReply.published) + case .old: .old(commentReply.published) } } } diff --git a/Mlem/Extensions/Tracker Items/UserContentModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/UserContentModel+TrackerItem.swift index d7ae4e78a..2e0abf645 100644 --- a/Mlem/Extensions/Tracker Items/UserContentModel+TrackerItem.swift +++ b/Mlem/Extensions/Tracker Items/UserContentModel+TrackerItem.swift @@ -19,7 +19,7 @@ extension UserContentModel: TrackerItem { } } - func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { switch self { case let .post(postModel): postModel.sortVal(sortType: sortType) case let .comment(hierarchicalComment): hierarchicalComment.sortVal(sortType: sortType) diff --git a/Mlem/Extensions/View Modifiers/View+CustomBadge.swift b/Mlem/Extensions/View Modifiers/View+CustomBadge.swift index 5b73f68f5..d8bef8cf9 100644 --- a/Mlem/Extensions/View Modifiers/View+CustomBadge.swift +++ b/Mlem/Extensions/View Modifiers/View+CustomBadge.swift @@ -34,7 +34,7 @@ struct CustomBadge: ViewModifier { @ViewBuilder var customBadge: some View { - if let count, count != 0 { + if let count, count > 0 { Text(count.description) .font(.system(size: 10)) .padding(.vertical, 1) diff --git a/Mlem/Extensions/View Modifiers/View+DestructiveConfirmation.swift b/Mlem/Extensions/View Modifiers/View+DestructiveConfirmation.swift index 97d302213..4e44bb5e3 100644 --- a/Mlem/Extensions/View Modifiers/View+DestructiveConfirmation.swift +++ b/Mlem/Extensions/View Modifiers/View+DestructiveConfirmation.swift @@ -8,23 +8,23 @@ import Foundation import SwiftUI -struct DestructiveConfirmation: ViewModifier { - let confirmationMenuFunction: StandardMenuFunction? - @Binding var isPresentingConfirmDestructive: Bool +struct MenuFunctionPopupView: ViewModifier { + @Binding var menuFunctionPopup: MenuFunctionPopup? func body(content: Content) -> some View { content - .confirmationDialog("Destructive Action Confirmation", isPresented: $isPresentingConfirmDestructive) { - if let destructiveCallback = confirmationMenuFunction?.callback { - Button("Yes", role: .destructive) { - Task { - destructiveCallback() - } + .confirmationDialog( + "Destructive Action Confirmation", + isPresented: Binding(get: { menuFunctionPopup != nil }, set: { _, _ in menuFunctionPopup = nil }) + ) { + if let actions = menuFunctionPopup?.actions { + ForEach(actions, id: \.text) { action in + Button(action.text, role: action.isDestructive ? .destructive : nil, action: action.callback) } } } message: { - if let destructivePrompt = confirmationMenuFunction?.destructiveActionPrompt { - Text(destructivePrompt) + if let prompt = menuFunctionPopup?.prompt { + Text(prompt) } } } @@ -49,12 +49,10 @@ extension View { /// - isPresentingConfirmDestructive: binding Bool to toggle the confirmation presentation /// - confirmationMenuFunction: menu function to confirm func destructiveConfirmation( - isPresentingConfirmDestructive: Binding, - confirmationMenuFunction: StandardMenuFunction? + menuFunctionPopup: Binding ) -> some View { - modifier(DestructiveConfirmation( - confirmationMenuFunction: confirmationMenuFunction, - isPresentingConfirmDestructive: isPresentingConfirmDestructive + modifier(MenuFunctionPopupView( + menuFunctionPopup: menuFunctionPopup )) } } diff --git a/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift b/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift index 966b3cd88..f1fee48af 100644 --- a/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift +++ b/Mlem/Extensions/View Modifiers/View+HandleLemmyLinks.swift @@ -36,8 +36,10 @@ struct HandleLemmyLinksDisplay: ViewModifier { UserView(user: user, communityContext: communityContext) .environmentObject(appState) .environmentObject(quickLookState) - case let .instance(domainName, instance): - InstanceView(domainName: domainName, instance: instance) + case let .instance(instance): + InstanceView(instance: instance) + case let .instanceFediseerOpinionList(instance, data: data, type: type): + FediseerOpinionListView(instance: instance, opinionType: type, fediseerData: data) case let .postLinkWithContext(postLink): ExpandedPost(post: postLink.post, community: postLink.community, scrollTarget: postLink.scrollTarget) .environmentObject(postLink.postTracker) @@ -45,8 +47,14 @@ struct HandleLemmyLinksDisplay: ViewModifier { .environmentObject(quickLookState) .environmentObject(layoutWidgetTracker) case let .lazyLoadPostLinkWithContext(post): - LazyLoadExpandedPost(post: post.post, scrollTarget: post.scrollTarget) + LazyLoadExpandedPost(postId: post.postId, scrollTarget: post.scrollTarget) .environmentObject(quickLookState) + case let .postVotes(post): + VotesListView(content: post) + case let .commentVotes(comment): + VotesListView(content: comment) + case let .modlog(modlogLink): + ModlogView(modlogLink: modlogLink) case let .settings(page): settingsDestination(for: page) case let .aboutSettings(page): @@ -59,6 +67,8 @@ struct HandleLemmyLinksDisplay: ViewModifier { postSettingsDestination(for: page) case let .licenseSettings(page): licensesSettingsDestination(for: page) + case let .moderationSettings(page): + moderationSettingsDestination(for: page) } } } @@ -77,6 +87,8 @@ struct HandleLemmyLinksDisplay: ViewModifier { AccountGeneralSettingsView() case .accountLocal: LocalAccountSettingsView() + case .blockList: + BlockListView() case .accountAdvanced: AdvancedAccountSettingsView() case .accountDiscussionLanguages: @@ -89,6 +101,10 @@ struct HandleLemmyLinksDisplay: ViewModifier { QuickSwitcherSettingsView() case .general: GeneralSettingsView() + case .links: + LinksSettingsView() + case .moderation: + ModerationSettingsView() case .sorting: SortingSettingsView() case .contentFilters: @@ -140,7 +156,7 @@ struct HandleLemmyLinksDisplay: ViewModifier { private func commentSettingsDestination(for page: CommentSettingsPage) -> some View { switch page { case .layoutWidget: - LayoutWidgetEditView(widgets: layoutWidgetTracker.groups.comment, onSave: { widgets in + LayoutWidgetEditView(mode: .user, widgets: layoutWidgetTracker.groups.comment, onSave: { widgets in layoutWidgetTracker.groups.comment = widgets layoutWidgetTracker.saveLayoutWidgets() }) @@ -152,7 +168,7 @@ struct HandleLemmyLinksDisplay: ViewModifier { switch page { case .customizeWidgets: /// We really should be passing in the layout widget through the route enum value, but that would involve making layout widget tracker hashable and codable. - LayoutWidgetEditView(widgets: layoutWidgetTracker.groups.post, onSave: { widgets in + LayoutWidgetEditView(mode: .user, widgets: layoutWidgetTracker.groups.post, onSave: { widgets in layoutWidgetTracker.groups.post = widgets layoutWidgetTracker.saveLayoutWidgets() }) @@ -166,6 +182,17 @@ struct HandleLemmyLinksDisplay: ViewModifier { DocumentView(text: doc.body) } } + + @ViewBuilder + private func moderationSettingsDestination(for page: ModerationSettingsPage) -> some View { + switch page { + case .customizeWidgets: + LayoutWidgetEditView(mode: .moderator, widgets: layoutWidgetTracker.groups.moderator) { widgets in + layoutWidgetTracker.groups.moderator = widgets + layoutWidgetTracker.saveLayoutWidgets() + } + } + } } struct HandleLemmyLinkResolution: ViewModifier { @@ -264,7 +291,11 @@ struct HandleLemmyLinkResolution: ViewModifier { do { switch resolution { case let .post(object): - try navigationPath.wrappedValue.append(Path.makeRoute(object)) + try navigationPath.wrappedValue.append(Path.makeRoute(PostLinkWithContext( + post: .init(from: object), + postTracker: .init(internetSpeed: .slow, sortType: .new, showReadPosts: true, feedType: .all) + ) + )) return true case let .person(object): try navigationPath.wrappedValue.append(Path.makeRoute(UserModel(from: object.person))) diff --git a/Mlem/Extensions/View Modifiers/View+SwipeyActions.swift b/Mlem/Extensions/View Modifiers/View+SwipeyActions.swift index 1e479b9b3..45d3af1a7 100644 --- a/Mlem/Extensions/View Modifiers/View+SwipeyActions.swift +++ b/Mlem/Extensions/View Modifiers/View+SwipeyActions.swift @@ -18,7 +18,15 @@ public struct SwipeAction { let symbol: Symbol let color: Color + let iconColor: Color? let action: () -> Void + + init(symbol: Symbol, color: Color, iconColor: Color? = nil, action: @escaping () -> Void) { + self.symbol = symbol + self.color = color + self.iconColor = iconColor + self.action = action + } } // MARK: - @@ -80,6 +88,7 @@ struct SwipeyView: ViewModifier { @State var dragPosition: CGFloat = .zero @State var prevDragPosition: CGFloat = .zero @State var dragBackground: Color? = .systemBackground + @State var iconColor: Color? = .white @State var leadingSwipeSymbol: String? @State var trailingSwipeSymbol: String? @@ -144,21 +153,25 @@ struct SwipeyView: ViewModifier { // update color and symbol. If crossed an edge, play a gentle haptic switch edgeForActions { case .leading: - leadingSwipeSymbol = actionIndex == nil - ? primaryLeadingAction?.symbol.emptyName - : action?.symbol.fillName - - dragBackground = actionIndex == nil - ? primaryLeadingAction?.color.opacity(dragPosition / threshold) - : action?.color.opacity(dragPosition / threshold) + if actionIndex == nil { + leadingSwipeSymbol = primaryLeadingAction?.symbol.emptyName + dragBackground = primaryLeadingAction?.color.opacity(dragPosition / threshold) + iconColor = primaryLeadingAction?.iconColor + } else { + leadingSwipeSymbol = action?.symbol.fillName + dragBackground = action?.color.opacity(dragPosition / threshold) + iconColor = action?.iconColor + } case .trailing: - trailingSwipeSymbol = actionIndex == nil - ? primaryTrailingAction?.symbol.emptyName - : action?.symbol.fillName - - dragBackground = actionIndex == nil - ? primaryTrailingAction?.color.opacity(dragPosition / threshold) - : action?.color.opacity(dragPosition / threshold) + if actionIndex == nil { + trailingSwipeSymbol = primaryTrailingAction?.symbol.emptyName + dragBackground = primaryTrailingAction?.color.opacity(dragPosition / threshold) + iconColor = primaryTrailingAction?.iconColor + } else { + trailingSwipeSymbol = action?.symbol.fillName + dragBackground = action?.color.opacity(dragPosition / threshold) + iconColor = action?.iconColor + } } // If crossed an edge, play a gentle haptic @@ -184,7 +197,7 @@ struct SwipeyView: ViewModifier { Image(systemName: trailingSwipeSymbol ?? Icons.warning) .font(.title) .frame(width: 20, height: 20) - .foregroundColor(.white) + .foregroundColor(iconColor ?? .white) .padding(.horizontal, 20) } .accessibilityHidden(true) // prevent these from popping up in VO @@ -214,6 +227,7 @@ struct SwipeyView: ViewModifier { leadingSwipeSymbol = primaryLeadingAction?.symbol.emptyName trailingSwipeSymbol = primaryTrailingAction?.symbol.emptyName dragBackground = .systemBackground + iconColor = .white } } diff --git a/Mlem/Extensions/View/View+PresentationBackgroundInteraction.swift b/Mlem/Extensions/View/View+PresentationBackgroundInteraction.swift index 376f71570..b8eae0c88 100644 --- a/Mlem/Extensions/View/View+PresentationBackgroundInteraction.swift +++ b/Mlem/Extensions/View/View+PresentationBackgroundInteraction.swift @@ -17,4 +17,12 @@ extension View { return self } } + + func _presentationCornerRadius(_ cornerRadius: CGFloat?) -> some View { + if #available(iOS 16.4, *) { + return self.presentationCornerRadius(cornerRadius) + } else { + return self + } + } } diff --git a/Mlem/Haptics/Haptic Manager.swift b/Mlem/Haptics/Haptic Manager.swift index 21b300c68..9d50bff0f 100644 --- a/Mlem/Haptics/Haptic Manager.swift +++ b/Mlem/Haptics/Haptic Manager.swift @@ -100,7 +100,7 @@ class HapticManager { handleEngineFailure(with: file) } } else { - print("no engine") + print("\(haptic.rawValue) not played (no engine)") } } } diff --git a/Mlem/Icons.swift b/Mlem/Icons.swift index 4f5f4de8d..f4ac07c41 100644 --- a/Mlem/Icons.swift +++ b/Mlem/Icons.swift @@ -11,7 +11,8 @@ import SwiftUI /// SFSymbol names for icons enum Icons { // votes - static let votes: String = "arrow.up.arrow.down.square" + static let votes: String = "arrow.up.arrow.down" + static let votesSquare: String = "arrow.up.arrow.down.square" static let upvote: String = "arrow.up" static let upvoteSquare: String = "arrow.up.square" static let upvoteSquareFill: String = "arrow.up.square.fill" @@ -43,16 +44,27 @@ enum Icons { static let moderation: String = "shield" static let moderationFill: String = "shield.fill" static let moderationReport: String = "exclamationmark.shield" + static let messageReportSetting: String = "envelope.badge.shield.half.filled" // misc post static let posts: String = "doc.plaintext" + static let postsFill: String = "doc.plaintext.fill" static let replies: String = "bubble.left" static let unreadReplies: String = "text.bubble" static let textPost: String = "text.book.closed" static let titleOnlyPost: String = "character.bubble" static let pinned: String = "pin.fill" + static let unpinned: String = "pin.slash.fill" static let websiteIcon: String = "globe" - static let hideRead: String = "book" + static let read: String = "book" + static let locked: String = "lock.fill" + static let unlocked: String = "lock.open.fill" + static let removed: String = "xmark.bin.fill" + static let restored: String = "arrow.up.bin.fill" + + // inbox + static let message = "envelope" + static let messageFill = "envelope.fill" // post sizes static let postSizeSetting: String = "rectangle.expand.vertical" @@ -66,16 +78,14 @@ enum Icons { // feeds 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" + static let moderatedFeed: String = moderation + static let moderatedFeedFill: String = moderationFill // sort types static let activeSort: String = "popcorn" @@ -88,8 +98,8 @@ enum Icons { static let newSortFill: String = "hare.fill" static let oldSort: String = "tortoise" static let oldSortFill: String = "tortoise.fill" - static let newCommentsSort: String = "exclamationmark.bubble" - static let newCommentsSortFill: String = "exclamationmark.bubble.fill" + static let newCommentsSort: String = "plus.bubble" + static let newCommentsSortFill: String = "plus.bubble.fill" static let mostCommentsSort: String = "bubble.left.and.bubble.right" static let mostCommentsSortFill: String = "bubble.left.and.bubble.right.fill" static let controversialSort: String = "bolt" @@ -102,10 +112,10 @@ enum Icons { // user flairs static let developerFlair: String = "hammer.fill" - static let adminFlair: String = "crown.fill" static let botFlair: String = "terminal.fill" static let opFlair: String = "person.fill" - static let bannedFlair: String = "multiply.circle" + static let instanceBannedFlair: String = "xmark.circle.fill" + static let communityBannedFlair: String = "xmark.shield.fill" // entities/general Lemmy concepts static let federation: String = "point.3.filled.connected.trianglepath.dotted" @@ -115,6 +125,10 @@ enum Icons { static let userBlock: String = "person.fill.xmark" static let community: String = "building.2.crop.circle" static let communityFill: String = "building.2.crop.circle.fill" + static let communityButton: String = "building.2" + static let admin: String = "crown" + static let adminFill: String = "crown.fill" + static let unAdmin: String = "cloud.bolt.fill" // idk what to do for this one // tabs static let feeds: String = "scroll" @@ -149,12 +163,19 @@ enum Icons { static let close: String = "multiply" static let cakeDay: String = "birthday.cake" + // uptime + static let uptimeOffline: String = "xmark.circle.fill" + static let uptimeOnline: String = "checkmark.circle.fill" + static let uptimeOutage: String = "exclamationmark.circle.fill" + // end of feed static let endOfFeedHobbit: String = "figure.climbing" static let endOfFeedCartoon: String = "figure.wave" + static let endOfFeedTurtle: String = "tortoise" // common operations static let share: String = "square.and.arrow.up" + static let add: String = "plus" static let subscribe: String = "plus.circle" static let subscribed: String = "checkmark.circle" static let subscribePerson: String = "person.crop.circle.badge.plus" @@ -171,6 +192,11 @@ enum Icons { static let edit: String = "pencil" static let delete: String = "trash" static let copy: String = "doc.on.doc" + static let copyFill: String = "doc.on.doc.fill" + static let paste: String = "doc.on.clipboard" + static let select: String = "selection.pin.in.out" + static let choosePhoto: String = "photo.on.rectangle" + static let chooseFile: String = "folder" // settings static let upvoteOnSave: String = "arrow.up.heart" @@ -191,6 +217,50 @@ enum Icons { static let appLockSettings: String = "lock.app.dashed" static let collapseComments: String = "arrow.down.and.line.horizontal.and.arrow.up" + // mod tools + static let auditUser: String = "person.crop.circle.badge.questionmark.fill" + static let communityBan: String = "xmark.shield" + static let communityBanFill: String = "xmark.shield.fill" + static let communityBanned: String = "xmark.shield.fill" + static let communityUnban: String = "checkmark.shield" + static let communityUnbanned: String = "checkmark.shield.fill" + static let instanceBan: String = "xmark.circle" + static let instanceUnban: String = "checkmark.circle" + static let instanceBanned: String = "xmark.circle.fill" + static let instanceUnbanned: String = "checkmark.circle.fill" + static let unmod: String = "shield.slash" + static let unmodFill: String = "shield.slash.fill" + static let pin: String = "pin" + static let unpin: String = "pin.slash" + static let lock: String = "lock" + static let unlock: String = "lock.open" + static let remove: String = "xmark.bin" + static let removeFill: String = "xmark.bin.fill" + static let purge: String = "burn" + static let restore: String = "arrow.up.bin" + static let restoreFill: String = "arrow.up.bin.fill" + static let commentReport: String = "text.bubble" + static let commentReportFill: String = "text.bubble.fill" + static let registrationApplication: String = "list.clipboard" + static let registrationApplicationFill: String = "list.clipboard.fill" + static let resolve: String = "checkmark.circle" + static let resolveFill: String = "checkmark.circle.fill" + static let unresolve: String = "checkmark.gobackward" + static let approve: String = "checkmark" + static let approveCircle: String = "checkmark.circle" + static let approveCircleFill: String = "checkmark.circle.fill" + static let deny: String = "xmark" + static let denyCircle: String = "xmark.circle" + static let denyCircleFill = "xmark.circle.fill" + + // fediseer + static let fediseer: String = "shield.checkered" + static let fediseerGuarantee: String = "checkmark.seal.fill" + static let fediseerUnguarantee: String = "xmark.seal.fill" + static let fediseerEndorsement: String = "signature" + static let fediseerHesitation: String = "exclamationmark.triangle.fill" + static let fediseerCensure: String = "exclamationmark.octagon.fill" + // misc static let `private`: String = "lock" static let email: String = "envelope" diff --git a/Mlem/Info.plist b/Mlem/Info.plist index 197de21db..a4a5a42b1 100644 --- a/Mlem/Info.plist +++ b/Mlem/Info.plist @@ -19,10 +19,6 @@ ITSAppUsesNonExemptEncryption - NSFaceIDUsageDescription - $(PRODUCT_NAME) requires Face ID permissions for app locking feature. - NSPhotoLibraryAddUsageDescription - NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/Mlem/Logic/Abbreviate Numbers.swift b/Mlem/Logic/Abbreviate Numbers.swift deleted file mode 100644 index 50e4385ae..000000000 --- a/Mlem/Logic/Abbreviate Numbers.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Abbreviate Numbers.swift -// Mlem -// -// Created by Sjmarf on 23/09/2023. -// - -import Foundation - -func abbreviateNumber(_ number: Int) -> String { - if number >= 10_000_000 { - return "\(Int(round(Double(number) / 1_000_000)))M" - } - if number >= 1_000_000 { - return "\(Double(round(Double(number) / 100_000) / 10))M" - } - if number >= 10_000 { - return "\(Int(round(Double(number) / 1000)))K" - } - if number >= 1000 { - return "\(Double(round(Double(number) / 100) / 10))K" - } - return String(number) -} diff --git a/Mlem/Logic/BiometricUnlock.swift b/Mlem/Logic/BiometricUnlock.swift index 82c5f3f10..bbda55b39 100644 --- a/Mlem/Logic/BiometricUnlock.swift +++ b/Mlem/Logic/BiometricUnlock.swift @@ -32,11 +32,11 @@ class BiometricUnlock: ObservableObject { let context = LAContext() var error: NSError? - if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { let reason = "Please authenticate to unlock app." - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in - DispatchQueue.main.sync { + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in + DispatchQueue.main.async { if success { self.isUnlocked = true onComplete(.success(())) @@ -56,7 +56,7 @@ class BiometricUnlock: ObservableObject { var error: NSError? let context = LAContext() - let isBioMetricsAvailable = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + let isBioMetricsAvailable = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) if let error { print("Biometrics error: \(error.localizedDescription)") diff --git a/Mlem/Logic/URLHandler.swift b/Mlem/Logic/URLHandler.swift index 7b7f9a037..b339f906c 100644 --- a/Mlem/Logic/URLHandler.swift +++ b/Mlem/Logic/URLHandler.swift @@ -30,7 +30,7 @@ class URLHandler { guard let scheme = url.scheme, scheme.hasPrefix("http") else { return .init(result: .systemAction, action: .error("This type of link is not currently supported 😞")) } - let openLinksInBrowser = UserDefaults.standard.bool(forKey: "openLinksInBrowser") + @AppStorage("openLinksInBrowser") var openLinksInBrowser = false if openLinksInBrowser { UIApplication.shared.open(url) } else { @@ -48,7 +48,8 @@ extension SFSafariViewController.Configuration { /// The default settings used in this application static var `default`: Self { let configuration = Self() - configuration.entersReaderIfAvailable = false + @AppStorage("openLinksInReaderMode") var openLinksInReaderMode = false + configuration.entersReaderIfAvailable = openLinksInReaderMode return configuration } } diff --git a/Mlem/MlemApp.swift b/Mlem/MlemApp.swift index 67d307351..cb3940ec8 100644 --- a/Mlem/MlemApp.swift +++ b/Mlem/MlemApp.swift @@ -73,7 +73,7 @@ struct MlemApp: App { private func setupAppShortcuts() { guard accountsTracker.savedAccounts.first != nil else { return } - UIApplication.shared.shortcutItems = FeedType.allAggregateFeedCases.map { feedType in + UIApplication.shared.shortcutItems = PostFeedType.allShortcutFeedCases.map { feedType in let icon = UIApplicationShortcutIcon(systemImageName: feedType.iconName) return UIApplicationShortcutItem( type: feedType.toShortcutString, diff --git a/Mlem/Models/Batchers/MarkReadBatcher.swift b/Mlem/Models/Batchers/MarkReadBatcher.swift new file mode 100644 index 000000000..7b97e41a8 --- /dev/null +++ b/Mlem/Models/Batchers/MarkReadBatcher.swift @@ -0,0 +1,100 @@ +// +// MarkReadBatcher.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-08. +// + +import Dependencies +import Foundation +import Semaphore + +/// Class to handle accumulating and dispatching batch mark read requests. It maintains three collections of post IDs: +/// - Staged: post IDs that are ready to mark as read, but should not be marked just yet (even if threshold is met) +/// - Pending: post IDs that are queued to be marked read +/// - Sending: post IDs currently being marked read +/// To mark a post as read, it must first be staged; `add()` will ignore any request for a non-staged post. This is done to interface smoothly with the view logic that handles mark read on scroll; posts are flagged to be marked read when a later post appears, but only marked read once they disappear. The later post simply calls `stage()` in an `onAppear()` and the post itself calls `add()` in an `onDisappear()`, and the staging logic handles the rest. +class MarkReadBatcher { + @Dependency(\.notifier) var notifier + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.postRepository) var postRepository + + private let loadingSemaphore: AsyncSemaphore = .init(value: 1) + private let stagedSemaphore: AsyncSemaphore = .init(value: 1) + + private(set) var enabled: Bool = false + private var staged: Set = .init() + private var pending: [Int] = .init() + private var sending: [Int] = .init() + + func resolveSiteVersion(to siteVersion: SiteVersion) { + enabled = siteVersion >= .init("0.19.0") + } + + func flush() async { + // only one thread may execute this function at a time to avoid duplicate requests + await loadingSemaphore.wait() + defer { loadingSemaphore.signal() } + + sending = pending + pending = .init() + + // perform this on background thread to return ASAP + Task { + await dispatchSending() + } + } + + func clearStaged() { + staged.removeAll() + } + + func dispatchSending() async { + guard sending.count > 0 else { + return + } + + do { + try await postRepository.markRead(postIds: sending, read: true) + } catch { + errorHandler.handle(error) + } + + sending = .init() + } + + func stage(_ postId: Int) async { + guard enabled else { + assertionFailure("Cannot stage to disabled batcher!") + return + } + + await stagedSemaphore.wait() + staged.insert(postId) + stagedSemaphore.signal() + } + + func add(post: PostModel) async { + guard enabled else { + assertionFailure("Cannot add to disabled batcher!") + return + } + + if pending.count >= 50 { + await flush() + } + + // This call is deliberately placed *after* the flush check to avoid the potential data race: + // - Threads 0 and 1 call add() at the same time + // - Thread 0 calls flush() and performs sending = pending + // - Thread 1 adds its id to pending + // - Thread 0 performs pending = .init(), and thread 1's id is lost forever! + await stagedSemaphore.wait() + if staged.contains(post.postId) { + pending.append(post.postId) + staged.remove(post.postId) + await post.setRead(true) + } + stagedSemaphore.signal() + } +} diff --git a/Mlem/Models/Composers/Administration/BanUserEditorModel.swift b/Mlem/Models/Composers/Administration/BanUserEditorModel.swift new file mode 100644 index 000000000..91376d163 --- /dev/null +++ b/Mlem/Models/Composers/Administration/BanUserEditorModel.swift @@ -0,0 +1,18 @@ +// +// BanUser.swift +// Mlem +// +// Created by Sjmarf on 26/01/2024. +// + +import Dependencies +import Foundation +import SwiftUI + +struct BanUserEditorModel: Identifiable { + @Dependency(\.commentRepository) var commentRepository + + let user: UserModel + var callback: (_ item: UserModel) -> Void = { _ in } + var id: Int { user.userId } +} diff --git a/Mlem/Models/Composers/Response Composers/Replies/ReplyToComment.swift b/Mlem/Models/Composers/Response Composers/Replies/ReplyToComment.swift index 0c6fda164..681d19c55 100644 --- a/Mlem/Models/Composers/Response Composers/Replies/ReplyToComment.swift +++ b/Mlem/Models/Composers/Response Composers/Replies/ReplyToComment.swift @@ -35,11 +35,9 @@ struct ReplyToComment: ResponseEditorModel { commentView: comment, isParentCollapsed: .constant(false), isCollapsed: .constant(false), - showPostContext: true, - menuFunctions: [], - links: [] + showPostContext: true ) - .padding(.horizontal)) + .padding(AppConstants.standardSpacing)) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Composers/Response Composers/Replies/ReplyToCommentReply.swift b/Mlem/Models/Composers/Response Composers/Replies/ReplyToCommentReply.swift index a690d4673..75f9b0ab9 100644 --- a/Mlem/Models/Composers/Response Composers/Replies/ReplyToCommentReply.swift +++ b/Mlem/Models/Composers/Response Composers/Replies/ReplyToCommentReply.swift @@ -21,8 +21,7 @@ struct ReplyToCommentReply: ResponseEditorModel { var id: Int { commentReply.id } func embeddedView() -> AnyView { - AnyView(InboxReplyBodyView(reply: commentReply) - .padding(.horizontal)) + AnyView(InboxReplyBodyView(reply: commentReply)) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Composers/Response Composers/Replies/ReplyToMention.swift b/Mlem/Models/Composers/Response Composers/Replies/ReplyToMention.swift index 42cbab91a..0aaa78ace 100644 --- a/Mlem/Models/Composers/Response Composers/Replies/ReplyToMention.swift +++ b/Mlem/Models/Composers/Response Composers/Replies/ReplyToMention.swift @@ -23,7 +23,6 @@ struct ReplyToMention: ResponseEditorModel { func embeddedView() -> AnyView { AnyView( InboxMentionBodyView(mention: mention) - .padding(.horizontal) ) } diff --git a/Mlem/Models/Composers/Response Composers/Replies/ReplyToMessage.swift b/Mlem/Models/Composers/Response Composers/Replies/ReplyToMessage.swift index 2e88ac81e..d7a748056 100644 --- a/Mlem/Models/Composers/Response Composers/Replies/ReplyToMessage.swift +++ b/Mlem/Models/Composers/Response Composers/Replies/ReplyToMessage.swift @@ -22,7 +22,7 @@ struct ReplyToMessage: ResponseEditorModel { func embeddedView() -> AnyView { AnyView(InboxMessageBodyView(message: message) - .padding(.horizontal, AppConstants.postAndCommentSpacing)) + .padding(AppConstants.standardSpacing)) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Composers/Response Composers/Replies/ReplyToPost.swift b/Mlem/Models/Composers/Response Composers/Replies/ReplyToPost.swift index cd4b7a6c5..80df5eb74 100644 --- a/Mlem/Models/Composers/Response Composers/Replies/ReplyToPost.swift +++ b/Mlem/Models/Composers/Response Composers/Replies/ReplyToPost.swift @@ -33,7 +33,7 @@ struct ReplyToPost: ResponseEditorModel { func embeddedView() -> AnyView { AnyView(LargePost(post: post, layoutMode: .constant(.maximize)) - .padding(.horizontal)) + .padding(AppConstants.standardSpacing)) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Composers/Response Composers/Reports/ReportComment.swift b/Mlem/Models/Composers/Response Composers/Reports/ReportComment.swift index 2f3fc2404..a09e1600e 100644 --- a/Mlem/Models/Composers/Response Composers/Reports/ReportComment.swift +++ b/Mlem/Models/Composers/Response Composers/Reports/ReportComment.swift @@ -24,11 +24,9 @@ struct ReportComment: ResponseEditorModel { commentView: comment, isParentCollapsed: .constant(false), isCollapsed: .constant(false), - showPostContext: true, - menuFunctions: [], - links: [] + showPostContext: true ) - .padding(.horizontal, AppConstants.postAndCommentSpacing)) + .padding(AppConstants.standardSpacing)) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Composers/Response Composers/Reports/ReportCommentReply.swift b/Mlem/Models/Composers/Response Composers/Reports/ReportCommentReply.swift index 435db4fdc..021adf7a5 100644 --- a/Mlem/Models/Composers/Response Composers/Reports/ReportCommentReply.swift +++ b/Mlem/Models/Composers/Response Composers/Reports/ReportCommentReply.swift @@ -20,8 +20,7 @@ struct ReportCommentReply: ResponseEditorModel { let commentReply: ReplyModel func embeddedView() -> AnyView { - AnyView(InboxReplyBodyView(reply: commentReply) - .padding(.horizontal)) + AnyView(InboxReplyBodyView(reply: commentReply)) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Composers/Response Composers/Reports/ReportMention.swift b/Mlem/Models/Composers/Response Composers/Reports/ReportMention.swift index 3f0a97399..012df63d2 100644 --- a/Mlem/Models/Composers/Response Composers/Reports/ReportMention.swift +++ b/Mlem/Models/Composers/Response Composers/Reports/ReportMention.swift @@ -22,7 +22,6 @@ struct ReportMention: ResponseEditorModel { func embeddedView() -> AnyView { AnyView( InboxMentionBodyView(mention: mention) - .padding(.horizontal) ) } diff --git a/Mlem/Models/Composers/Response Composers/Reports/ReportMessage.swift b/Mlem/Models/Composers/Response Composers/Reports/ReportMessage.swift index 7b4c950a9..a7c2de13a 100644 --- a/Mlem/Models/Composers/Response Composers/Reports/ReportMessage.swift +++ b/Mlem/Models/Composers/Response Composers/Reports/ReportMessage.swift @@ -22,7 +22,7 @@ struct ReportMessage: ResponseEditorModel { func embeddedView() -> AnyView { AnyView(InboxMessageBodyView(message: message) - .padding(.horizontal)) + .padding(AppConstants.standardSpacing)) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Composers/Response Composers/Reports/ReportPost.swift b/Mlem/Models/Composers/Response Composers/Reports/ReportPost.swift index 9210e5cb2..cecdbb4e3 100644 --- a/Mlem/Models/Composers/Response Composers/Reports/ReportPost.swift +++ b/Mlem/Models/Composers/Response Composers/Reports/ReportPost.swift @@ -21,8 +21,10 @@ struct ReportPost: ResponseEditorModel { let post: PostModel func embeddedView() -> AnyView { - AnyView(LargePost(post: post, layoutMode: .constant(.maximize)) - .padding(.horizontal, AppConstants.postAndCommentSpacing)) + AnyView( + LargePost(post: post, layoutMode: .constant(.maximize)) + .padding(AppConstants.standardSpacing) + ) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Content/Community/Community List/CommunityListModel.swift b/Mlem/Models/Content/Community/Community List/CommunityListModel.swift index ac03883f4..1d0976cd6 100644 --- a/Mlem/Models/Content/Community/Community List/CommunityListModel.swift +++ b/Mlem/Models/Content/Community/Community List/CommunityListModel.swift @@ -2,8 +2,7 @@ // CommunityListModel.swift // Mlem // -// Created by mormaer on 11/08/2023. -// +// Created by Eric Andrews on 2024-04-18. // import Combine @@ -15,38 +14,44 @@ class CommunityListModel: ObservableObject { @Dependency(\.errorHandler) var errorHandler @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker @Dependency(\.notifier) var notifier - @Dependency(\.mainQueue) var mainQueue - - @Published private(set) var communities = [APICommunity]() - private var subscriptions = [APICommunity]() - private var favoriteCommunities = [APICommunity]() private var cancellables = Set() + @Published private(set) var allSections: [CommunityListSection] = .init() + @Published private(set) var visibleSections: [CommunityListSection] = .init() + + private(set) var subscribed: [APICommunity] = .init() + private var subscribedSet: Set = .init() + private(set) var favorited: [APICommunity] = .init() + init() { favoriteCommunitiesTracker .$favoritesForCurrentAccount - .dropFirst() .sink { [weak self] value in - self?.updateFavorites(value) + if let self { + Task { + await self.updateFavorites(value) + } + } } .store(in: &cancellables) + let (newAllSections, newVisibleSections) = recomputeSections() + self.allSections = newAllSections + self.visibleSections = newVisibleSections } - // MARK: - Public methods - func load() async { do { // load our subscribed communities - let subscriptions = try await communityRepository + let newSubscribed = try await communityRepository .loadSubscriptions() .map(\.community) // load our favourite communities - let favorites = favoriteCommunitiesTracker.favoritesForCurrentAccount + let newFavorited = favoriteCommunitiesTracker.favoritesForCurrentAccount // combine the two lists - combine(subscriptions, favorites) + await update(newSubscribed, newFavorited) } catch { errorHandler.handle( .init(underlyingError: error) @@ -55,12 +60,12 @@ class CommunityListModel: ObservableObject { } func isSubscribed(to community: APICommunity) -> Bool { - subscriptions.contains(community) + subscribedSet.contains(community.id) } - func updateSubscriptionStatus(for community: APICommunity, subscribed: Bool) { + func updateSubscriptionStatus(for community: APICommunity, subscribed: Bool) async { // immediately update our local state - updateLocalStatus(for: community, subscribed: subscribed) + await updateLocalStatus(for: community, subscribed: subscribed) // then attempt to update our remote state Task { @@ -68,115 +73,21 @@ class CommunityListModel: ObservableObject { } } - var visibleSections: [CommunityListSection] { - allSections() - - // Only show sections which have labels to show - .filter { communitySection -> Bool in - communitySection.inlineHeaderLabel != nil - } - - // Only show letter headers for letters we have in our community list - .filter { communitySection -> Bool in - communities - .contains(where: { communitySection.sidebarEntry - .contains(community: $0, isSubscribed: isSubscribed(to: $0)) - }) - } - } - - func communities(for section: CommunityListSection) -> [APICommunity] { - // Filter down to sidebar entry which wants us - communities - .filter { community -> Bool in - section.sidebarEntry.contains(community: community, isSubscribed: isSubscribed(to: community)) - } - } - - func allSections() -> [CommunityListSection] { - var sections = [CommunityListSection]() - - sections.append( - withDependencies(from: self) { - CommunityListSection( - viewId: "top", - sidebarEntry: EmptySidebarEntry( - sidebarLabel: nil, - sidebarIcon: "line.3.horizontal" - ), - inlineHeaderLabel: nil, - accessibilityLabel: "Top of communities" - ) - } - ) - - sections.append( - withDependencies(from: self) { - CommunityListSection( - viewId: "favorites", - sidebarEntry: FavoritesSidebarEntry( - sidebarLabel: nil, - sidebarIcon: "star.fill" - ), - inlineHeaderLabel: "Favorites", - accessibilityLabel: "Favorited Communities" - ) - } - ) - - sections.append(contentsOf: alphabeticSections()) - - sections.append( - withDependencies(from: self) { - CommunityListSection( - viewId: "non_letter_titles", - sidebarEntry: RegexCommunityNameSidebarEntry( - communityNameRegex: /^[^a-zA-Z]/, - sidebarLabel: "#", - sidebarIcon: nil - ), - inlineHeaderLabel: "#", - accessibilityLabel: "Communities starting with a symbol or number" - ) - } - ) - - return sections - } - - func alphabeticSections() -> [CommunityListSection] { - let alphabet: [String] = .alphabet - return alphabet.map { character in - withDependencies(from: self) { - // This looks sinister but I didn't know how to string replace in a non-string based regex - CommunityListSection( - viewId: character, - sidebarEntry: RegexCommunityNameSidebarEntry( - communityNameRegex: (try? Regex("^[\(character.uppercased())\(character.lowercased())]"))!, - sidebarLabel: character, - sidebarIcon: nil - ), - inlineHeaderLabel: character, - accessibilityLabel: "Communities starting with the letter '\(character)'" - ) - } - } - } - - // MARK: - Private methods - - private func updateLocalStatus(for community: APICommunity, subscribed: Bool) { - var updatedSubscriptions = subscriptions + private func updateLocalStatus(for community: APICommunity, subscribed: Bool) async { + var newSubscribed = self.subscribed if subscribed { - updatedSubscriptions.append(community) + newSubscribed.append(community) } else { - if let index = updatedSubscriptions.firstIndex(where: { $0 == community }) { - updatedSubscriptions.remove(at: index) + if !subscribedSet.contains(community.id) { + assertionFailure("Tried to unsubscribe from already unsubscribed community \(community.fullyQualifiedName)") + } + if let index = newSubscribed.firstIndex(where: { $0 == community }) { + newSubscribed.remove(at: index) } } - combine(updatedSubscriptions, favoriteCommunities) + await update(newSubscribed, favorited) } private func updateRemoteStatus(for community: APICommunity, subscribed: Bool) async { @@ -189,10 +100,10 @@ class CommunityListModel: ObservableObject { await notifier.add(.success("Unsubscribed from \(community.name)")) } - if let indexToUpdate = subscriptions.firstIndex(where: { $0.id == updatedCommunity.id }) { - var updatedSubscriptions = subscriptions - updatedSubscriptions[indexToUpdate] = updatedCommunity - combine(updatedSubscriptions, favoriteCommunities) + if let indexToUpdate = self.subscribed.firstIndex(where: { $0.id == updatedCommunity.id }) { + var newSubscribed = self.subscribed + newSubscribed[indexToUpdate] = updatedCommunity + await update(newSubscribed, favorited) } } catch { let phrase = subscribed ? "subscribe to" : "unsubscribe from" @@ -205,28 +116,91 @@ class CommunityListModel: ObservableObject { ) // as the call failed, we need to revert the change to the local state - await MainActor.run { - updateLocalStatus(for: community, subscribed: !subscribed) - } + await updateLocalStatus(for: community, subscribed: !subscribed) + } + } + + private func updateFavorites(_ favorites: [APICommunity]) async { + await update(subscribed, favorites) + } + + private func update(_ subscribed: [APICommunity], _ favorited: [APICommunity]) async { + // store the values for future use + self.subscribed = subscribed + subscribedSet = Set(subscribed.lazy.map(\.id)) + self.favorited = favorited.sorted() + + let (newAllSections, newVisibleSections) = recomputeSections() + await MainActor.run { + self.allSections = newAllSections + self.visibleSections = newVisibleSections } } - private func updateFavorites(_ favorites: [APICommunity]) { - combine(subscriptions, favorites) + private func recomputeSections() -> (all: [CommunityListSection], visible: [CommunityListSection]) { + var newAllSections: [CommunityListSection] = .init() + var newVisibleSections: [CommunityListSection] = .init() + + let topSection = withDependencies(from: self) { + CommunityListSection( + viewId: "top", + sidebarEntry: .init(sidebarLabel: nil, sidebarIcon: "line.3.horizontal"), + inlineHeaderLabel: nil, + accessibilityLabel: "Top of communities", + communities: .init() + ) + } + newAllSections.append(topSection) + + let favoritesSection = withDependencies(from: self) { + CommunityListSection( + viewId: "favorites", + sidebarEntry: .init(sidebarLabel: nil, sidebarIcon: "star.fill"), + inlineHeaderLabel: "Favorites", + accessibilityLabel: "Favorited Communities", + communities: favorited + ) + } + newAllSections.append(favoritesSection) + if !favorited.isEmpty { + newVisibleSections.append(favoritesSection) + } + + let alphabeticSections = alphabeticSections() + + newAllSections.append(contentsOf: alphabeticSections) + newVisibleSections.append(contentsOf: alphabeticSections.filter { section in + !section.communities.isEmpty + }) + + return (all: newAllSections, visible: newVisibleSections) } - private func combine(_ subscriptions: [APICommunity], _ favorites: [APICommunity]) { - // store the values for future use... - self.subscriptions = subscriptions - favoriteCommunities = favorites + private func alphabeticSections() -> [CommunityListSection] { + let sections: [String: [APICommunity]] = .init( + grouping: subscribed, + by: { item in + if let first = item.name.strippingDiacritics.first, first.isLetter { + return first.uppercased() + } + return "#" + } + ) - // combine and sort the two lists, excluding duplicates - let combined = subscriptions + favorites.filter { !subscriptions.contains($0) } - let sorted = combined.sorted() + assert(sections.values.reduce(0) { x, communities in + x + communities.count + } == subscribed.count, "mapping operation produced mismatched counts") - // update our published value for the view to render - mainQueue.schedule { [weak self] in - self?.communities = sorted + return [String].labelAlphabet.map { character in + CommunityListSection( + viewId: character, + sidebarEntry: .init(sidebarLabel: character, sidebarIcon: nil), + inlineHeaderLabel: character, + accessibilityLabel: "Communities starting with \(character == "#" ? "a symbol or number" : character)", + communities: sections[character, default: .init()].sorted { lhs, rhs in + lhs.name.strippingDiacritics < rhs.name.strippingDiacritics + } + ) } } } diff --git a/Mlem/Models/Content/Community/Community List/CommunityListSection.swift b/Mlem/Models/Content/Community/Community List/CommunityListSection.swift index 049a3b97b..f35d2a0aa 100644 --- a/Mlem/Models/Content/Community/Community List/CommunityListSection.swift +++ b/Mlem/Models/Content/Community/Community List/CommunityListSection.swift @@ -11,7 +11,8 @@ import SwiftUI struct CommunityListSection: Identifiable { let id = UUID() let viewId: String - let sidebarEntry: any SidebarEntry + let sidebarEntry: SidebarEntry let inlineHeaderLabel: String? let accessibilityLabel: String + let communities: [APICommunity] } diff --git a/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift b/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift index 174efbeac..467381ab2 100644 --- a/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift +++ b/Mlem/Models/Content/Community/CommunityModel+MenuFunctions.swift @@ -13,7 +13,6 @@ extension CommunityModel { .standardMenuFunction( text: "New Post", imageName: Icons.sendFill, - destructiveActionPrompt: nil, enabled: true ) { editorTracker.openEditor(with: PostEditorModel( @@ -23,34 +22,38 @@ extension CommunityModel { } } - func subscribeMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) throws -> StandardMenuFunction { + func subscribeMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) throws -> MenuFunction { guard let subscribed else { throw CommunityError.noData } - return .init( - text: subscribed ? "Unsubscribe" : "Subscribe", - imageName: subscribed ? Icons.unsubscribe : Icons.subscribe, - destructiveActionPrompt: subscribed ? "Are you sure you want to unsubscribe from \(name!)?" : nil, - enabled: true, - callback: { - Task { - do { - try await self.toggleSubscribe(callback) - } catch { - errorHandler.handle(error) - } + let callback = { + Task { + do { + try await self.toggleSubscribe(callback) + } catch { + errorHandler.handle(error) } } + return () + } + + if subscribed { + return .standardMenuFunction( + text: "Unsubscribe", + imageName: Icons.unsubscribe, + confirmationPrompt: "Are you sure you want to unsubscribe from \(name!)?", + callback: callback + ) + } + return .standardMenuFunction( + text: "Subscribe", + imageName: Icons.subscribe, + callback: callback ) } - func favoriteMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> StandardMenuFunction { - .init( - text: favorited ? "Unfavorite" : "Favorite", - imageName: favorited ? Icons.unfavorite : Icons.favorite, - destructiveActionPrompt: favorited ? "Really unfavorite \(name ?? "this community")?" : nil, - enabled: true - ) { + func favoriteMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> MenuFunction { + let callback = { Task { do { try await self.toggleFavorite(callback) @@ -58,6 +61,34 @@ extension CommunityModel { errorHandler.handle(error) } } + return () + } + + if favorited { + return .standardMenuFunction( + text: "Unfavorite", + imageName: Icons.unfavorite, + confirmationPrompt: "Really unfavorite \(name ?? "this community")?", + callback: callback + ) + } + return .standardMenuFunction(text: "Favorite", imageName: Icons.favorite, callback: callback) + } + + func blockCallback(_ callback: @escaping (_ item: Self) -> Void = { _ in }) { + let blocked = blocked ?? false + Task { + do { + var new = self + try await new.toggleBlock(callback) + if new.blocked != blocked { + await notifier.add(.success("\(blocked ? "Unblocked" : "Blocked") community")) + } else { + await notifier.add(.failure("Failed to \(blocked ? "block" : "block") community")) + } + } catch { + errorHandler.handle(error) + } } } @@ -65,26 +96,23 @@ extension CommunityModel { guard let blocked else { throw CommunityError.noData } + + if blocked { + return .standardMenuFunction(text: "Unblock", imageName: Icons.show, callback: { blockCallback(callback) }) + } return .standardMenuFunction( - text: blocked ? "Unblock" : "Block", - imageName: blocked ? Icons.show : Icons.hide, - destructiveActionPrompt: blocked ? nil : AppConstants.blockCommunityPrompt, - enabled: true, - callback: { - Task { - do { - try await self.toggleBlock(callback) - } catch { - errorHandler.handle(error) - } - } - } + text: "Block", + imageName: Icons.hide, + confirmationPrompt: AppConstants.blockCommunityPrompt, + callback: { blockCallback(callback) } ) } + // swiftlint:disable:next function_body_length func menuFunctions( editorTracker: EditorTracker? = nil, postTracker: StandardPostTracker? = nil, + modToolTracker: ModToolTracker? = nil, _ callback: @escaping (_ item: Self) -> Void = { _ in } ) -> [MenuFunction] { var functions: [MenuFunction] = .init() @@ -92,30 +120,32 @@ extension CommunityModel { functions.append(newPostMenuFunction(editorTracker: editorTracker, postTracker: postTracker)) } if let function = try? subscribeMenuFunction(callback) { - functions.append(.standard(function)) - functions.append(.standard(favoriteMenuFunction(callback))) + functions.append(function) + functions.append(favoriteMenuFunction(callback)) } - if let instanceHost = communityUrl.host() { - let instance: InstanceModel? - if let site { - instance = .init(from: site) - } else { - instance = nil - } - functions.append( - .navigationMenuFunction( - text: instanceHost, - imageName: Icons.instance, - destination: .instance(instanceHost, instance) + do { + if let instanceHost = communityUrl.host() { + var instance: InstanceModel + if let site { + instance = .init(from: site, isLocal: true) + } else { + instance = try .init(domainName: instanceHost) + } + functions.append( + .navigationMenuFunction( + text: instanceHost, + imageName: Icons.instance, + destination: .instance(instance) + ) ) - ) + } + } catch { + print("Failed to add instance menu function!") } functions.append( .standardMenuFunction( text: "Copy Name", imageName: Icons.copy, - destructiveActionPrompt: nil, - enabled: true, callback: copyFullyQualifiedName ) ) @@ -124,6 +154,28 @@ extension CommunityModel { functions.append(function) } + if siteInformation.isAdmin, let modToolTracker { + functions.append(.divider) + functions.append( + .toggleableMenuFunction( + toggle: removed, + trueText: "Restore", + trueImageName: Icons.restore, + falseText: "Remove", + falseImageName: Icons.remove, + isDestructive: .whenFalse, + callback: { + modToolTracker.removeCommunity(self, shouldRemove: !removed) + } + ) + ) + functions.append( + .standardMenuFunction(text: "Purge", imageName: Icons.purge, isDestructive: true) { + modToolTracker.purgeContent(self) + } + ) + } + return functions } } diff --git a/Mlem/Models/Content/Community/CommunityModel+SwipeActions.swift b/Mlem/Models/Content/Community/CommunityModel+SwipeActions.swift index 087d88add..7d3595477 100644 --- a/Mlem/Models/Content/Community/CommunityModel+SwipeActions.swift +++ b/Mlem/Models/Content/Community/CommunityModel+SwipeActions.swift @@ -6,11 +6,12 @@ // import Foundation +import SwiftUI extension CommunityModel { func subscribeSwipeAction( - _ callback: @escaping (_ item: Self) -> Void = { _ in }, - confirmDestructive: ((StandardMenuFunction) -> Void)? = nil + _ trackerCallback: @escaping (_ item: Self) -> Void = { _ in }, + menuFunctionPopup: Binding ) throws -> SwipeAction { guard let subscribed else { throw CommunityError.noData @@ -22,28 +23,32 @@ extension CommunityModel { symbol: .init(emptyName: emptySymbolName, fillName: fullSymbolName), color: subscribed ? .red : .green, action: { - Task { - hapticManager.play(haptic: .lightSuccess, priority: .low) - - if subscribed, let confirmDestructive { - if let function = try? subscribeMenuFunction(callback) { - confirmDestructive(function) - } - } else { + hapticManager.play(haptic: .lightSuccess, priority: .low) + let callback = { + Task { do { - try await self.toggleSubscribe(callback) + try await self.toggleSubscribe(trackerCallback) } catch { errorHandler.handle(error) } } + return () + } + if subscribed { + menuFunctionPopup.wrappedValue = .init( + prompt: "Are you sure you want to unsubscribe from \(name!)?", + actions: [.init(text: "Yes", callback: callback)] + ) + } else { + callback() } } ) } func favoriteSwipeAction( - _ callback: @escaping (_ item: Self) -> Void = { _ in }, - confirmDestructive: ((StandardMenuFunction) -> Void)? = nil + _ trackerCallback: @escaping (_ item: Self) -> Void = { _ in }, + menuFunctionPopup: Binding ) -> SwipeAction { let (emptySymbolName, fullSymbolName) = favorited ? (Icons.unfavorite, Icons.unfavoriteFill) @@ -52,26 +57,35 @@ extension CommunityModel { 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 { - try await self.toggleFavorite(callback) + let callback = { + Task { + do { + try await self.toggleFavorite(trackerCallback) + } catch { + errorHandler.handle(error) + } } + return () + } + if favorited { + menuFunctionPopup.wrappedValue = .init( + prompt: "Are you sure you want to unfavorite \(name!)?", + actions: [.init(text: "Yes", callback: callback)] + ) + } else { + callback() } } ) } func swipeActions( - _ callback: @escaping (_ item: Self) -> Void = { _ in }, - confirmDestructive: ((StandardMenuFunction) -> Void)? = nil + _ trackerCallback: @escaping (_ item: Self) -> Void = { _ in }, + menuFunctionPopup: Binding ) -> SwipeConfiguration { var trailingActions: [SwipeAction] = [] - let subscribeAction = try? subscribeSwipeAction(callback, confirmDestructive: confirmDestructive) - let favoriteAction = favoriteSwipeAction(callback, confirmDestructive: confirmDestructive) + let subscribeAction = try? subscribeSwipeAction(trackerCallback, menuFunctionPopup: menuFunctionPopup) + let favoriteAction = favoriteSwipeAction(trackerCallback, menuFunctionPopup: menuFunctionPopup) if let subscribeAction { trailingActions.append(subscribeAction) diff --git a/Mlem/Models/Content/Community/CommunityModel.swift b/Mlem/Models/Content/Community/CommunityModel.swift index 86ae26b11..f98fb90d0 100644 --- a/Mlem/Models/Content/Community/CommunityModel.swift +++ b/Mlem/Models/Content/Community/CommunityModel.swift @@ -15,13 +15,17 @@ struct ActiveUserCount { let day: Int } -struct CommunityModel { +// swiftlint:disable:next type_body_length +struct CommunityModel: Purgable { + // MARK: - Members and Init + @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 + @Dependency(\.siteInformation) var siteInformation enum CommunityError: Error { case noData @@ -86,11 +90,14 @@ struct CommunityModel { update(with: communityView) } - init(from community: APICommunity, subscribed: Bool? = nil) { + init(from community: APICommunity, subscribed: Bool? = nil, blocked: Bool? = nil) { update(with: community) if let subscribed { self.subscribed = subscribed } + if let blocked { + self.blocked = blocked + } } mutating func update(with response: CommunityResponse) { @@ -151,6 +158,19 @@ struct CommunityModel { favorited = favoriteCommunitiesTracker.isFavorited(community) } + // MARK: - Convenience + + func isModerator(_ userId: Int?) -> Bool { + if let moderators, let userId { + return moderators.contains(where: { userModel in + userModel.userId == userId + }) + } + return false + } + + // MARK: - Interactions + func toggleSubscribe(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async throws { var new = self guard let subscribed, let subscriberCount else { @@ -211,14 +231,13 @@ struct CommunityModel { } } - func toggleBlock(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async throws { - var new = self + mutating func toggleBlock(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async throws { guard let blocked else { throw CommunityError.noData } - new.blocked = !blocked - RunLoop.main.perform { [new] in - callback(new) + self.blocked = !blocked + RunLoop.main.perform { [self] in + callback(self) } do { let response: BlockCommunityResponse @@ -227,9 +246,9 @@ struct CommunityModel { } else { response = try await communityRepository.unblockCommunity(id: communityId) } - new.update(with: response.communityView) - RunLoop.main.perform { [new] in - callback(new) + update(with: response.communityView) + RunLoop.main.perform { [self] in + callback(self) } } catch { hapticManager.play(haptic: .failure, priority: .high) @@ -241,6 +260,93 @@ struct CommunityModel { } } + // MARK: - Moderation + + func toggleRemove( + reason: String?, + callback: @escaping (_ item: Self) -> Void = { _ in }, + onFailure: () -> Void + ) async { + // no need to state fake because removal masked by sheet + do { + let response = try await apiClient.removeCommunity( + id: communityId, + shouldRemove: !removed, + reason: reason + ) + callback(.init(from: response)) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + } + } + + func purge(reason: String?) async -> Bool { + do { + let response = try await apiClient.purgeCommunity(id: communityId, reason: reason) + if !response.success { + throw APIClientError.unexpectedResponse + } + return true + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + } + return false + } + + func banUser( + userId: Int, + ban: Bool, + removeData: Bool? = nil, + reason: String? = nil, + expires: Int? = nil + ) async -> Bool { + do { + let updatedBannedStatus = try await apiClient.banFromCommunity( + userId: userId, + communityId: communityId, + ban: ban, + removeData: removeData, + reason: reason, + expires: expires + ) + return updatedBannedStatus + } catch { + errorHandler.handle(error) + return !ban + } + } + + /// Updates mod status of the given user in this community and updates the mod list + /// - Parameters: + /// - of: id of the user to change mod status of + /// - to: new mod status + /// - Returns: true on successful update, false otherwise + func updateModStatus(of userId: Int, to status: Bool, callback: @escaping (_ item: Self) -> Void = { _ in }) async -> Bool { + var new = self + do { + let newModerators = try await apiClient.updateModStatus(of: userId, in: communityId, status: status) + new.moderators = newModerators + RunLoop.main.perform { [new] in + callback(new) + } + return true + } catch { + errorHandler.handle(error) + return false + } + } + + // MARK: - Misc + + var fullyQualifiedNameComponents: (String, String)? { + if let host = communityUrl.host() { + return (name!, host) + } + return nil + } + var fullyQualifiedName: String? { if let host = communityUrl.host() { return "\(name!)@\(host)" @@ -283,6 +389,7 @@ extension CommunityModel: Hashable { hasher.combine(favorited) hasher.combine(subscriberCount) hasher.combine(blocked) + hasher.combine(removed) hasher.combine(moderators?.map(\.id) ?? []) } } diff --git a/Mlem/Models/Content/Inbox/CommentReportModel.swift b/Mlem/Models/Content/Inbox/CommentReportModel.swift new file mode 100644 index 000000000..c81a23b54 --- /dev/null +++ b/Mlem/Models/Content/Inbox/CommentReportModel.swift @@ -0,0 +1,282 @@ +// +// CommentReportModel.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-27. +// + +import Dependencies +import Foundation + +class CommentReportModel: ContentIdentifiable, ObservableObject { + @Dependency(\.apiClient) var apiClient + @Dependency(\.hapticManager) var hapticManager + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation + + var reporter: UserModel + var resolver: UserModel? + @Published var commentCreator: UserModel + var community: CommunityModel + var commentReport: APICommentReport + @Published var comment: APIComment + @Published var votes: VotesModel + @Published var numReplies: Int + @Published var commentCreatorBannedFromCommunity: Bool + @Published var purged: Bool + + var uid: ContentModelIdentifier { .init(contentType: .commentReport, contentId: commentReport.id) } + + init( + reporter: UserModel, + resolver: UserModel?, + commentCreator: UserModel, + community: CommunityModel, + commentReport: APICommentReport, + comment: APIComment, + votes: VotesModel, + numReplies: Int, + commentCreatorBannedFromCommunity: Bool + ) { + self.reporter = reporter + self.resolver = resolver + self.commentCreator = commentCreator + self.community = community + self.commentReport = commentReport + self.comment = comment + self.votes = votes + self.numReplies = numReplies + self.commentCreatorBannedFromCommunity = commentCreatorBannedFromCommunity + self.purged = false + } + + @MainActor + func reinit(from commentReport: CommentReportModel) { + reporter = commentReport.reporter + resolver = commentReport.resolver + commentCreator = commentReport.commentCreator + community = commentReport.community + self.commentReport = commentReport.commentReport + comment = commentReport.comment + votes = commentReport.votes + numReplies = commentReport.numReplies + commentCreatorBannedFromCommunity = commentCreatorBannedFromCommunity + purged = commentReport.purged + } + + @MainActor + func setPurged(_ newPurged: Bool) { + purged = newPurged + } + + func toggleResolved(unreadTracker: UnreadTracker, withHaptic: Bool = true) async { + let originalReadState: Bool = read + + if withHaptic { + hapticManager.play(haptic: .lightSuccess, priority: .low) + } + do { + let response = try await apiClient.markCommentReportResolved(reportId: commentReport.id, resolved: !commentReport.resolved) + await reinit(from: response) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.commentReports.toggleRead(originalState: originalReadState) + } + } catch { + errorHandler.handle(error) + } + } + + func toggleCommentRemoved(modToolTracker: ModToolTracker, unreadTracker: UnreadTracker) { + modToolTracker.removeComment(self, shouldRemove: !comment.removed) { + if !self.read { + Task { + await self.toggleResolved(unreadTracker: unreadTracker, withHaptic: false) + } + } + } + } + + func toggleCommentCreatorBanned(modToolTracker: ModToolTracker, inboxTracker: InboxTracker, unreadTracker: UnreadTracker) { + modToolTracker.banUser( + commentCreator, + from: community, + bannedFromCommunity: commentCreatorBannedFromCommunity, + shouldBan: !commentCreatorBannedFromCommunity, + userRemovalWalker: .init(inboxTracker: inboxTracker) + ) { + if !self.read { + Task(priority: .userInitiated) { + await self.toggleResolved(unreadTracker: unreadTracker, withHaptic: false) + } + } + } + } + + func purgeComment(modToolTracker: ModToolTracker) { + modToolTracker.purgeContent(self) + } + + func genMenuFunctions(modToolTracker: ModToolTracker, inboxTracker: InboxTracker, unreadTracker: UnreadTracker) -> [MenuFunction] { + var ret: [MenuFunction] = .init() + + ret.append(.toggleableMenuFunction( + toggle: commentReport.resolved, + trueText: "Unresolve", + trueImageName: Icons.unresolve, + falseText: "Resolve", + falseImageName: Icons.resolve + ) { + Task(priority: .userInitiated) { + await self.toggleResolved(unreadTracker: unreadTracker) + } + } + ) + + ret.append(.toggleableMenuFunction( + toggle: comment.removed, + trueText: "Restore", + trueImageName: Icons.restore, + falseText: "Remove", + falseImageName: Icons.remove, + isDestructive: .whenFalse + ) { + self.toggleCommentRemoved(modToolTracker: modToolTracker, unreadTracker: unreadTracker) + } + ) + + ret.append(.toggleableMenuFunction( + toggle: commentCreatorBannedFromCommunity, + trueText: "Unban", + trueImageName: Icons.communityUnban, + falseText: "Ban", + falseImageName: Icons.communityBan, + isDestructive: .whenFalse + ) { + self.toggleCommentCreatorBanned( + modToolTracker: modToolTracker, + inboxTracker: inboxTracker, + unreadTracker: unreadTracker + ) + } + ) + + ret.append(.standardMenuFunction( + text: "Purge", + imageName: Icons.purge, + isDestructive: true + ) { + self.purgeComment(modToolTracker: modToolTracker) + } + ) + + return ret + } + + func swipeActions( + modToolTracker: ModToolTracker, + inboxTracker: InboxTracker, + unreadTracker: UnreadTracker + ) -> SwipeConfiguration { + var leadingActions: [SwipeAction] = .init() + var trailingActions: [SwipeAction] = .init() + + leadingActions.append(SwipeAction( + symbol: .init( + emptyName: read ? Icons.resolveFill : Icons.resolve, + fillName: read ? Icons.resolve : Icons.resolveFill + ), + color: .green + ) { + Task(priority: .userInitiated) { + await self.toggleResolved(unreadTracker: unreadTracker) + } + }) + leadingActions.append(SwipeAction( + symbol: .init( + emptyName: comment.removed ? Icons.restore : Icons.remove, + fillName: comment.removed ? Icons.restoreFill : Icons.removeFill + ), + color: .red + ) { + self.toggleCommentRemoved(modToolTracker: modToolTracker, unreadTracker: unreadTracker) + }) + + trailingActions.append(SwipeAction( + symbol: .init( + emptyName: creatorBannedFromCommunity ? Icons.communityUnban : Icons.communityBan, + fillName: creatorBannedFromCommunity ? Icons.communityUnbanned : Icons.communityBanFill + ), + color: .red + ) { + self.toggleCommentCreatorBanned( + modToolTracker: modToolTracker, + inboxTracker: inboxTracker, + unreadTracker: unreadTracker + ) + }) + + if siteInformation.isAdmin { + trailingActions.append(SwipeAction( + symbol: .init(emptyName: Icons.purge, fillName: Icons.purge), + color: .primary, + iconColor: .systemBackground + ) { + modToolTracker.purgeContent(self) + }) + } + + return SwipeConfiguration(leadingActions: leadingActions, trailingActions: trailingActions) + } +} + +extension CommentReportModel: Removable, Purgable { + func remove(reason: String?, shouldRemove: Bool) async -> Bool { + do { + let response = try await apiClient.removeComment( + id: comment.id, + shouldRemove: shouldRemove, + reason: reason + ) + if response.commentView.comment.removed == shouldRemove { + await MainActor.run { + self.comment.removed = shouldRemove + } + } + return true + } catch { + errorHandler.handle(error) + } + return false + } + + func purge(reason: String?) async -> Bool { + do { + let response = try await apiClient.purgeComment(id: comment.id, reason: reason) + if response.success { + await setPurged(true) + // don't need to actually call toggleResolved()--purge removes the report altogether, but this is less jarring than removing it from feed + await MainActor.run { + self.commentReport.resolved = true + } + return true + } + } catch { + errorHandler.handle(error) + } + return false + } +} + +extension CommentReportModel: Hashable, Equatable { + static func == (lhs: CommentReportModel, rhs: CommentReportModel) -> Bool { + lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(reporter) + hasher.combine(commentReport) + hasher.combine(comment) + hasher.combine(votes) + hasher.combine(numReplies) + } +} diff --git a/Mlem/Models/Content/Inbox/MentionModel.swift b/Mlem/Models/Content/Inbox/MentionModel.swift index d3339e519..599e7557d 100644 --- a/Mlem/Models/Content/Inbox/MentionModel.swift +++ b/Mlem/Models/Content/Inbox/MentionModel.swift @@ -7,6 +7,9 @@ import Dependencies import Foundation +import SwiftUI + +// swiftlint:disable file_length /// Internal representation of a person mention class MentionModel: ContentIdentifiable, ObservableObject { @@ -24,8 +27,9 @@ class MentionModel: ContentIdentifiable, ObservableObject { var recipient: APIPerson @Published var numReplies: Int @Published var votes: VotesModel - @Published var creatorBannedFromCommunity: Bool + @Published var commentCreatorBannedFromCommunity: Bool @Published var subscribed: APISubscribedStatus + @Published var read: Bool @Published var saved: Bool @Published var creatorBlocked: Bool @@ -45,6 +49,7 @@ class MentionModel: ContentIdentifiable, ObservableObject { votes: VotesModel, creatorBannedFromCommunity: Bool, subscribed: APISubscribedStatus, + read: Bool, saved: Bool, creatorBlocked: Bool ) { @@ -56,8 +61,9 @@ class MentionModel: ContentIdentifiable, ObservableObject { self.recipient = recipient self.numReplies = numReplies self.votes = votes - self.creatorBannedFromCommunity = creatorBannedFromCommunity + self.commentCreatorBannedFromCommunity = creatorBannedFromCommunity self.subscribed = subscribed + self.read = read self.saved = saved self.creatorBlocked = creatorBlocked } @@ -71,8 +77,9 @@ class MentionModel: ContentIdentifiable, ObservableObject { self.recipient = personMentionView.recipient self.numReplies = personMentionView.counts.childCount self.votes = VotesModel(from: personMentionView.counts, myVote: personMentionView.myVote) - self.creatorBannedFromCommunity = personMentionView.creatorBannedFromCommunity + self.commentCreatorBannedFromCommunity = personMentionView.creatorBannedFromCommunity self.subscribed = personMentionView.subscribed + self.read = personMentionView.personMention.read self.saved = personMentionView.saved self.creatorBlocked = personMentionView.creatorBlocked } @@ -89,6 +96,7 @@ class MentionModel: ContentIdentifiable, ObservableObject { votes: VotesModel? = nil, creatorBannedFromCommunity: Bool? = nil, subscribed: APISubscribedStatus? = nil, + read: Bool? = nil, saved: Bool? = nil, creatorBlocked: Bool? = nil ) { @@ -100,8 +108,9 @@ class MentionModel: ContentIdentifiable, ObservableObject { self.recipient = recipient ?? mentionModel.recipient self.numReplies = numReplies ?? mentionModel.numReplies self.votes = votes ?? mentionModel.votes - self.creatorBannedFromCommunity = creatorBannedFromCommunity ?? mentionModel.creatorBannedFromCommunity + self.commentCreatorBannedFromCommunity = creatorBannedFromCommunity ?? mentionModel.commentCreatorBannedFromCommunity self.subscribed = subscribed ?? mentionModel.subscribed + self.read = read ?? mentionModel.read self.saved = saved ?? mentionModel.saved self.creatorBlocked = creatorBlocked ?? mentionModel.creatorBlocked } @@ -118,6 +127,16 @@ extension MentionModel { votes = newVotes } + @MainActor + func setSaved(_ newSaved: Bool) { + saved = newSaved + } + + @MainActor + func setRead(_ newRead: Bool) { + read = newRead + } + /// Re-initializes all fields to match the given MentionModel @MainActor func reinit(from mentionModel: MentionModel) { @@ -128,12 +147,15 @@ extension MentionModel { community = mentionModel.community recipient = mentionModel.recipient votes = mentionModel.votes - creatorBannedFromCommunity = mentionModel.creatorBannedFromCommunity + commentCreatorBannedFromCommunity = mentionModel.commentCreatorBannedFromCommunity subscribed = mentionModel.subscribed saved = mentionModel.saved creatorBlocked = mentionModel.creatorBlocked } + func toggleUpvote(unreadTracker: UnreadTracker) async { await vote(inputOp: .upvote, unreadTracker: unreadTracker) } + func toggleDownvote(unreadTracker: UnreadTracker) async { await vote(inputOp: .downvote, unreadTracker: unreadTracker) } + func vote(inputOp: ScoringOperation, unreadTracker: UnreadTracker) async { guard !voting else { return @@ -156,7 +178,7 @@ extension MentionModel { await reinit(from: updatedMention) if !original.personMention.read { _ = try await inboxRepository.markMentionRead(id: personMention.id, isRead: true) - await unreadTracker.readMention() + await unreadTracker.mentions.read() } } catch { hapticManager.play(haptic: .failure, priority: .high) @@ -177,8 +199,10 @@ extension MentionModel { // call API and either update with latest info or revert state fake on fail do { let newMessage = try await inboxRepository.markMentionRead(id: personMention.id, isRead: personMention.read) - await unreadTracker.toggleMentionRead(originalState: originalPersonMention.read) await reinit(from: newMessage) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.mentions.toggleRead(originalState: originalPersonMention.read) + } } catch { hapticManager.play(haptic: .failure, priority: .high) errorHandler.handle(error) @@ -186,6 +210,44 @@ extension MentionModel { } } + func toggleSave(unreadTracker: UnreadTracker) async { + hapticManager.play(haptic: .success, priority: .low) + + let shouldSave: Bool = !saved + @AppStorage("upvoteOnSave") var upvoteOnSave = false + + // state fake + let original: MentionModel = .init(from: self) + await setSaved(shouldSave) + await setRead(true) + if shouldSave, upvoteOnSave, votes.myVote != .upvote { + await setVotes(votes.applyScoringOperation(operation: .upvote)) + } + + // API call + do { + let saveResponse = try await inboxRepository.saveMention(self, shouldSave: shouldSave) + + if shouldSave, upvoteOnSave { + let voteResponse = try await inboxRepository.voteOnMention(self, vote: .upvote) + await reinit(from: voteResponse) + } else { + await reinit(from: saveResponse) + } + if !original.personMention.read { + _ = try await inboxRepository.markMentionRead(id: personMention.id, isRead: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.mentions.toggleRead(originalState: original.read) + } + } + + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await reinit(from: original) + } + } + @MainActor func reply(editorTracker: EditorTracker, unreadTracker: UnreadTracker) { editorTracker.openEditor(with: ConcreteEditorModel( @@ -196,7 +258,7 @@ extension MentionModel { // replying to a message marks it as read, but the call doesn't return anything so we just state fake it here if !personMention.read { setPersonMention(APIPersonMention(from: personMention, read: true)) - unreadTracker.readMention() + unreadTracker.mentions.read() } } @@ -239,9 +301,7 @@ extension MentionModel { // upvote ret.append(MenuFunction.standardMenuFunction( text: votes.myVote == .upvote ? "Undo Upvote" : "Upvote", - imageName: votes.myVote == .upvote ? Icons.upvoteSquareFill : Icons.upvoteSquare, - destructiveActionPrompt: nil, - enabled: true + imageName: votes.myVote == .upvote ? Icons.upvoteSquareFill : Icons.upvoteSquare ) { Task(priority: .userInitiated) { await self.vote(inputOp: .upvote, unreadTracker: unreadTracker) @@ -251,9 +311,7 @@ extension MentionModel { // downvote ret.append(MenuFunction.standardMenuFunction( text: votes.myVote == .downvote ? "Undo Downvote" : "Downvote", - imageName: votes.myVote == .downvote ? Icons.downvoteSquareFill : Icons.downvoteSquare, - destructiveActionPrompt: nil, - enabled: true + imageName: votes.myVote == .downvote ? Icons.downvoteSquareFill : Icons.downvoteSquare ) { Task(priority: .userInitiated) { await self.vote(inputOp: .downvote, unreadTracker: unreadTracker) @@ -263,9 +321,7 @@ extension MentionModel { // toggle read ret.append(MenuFunction.standardMenuFunction( text: personMention.read ? "Mark Unread" : "Mark Read", - imageName: personMention.read ? Icons.markUnread : Icons.markRead, - destructiveActionPrompt: nil, - enabled: true + imageName: personMention.read ? Icons.markUnread : Icons.markRead ) { Task(priority: .userInitiated) { await self.toggleRead(unreadTracker: unreadTracker) @@ -275,9 +331,7 @@ extension MentionModel { // reply ret.append(MenuFunction.standardMenuFunction( text: "Reply", - imageName: Icons.reply, - destructiveActionPrompt: nil, - enabled: true + imageName: Icons.reply ) { Task(priority: .userInitiated) { await self.reply(editorTracker: editorTracker, unreadTracker: unreadTracker) @@ -288,8 +342,7 @@ extension MentionModel { ret.append(MenuFunction.standardMenuFunction( text: "Report", imageName: Icons.moderationReport, - destructiveActionPrompt: AppConstants.reportCommentPrompt, - enabled: true + isDestructive: true ) { Task(priority: .userInitiated) { await self.report(editorTracker: editorTracker, unreadTracker: unreadTracker) @@ -300,8 +353,7 @@ extension MentionModel { ret.append(MenuFunction.standardMenuFunction( text: "Block", imageName: Icons.userBlock, - destructiveActionPrompt: AppConstants.blockUserPrompt, - enabled: true + confirmationPrompt: AppConstants.blockUserPrompt ) { Task(priority: .userInitiated) { await self.blockUser(userId: self.creator.id) @@ -390,3 +442,5 @@ extension MentionModel: Equatable { lhs.id == rhs.id } } + +// swiftlint:enable file_length diff --git a/Mlem/Models/Content/Inbox/MessageModel.swift b/Mlem/Models/Content/Inbox/MessageModel.swift index afd07a7f7..4dc96bbf6 100644 --- a/Mlem/Models/Content/Inbox/MessageModel.swift +++ b/Mlem/Models/Content/Inbox/MessageModel.swift @@ -19,6 +19,7 @@ class MessageModel: ContentIdentifiable, ObservableObject { @Dependency(\.errorHandler) var errorHandler @Dependency(\.notifier) var notifier @Dependency(\.hapticManager) var hapticManager + @Dependency(\.siteInformation) var siteInformation @Published var creator: UserModel @Published var recipient: UserModel @@ -61,8 +62,10 @@ class MessageModel: ContentIdentifiable, ObservableObject { // call API and either update with latest info or revert state fake on fail do { let newMessage = try await inboxRepository.markMessageRead(id: privateMessage.id, isRead: privateMessage.read) - await unreadTracker.toggleMessageRead(originalState: originalPrivateMessage.read) await reinit(from: newMessage) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.messages.toggleRead(originalState: originalPrivateMessage.read) + } } catch { hapticManager.play(haptic: .failure, priority: .high) errorHandler.handle(error) @@ -80,7 +83,7 @@ class MessageModel: ContentIdentifiable, ObservableObject { // replying to a message marks it as read, but the call doesn't return anything so we just state fake it here if !privateMessage.read { setPrivateMessage(APIPrivateMessage(from: privateMessage, read: true)) - unreadTracker.readMessage() + unreadTracker.messages.read() } } @@ -117,13 +120,17 @@ class MessageModel: ContentIdentifiable, ObservableObject { unreadTracker: UnreadTracker, editorTracker: EditorTracker ) -> [MenuFunction] { + // no actions on your own messages allowed + if siteInformation.userId == creatorId { + return .init() + } + var ret: [MenuFunction] = .init() // mark read ret.append(MenuFunction.standardMenuFunction( text: privateMessage.read ? "Mark unread" : "Mark read", imageName: privateMessage.read ? Icons.markUnread : Icons.markRead, - destructiveActionPrompt: nil, enabled: true ) { Task(priority: .userInitiated) { @@ -135,7 +142,6 @@ class MessageModel: ContentIdentifiable, ObservableObject { ret.append(MenuFunction.standardMenuFunction( text: "Reply", imageName: Icons.reply, - destructiveActionPrompt: nil, enabled: true ) { Task(priority: .userInitiated) { @@ -147,7 +153,6 @@ class MessageModel: ContentIdentifiable, ObservableObject { ret.append(MenuFunction.standardMenuFunction( text: "Report", imageName: Icons.moderationReport, - destructiveActionPrompt: AppConstants.reportMessagePrompt, enabled: true ) { Task(priority: .userInitiated) { @@ -159,7 +164,7 @@ class MessageModel: ContentIdentifiable, ObservableObject { ret.append(MenuFunction.standardMenuFunction( text: "Block User", imageName: Icons.userBlock, - destructiveActionPrompt: AppConstants.blockUserPrompt, + confirmationPrompt: AppConstants.blockUserPrompt, enabled: true ) { Task(priority: .userInitiated) { @@ -174,6 +179,10 @@ class MessageModel: ContentIdentifiable, ObservableObject { unreadTracker: UnreadTracker, editorTracker: EditorTracker ) -> SwipeConfiguration { + if siteInformation.userId == creatorId { + return .init() + } + var trailingActions: [SwipeAction] = .init() trailingActions.append(SwipeAction( diff --git a/Mlem/Models/Content/Inbox/MessageReportModel.swift b/Mlem/Models/Content/Inbox/MessageReportModel.swift new file mode 100644 index 000000000..97dad970b --- /dev/null +++ b/Mlem/Models/Content/Inbox/MessageReportModel.swift @@ -0,0 +1,156 @@ +// +// MessageReportModel.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Dependencies +import Foundation + +class MessageReportModel: ContentIdentifiable, ObservableObject { + @Dependency(\.apiClient) var apiClient + @Dependency(\.hapticManager) var hapticManager + @Dependency(\.errorHandler) var errorHandler + + var reporter: UserModel + var resolver: UserModel? + @Published var messageCreator: UserModel + @Published var messageReport: APIPrivateMessageReport + + var uid: ContentModelIdentifier { .init(contentType: .messageReport, contentId: messageReport.id) } + + init( + reporter: UserModel, + resolver: UserModel?, + messageCreator: UserModel, + messageReport: APIPrivateMessageReport + ) { + self.reporter = reporter + self.resolver = resolver + self.messageCreator = messageCreator + self.messageReport = messageReport + } + + @MainActor + func reinit(from messageReport: MessageReportModel) { + reporter = messageReport.reporter + resolver = messageReport.resolver + messageCreator = messageReport.messageCreator + self.messageReport = messageReport.messageReport + } + + func toggleResolved(unreadTracker: UnreadTracker, withHaptic: Bool = true) async { + let originalReadState: Bool = read + + if withHaptic { + hapticManager.play(haptic: .lightSuccess, priority: .high) + } + + do { + let response = try await apiClient.markPrivateMessageReportResolved( + reportId: messageReport.id, + resolved: !messageReport.resolved + ) + await reinit(from: response) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.messageReports.toggleRead(originalState: originalReadState) + } + } catch { + errorHandler.handle(error) + } + } + + func toggleMessageCreatorBanned(modToolTracker: ModToolTracker, inboxTracker: InboxTracker, unreadTracker: UnreadTracker) { + modToolTracker.banUser( + messageCreator, + bannedFromCommunity: false, + shouldBan: !messageCreator.banned, + userRemovalWalker: .init(inboxTracker: inboxTracker) + ) { + if !self.messageReport.resolved { + Task(priority: .userInitiated) { + await self.toggleResolved(unreadTracker: unreadTracker, withHaptic: false) + } + } + } + } + + func genMenuFunctions(modToolTracker: ModToolTracker, inboxTracker: InboxTracker, unreadTracker: UnreadTracker) -> [MenuFunction] { + var ret: [MenuFunction] = .init() + + ret.append(.toggleableMenuFunction( + toggle: creatorBannedFromInstance, + trueText: "Unban", + trueImageName: Icons.instanceUnban, + falseText: "Ban", + falseImageName: Icons.instanceBan + ) { + self.toggleMessageCreatorBanned(modToolTracker: modToolTracker, inboxTracker: inboxTracker, unreadTracker: unreadTracker) + }) + + ret.append(.toggleableMenuFunction( + toggle: messageReport.resolved, + trueText: "Unresolve", + trueImageName: Icons.unresolve, + falseText: "Resolve", + falseImageName: Icons.resolve + ) { + Task(priority: .userInitiated) { + await self.toggleResolved(unreadTracker: unreadTracker) + } + }) + + return ret + } + + func swipeActions( + modToolTracker: ModToolTracker, + inboxTracker: InboxTracker, + unreadTracker: UnreadTracker + ) -> SwipeConfiguration { + var leadingActions: [SwipeAction] = .init() + var trailingActions: [SwipeAction] = .init() + + leadingActions.append(SwipeAction( + symbol: .init( + emptyName: read ? Icons.resolveFill : Icons.resolve, + fillName: read ? Icons.resolve : Icons.resolveFill + ), + color: .green + ) { + Task(priority: .userInitiated) { + await self.toggleResolved(unreadTracker: unreadTracker) + } + }) + + trailingActions.append(SwipeAction( + symbol: .init( + emptyName: creatorBannedFromInstance ? Icons.instanceUnban : Icons.instanceBan, + fillName: creatorBannedFromInstance ? Icons.instanceUnbanned : Icons.instanceBanned + ), + color: .red + ) { + self.toggleMessageCreatorBanned( + modToolTracker: modToolTracker, + inboxTracker: inboxTracker, + unreadTracker: unreadTracker + ) + }) + + return SwipeConfiguration(leadingActions: leadingActions, trailingActions: trailingActions) + } +} + +extension MessageReportModel: Hashable, Equatable { + static func == (lhs: MessageReportModel, rhs: MessageReportModel) -> Bool { + lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(reporter) + hasher.combine(resolver) + hasher.combine(messageCreator) + hasher.combine(messageReport) + } +} diff --git a/Mlem/Models/Content/Inbox/PostReportModel.swift b/Mlem/Models/Content/Inbox/PostReportModel.swift new file mode 100644 index 000000000..c2206aebe --- /dev/null +++ b/Mlem/Models/Content/Inbox/PostReportModel.swift @@ -0,0 +1,276 @@ +// +// PostReportModel.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Dependencies +import Foundation + +class PostReportModel: ContentIdentifiable, ObservableObject { + @Dependency(\.apiClient) var apiClient + @Dependency(\.hapticManager) var hapticManager + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation + + var reporter: UserModel + var resolver: UserModel? + @Published var postCreator: UserModel + var community: CommunityModel + var postReport: APIPostReport + @Published var post: APIPost + @Published var votes: VotesModel + @Published var numReplies: Int + @Published var postCreatorBannedFromCommunity: Bool + @Published var purged: Bool + + var uid: ContentModelIdentifier { .init(contentType: .postReport, contentId: postReport.id) } + + init( + reporter: UserModel, + resolver: UserModel?, + postCreator: UserModel, + community: CommunityModel, + postReport: APIPostReport, + post: APIPost, + votes: VotesModel, + numReplies: Int, + postCreatorBannedFromCommunity: Bool + ) { + self.reporter = reporter + self.resolver = resolver + self.postCreator = postCreator + self.community = community + self.postReport = postReport + self.post = post + self.votes = votes + self.numReplies = numReplies + self.postCreatorBannedFromCommunity = postCreatorBannedFromCommunity + self.purged = false + } + + @MainActor + func reinit(from postReport: PostReportModel) { + reporter = postReport.reporter + resolver = postReport.resolver + postCreator = postReport.postCreator + community = postReport.community + self.postReport = postReport.postReport + post = postReport.post + votes = postReport.votes + numReplies = postReport.numReplies + postCreatorBannedFromCommunity = postReport.postCreatorBannedFromCommunity + purged = postReport.purged + } + + @MainActor + func setPurged(_ newPurged: Bool) { + purged = newPurged + } + + func toggleResolved(unreadTracker: UnreadTracker, withHaptic: Bool = true) async { + let originalReadState: Bool = read + if withHaptic { + hapticManager.play(haptic: .lightSuccess, priority: .low) + } + do { + let response = try await apiClient.markPostReportResolved(reportId: postReport.id, resolved: !postReport.resolved) + await reinit(from: response) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.postReports.toggleRead(originalState: originalReadState) + } + } catch { + errorHandler.handle(error) + } + } + + func togglePostRemoved(modToolTracker: ModToolTracker, unreadTracker: UnreadTracker) { + modToolTracker.removePost(self, shouldRemove: !post.removed) { + if !self.read { + Task { + await self.toggleResolved(unreadTracker: unreadTracker, withHaptic: false) + } + } + } + } + + func togglePostCreatorBanned(modToolTracker: ModToolTracker, inboxTracker: InboxTracker, unreadTracker: UnreadTracker) { + modToolTracker.banUser( + postCreator, + from: community, + bannedFromCommunity: postCreatorBannedFromCommunity, + shouldBan: !postCreatorBannedFromCommunity, + userRemovalWalker: .init(inboxTracker: inboxTracker) + ) { + if !self.postReport.resolved { + Task(priority: .userInitiated) { + await self.toggleResolved(unreadTracker: unreadTracker, withHaptic: false) + } + } + } + } + + func purgePost(modToolTracker: ModToolTracker) { + modToolTracker.purgeContent(self) + } + + func genMenuFunctions( + modToolTracker: ModToolTracker, + inboxTracker: InboxTracker, + unreadTracker: UnreadTracker + ) -> [MenuFunction] { + var ret: [MenuFunction] = .init() + + ret.append(.toggleableMenuFunction( + toggle: postReport.resolved, + trueText: "Unresolve", + trueImageName: Icons.unresolve, + falseText: "Resolve", + falseImageName: Icons.resolve + ) { + Task(priority: .userInitiated) { + await self.toggleResolved(unreadTracker: unreadTracker) + } + } + ) + + ret.append(.toggleableMenuFunction( + toggle: post.removed, + trueText: "Restore", + trueImageName: Icons.restore, + falseText: "Remove", + falseImageName: Icons.remove, + isDestructive: .whenFalse + ) { + self.togglePostRemoved(modToolTracker: modToolTracker, unreadTracker: unreadTracker) + } + ) + + ret.append(.toggleableMenuFunction( + toggle: creatorBannedFromCommunity, + trueText: "Unban", + trueImageName: Icons.communityUnban, + falseText: "Ban", + falseImageName: Icons.communityBan, + isDestructive: .whenFalse + ) { + self.togglePostCreatorBanned(modToolTracker: modToolTracker, inboxTracker: inboxTracker, unreadTracker: unreadTracker) + } + ) + + ret.append(.standardMenuFunction( + text: "Purge", + imageName: Icons.purge, + isDestructive: true + ) { + self.purgePost(modToolTracker: modToolTracker) + } + ) + + return ret + } + + func swipeActions( + modToolTracker: ModToolTracker, + inboxTracker: InboxTracker, + unreadTracker: UnreadTracker + ) -> SwipeConfiguration { + var leadingActions: [SwipeAction] = .init() + var trailingActions: [SwipeAction] = .init() + + leadingActions.append(SwipeAction( + symbol: .init( + emptyName: read ? Icons.resolveFill : Icons.resolve, + fillName: read ? Icons.resolve : Icons.resolveFill + ), + color: .green + ) { + Task(priority: .userInitiated) { + await self.toggleResolved(unreadTracker: unreadTracker) + } + }) + leadingActions.append(SwipeAction( + symbol: .init( + emptyName: post.removed ? Icons.restore : Icons.remove, + fillName: post.removed ? Icons.restoreFill : Icons.removeFill + ), + color: .red + ) { + self.togglePostRemoved(modToolTracker: modToolTracker, unreadTracker: unreadTracker) + }) + + trailingActions.append(SwipeAction( + symbol: .init( + emptyName: creatorBannedFromCommunity ? Icons.communityUnban : Icons.communityBan, + fillName: creatorBannedFromCommunity ? Icons.communityUnbanned : Icons.communityBanFill + ), + color: .red + ) { + self.togglePostCreatorBanned( + modToolTracker: modToolTracker, + inboxTracker: inboxTracker, + unreadTracker: unreadTracker + ) + }) + + if siteInformation.isAdmin { + trailingActions.append(SwipeAction( + symbol: .init(emptyName: Icons.purge, fillName: Icons.purge), + color: .primary, + iconColor: .systemBackground + ) { + modToolTracker.purgeContent(self) + }) + } + + return SwipeConfiguration(leadingActions: leadingActions, trailingActions: trailingActions) + } +} + +extension PostReportModel: Hashable, Equatable { + static func == (lhs: PostReportModel, rhs: PostReportModel) -> Bool { + lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(reporter) + hasher.combine(postReport) + hasher.combine(post) + hasher.combine(votes) + hasher.combine(numReplies) + } +} + +extension PostReportModel: Removable, Purgable { + func remove(reason: String?, shouldRemove: Bool) async -> Bool { + do { + let response = try await apiClient.removePost(id: post.id, shouldRemove: shouldRemove, reason: reason) + if response.post.removed == shouldRemove { + await MainActor.run { + self.post.removed = shouldRemove + } + } + return true + } catch { + errorHandler.handle(error) + } + return false + } + + func purge(reason: String?) async -> Bool { + do { + let response = try await apiClient.purgePost(id: post.id, reason: reason) + if response.success { + await setPurged(true) + await MainActor.run { + self.postReport.resolved = true + } + return true + } + } catch { + errorHandler.handle(error) + } + return false + } +} diff --git a/Mlem/Models/Content/Inbox/RegistrationApplicationModel.swift b/Mlem/Models/Content/Inbox/RegistrationApplicationModel.swift new file mode 100644 index 000000000..7ad59cc67 --- /dev/null +++ b/Mlem/Models/Content/Inbox/RegistrationApplicationModel.swift @@ -0,0 +1,142 @@ +// +// RegistrationApplicationModel.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-05. +// + +import Dependencies +import Foundation + +class RegistrationApplicationModel: ObservableObject { + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.apiClient) var apiClient + + @Published var application: APIRegistrationApplication + @Published var creator: UserModel + @Published var resolver: UserModel? + var approved: Bool? + + init( + application: APIRegistrationApplication, + creator: UserModel, + resolver: UserModel? = nil, + approved: Bool? + ) { + self.application = application + self.creator = creator + self.resolver = resolver + self.approved = approved + } + + @MainActor + func reinit(from application: RegistrationApplicationModel) { + self.application = application.application + creator = application.creator + resolver = application.resolver + approved = application.approved + } + + func approve(unreadTracker: UnreadTracker) async { + do { + let response = try await apiClient.approveRegistrationApplication( + applicationId: application.id, + approve: true, + denyReason: nil + ) + if !read { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.registrationApplications.read() + } + } + await reinit(from: response) + } catch { + errorHandler.handle(error) + } + } + + func deny(reason: String, unreadTracker: UnreadTracker) async -> Bool { + do { + let response = try await apiClient.approveRegistrationApplication( + applicationId: application.id, + approve: false, + denyReason: reason + ) + if !read { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.registrationApplications.read() + } + } + await reinit(from: response) + return true + } catch { + errorHandler.handle(error) + } + return false + } + + func genMenuFunctions(modToolTracker: ModToolTracker, unreadTracker: UnreadTracker) -> [MenuFunction] { + var ret: [MenuFunction] = .init() + + if !(approved ?? false) { + ret.append(.standardMenuFunction( + text: "Approve", + imageName: Icons.approveCircle + ) { + Task(priority: .userInitiated) { + await self.approve(unreadTracker: unreadTracker) + } + }) + } + + if approved ?? true { + ret.append(.standardMenuFunction( + text: "Deny", + imageName: Icons.denyCircle + ) { + modToolTracker.denyApplication(self) + }) + } + + return ret + } + + func swipeActions(modToolTracker: ModToolTracker, unreadTracker: UnreadTracker) -> SwipeConfiguration { + var leadingActions: [SwipeAction] = .init() + var trailingActions: [SwipeAction] = .init() + + if !(approved ?? false) { + trailingActions.append(SwipeAction( + symbol: .init(emptyName: Icons.approveCircle, fillName: Icons.approveCircleFill), + color: .blue + ) { + Task(priority: .userInitiated) { + await self.approve(unreadTracker: unreadTracker) + } + }) + } + + if approved ?? true { + leadingActions.append(SwipeAction( + symbol: .init(emptyName: Icons.denyCircle, fillName: Icons.denyCircleFill), + color: .red + ) { + modToolTracker.denyApplication(self) + }) + } + + return SwipeConfiguration(leadingActions: leadingActions, trailingActions: trailingActions) + } +} + +extension RegistrationApplicationModel: Hashable, Equatable { + static func == (lhs: RegistrationApplicationModel, rhs: RegistrationApplicationModel) -> Bool { + lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(application) + hasher.combine(creator) + hasher.combine(resolver) + } +} diff --git a/Mlem/Models/Content/Inbox/ReplyModel.swift b/Mlem/Models/Content/Inbox/ReplyModel.swift index 132bbce4c..1069cba7b 100644 --- a/Mlem/Models/Content/Inbox/ReplyModel.swift +++ b/Mlem/Models/Content/Inbox/ReplyModel.swift @@ -5,8 +5,11 @@ // Created by Eric Andrews on 2023-09-23. // +// swiftlint:disable file_length + import Dependencies import Foundation +import SwiftUI /// Internal representation of a comment reply class ReplyModel: ObservableObject, ContentIdentifiable { @@ -24,8 +27,9 @@ class ReplyModel: ObservableObject, ContentIdentifiable { var recipient: UserModel @Published var numReplies: Int @Published var votes: VotesModel - @Published var creatorBannedFromCommunity: Bool + @Published var commentCreatorBannedFromCommunity: Bool @Published var subscribed: APISubscribedStatus + @Published var read: Bool @Published var saved: Bool @Published var creatorBlocked: Bool @@ -45,6 +49,7 @@ class ReplyModel: ObservableObject, ContentIdentifiable { votes: VotesModel, creatorBannedFromCommunity: Bool, subscribed: APISubscribedStatus, + read: Bool, saved: Bool, creatorBlocked: Bool ) { @@ -56,8 +61,9 @@ class ReplyModel: ObservableObject, ContentIdentifiable { self.recipient = recipient self.numReplies = numReplies self.votes = votes - self.creatorBannedFromCommunity = creatorBannedFromCommunity + self.commentCreatorBannedFromCommunity = creatorBannedFromCommunity self.subscribed = subscribed + self.read = read self.saved = saved self.creatorBlocked = creatorBlocked } @@ -71,8 +77,9 @@ class ReplyModel: ObservableObject, ContentIdentifiable { self.recipient = UserModel(from: replyView.recipient) self.numReplies = replyView.counts.childCount self.votes = VotesModel(from: replyView.counts, myVote: replyView.myVote) - self.creatorBannedFromCommunity = replyView.creatorBannedFromCommunity + self.commentCreatorBannedFromCommunity = replyView.creatorBannedFromCommunity self.subscribed = replyView.subscribed + self.read = replyView.commentReply.read self.saved = replyView.saved self.creatorBlocked = replyView.creatorBlocked } @@ -89,6 +96,7 @@ class ReplyModel: ObservableObject, ContentIdentifiable { votes: VotesModel? = nil, creatorBannedFromCommunity: Bool? = nil, subscribed: APISubscribedStatus? = nil, + read: Bool? = nil, saved: Bool? = nil, creatorBlocked: Bool? = nil ) { @@ -100,8 +108,9 @@ class ReplyModel: ObservableObject, ContentIdentifiable { self.recipient = recipient ?? replyModel.recipient self.numReplies = numReplies ?? replyModel.numReplies self.votes = votes ?? replyModel.votes - self.creatorBannedFromCommunity = creatorBannedFromCommunity ?? replyModel.creatorBannedFromCommunity + self.commentCreatorBannedFromCommunity = creatorBannedFromCommunity ?? replyModel.commentCreatorBannedFromCommunity self.subscribed = subscribed ?? replyModel.subscribed + self.read = read ?? replyModel.read self.saved = saved ?? replyModel.saved self.creatorBlocked = creatorBlocked ?? replyModel.creatorBlocked } @@ -118,6 +127,16 @@ extension ReplyModel { votes = newVotes } + @MainActor + func setSaved(_ newSaved: Bool) { + saved = newSaved + } + + @MainActor + func setRead(_ newRead: Bool) { + read = newRead + } + /// Re-initializes all fields to match the given ReplyModel @MainActor func reinit(from replyModel: ReplyModel) { @@ -129,12 +148,16 @@ extension ReplyModel { recipient = replyModel.recipient numReplies = replyModel.numReplies votes = replyModel.votes - creatorBannedFromCommunity = replyModel.creatorBannedFromCommunity + commentCreatorBannedFromCommunity = replyModel.commentCreatorBannedFromCommunity subscribed = replyModel.subscribed + read = replyModel.read saved = replyModel.saved creatorBlocked = replyModel.creatorBlocked } + func toggleUpvote(unreadTracker: UnreadTracker) async { await vote(inputOp: .upvote, unreadTracker: unreadTracker) } + func toggleDownvote(unreadTracker: UnreadTracker) async { await vote(inputOp: .downvote, unreadTracker: unreadTracker) } + func vote(inputOp: ScoringOperation, unreadTracker: UnreadTracker) async { guard !voting else { return @@ -157,7 +180,9 @@ extension ReplyModel { await reinit(from: updatedReply) if !original.commentReply.read { _ = try await inboxRepository.markReplyRead(id: commentReply.id, isRead: true) - await unreadTracker.readReply() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.replies.read() + } } } catch { hapticManager.play(haptic: .failure, priority: .high) @@ -178,8 +203,10 @@ extension ReplyModel { // call API and either update with latest info or revert state fake on fail do { let newReply = try await inboxRepository.markReplyRead(id: commentReply.id, isRead: commentReply.read) - await unreadTracker.toggleReplyRead(originalState: originalCommentReply.read) await reinit(from: newReply) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.replies.toggleRead(originalState: originalCommentReply.read) + } } catch { hapticManager.play(haptic: .failure, priority: .high) errorHandler.handle(error) @@ -187,6 +214,44 @@ extension ReplyModel { } } + func toggleSave(unreadTracker: UnreadTracker) async { + hapticManager.play(haptic: .success, priority: .low) + + let shouldSave: Bool = !saved + @AppStorage("upvoteOnSave") var upvoteOnSave = false + + // state fake + let original: ReplyModel = .init(from: self) + await setSaved(shouldSave) + await setRead(true) + if shouldSave, upvoteOnSave, votes.myVote != .upvote { + await setVotes(votes.applyScoringOperation(operation: .upvote)) + } + + // API call + do { + let saveResponse = try await inboxRepository.saveCommentReply(self, shouldSave: shouldSave) + + if shouldSave, upvoteOnSave { + let voteResponse = try await inboxRepository.voteOnCommentReply(self, vote: .upvote) + await reinit(from: voteResponse) + } else { + await reinit(from: saveResponse) + } + if !original.commentReply.read { + let newReply = try await inboxRepository.markReplyRead(id: commentReply.id, isRead: true) + await reinit(from: newReply) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + unreadTracker.replies.read() + } + } + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await reinit(from: original) + } + } + @MainActor func reply(editorTracker: EditorTracker, unreadTracker: UnreadTracker) { editorTracker.openEditor(with: ConcreteEditorModel( @@ -235,7 +300,6 @@ extension ReplyModel { ret.append(MenuFunction.standardMenuFunction( text: votes.myVote == .upvote ? "Undo Upvote" : "Upvote", imageName: votes.myVote == .upvote ? Icons.upvoteSquareFill : Icons.upvoteSquare, - destructiveActionPrompt: nil, enabled: true ) { Task(priority: .userInitiated) { @@ -247,7 +311,6 @@ extension ReplyModel { ret.append(MenuFunction.standardMenuFunction( text: votes.myVote == .downvote ? "Undo Downvote" : "Downvote", imageName: votes.myVote == .downvote ? Icons.downvoteSquareFill : Icons.downvoteSquare, - destructiveActionPrompt: nil, enabled: true ) { Task(priority: .userInitiated) { @@ -259,7 +322,6 @@ extension ReplyModel { ret.append(MenuFunction.standardMenuFunction( text: commentReply.read ? "Mark Unread" : "Mark Read", imageName: commentReply.read ? Icons.markUnread : Icons.markRead, - destructiveActionPrompt: nil, enabled: true ) { Task(priority: .userInitiated) { @@ -271,7 +333,6 @@ extension ReplyModel { ret.append(MenuFunction.standardMenuFunction( text: "Reply", imageName: Icons.reply, - destructiveActionPrompt: nil, enabled: true ) { Task(priority: .userInitiated) { @@ -283,8 +344,7 @@ extension ReplyModel { ret.append(MenuFunction.standardMenuFunction( text: "Report", imageName: Icons.moderationReport, - destructiveActionPrompt: AppConstants.reportCommentPrompt, - enabled: true + isDestructive: true ) { Task(priority: .userInitiated) { await self.report(editorTracker: editorTracker, unreadTracker: unreadTracker) @@ -295,8 +355,7 @@ extension ReplyModel { ret.append(MenuFunction.standardMenuFunction( text: "Block", imageName: Icons.userBlock, - destructiveActionPrompt: AppConstants.blockUserPrompt, - enabled: true + confirmationPrompt: AppConstants.blockUserPrompt ) { Task(priority: .userInitiated) { await self.blockUser(userId: self.creator.id) @@ -384,3 +443,5 @@ extension ReplyModel: Equatable { lhs.id == rhs.id } } + +// swiftlint:enable file_length diff --git a/Mlem/Models/Content/Instance/InstanceModel+MenuFunctions.swift b/Mlem/Models/Content/Instance/InstanceModel+MenuFunctions.swift index 7a0b21256..9310b5b55 100644 --- a/Mlem/Models/Content/Instance/InstanceModel+MenuFunctions.swift +++ b/Mlem/Models/Content/Instance/InstanceModel+MenuFunctions.swift @@ -8,9 +8,35 @@ import Foundation extension InstanceModel { - func menuFunctions() -> [MenuFunction] { + func blockMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> MenuFunction { + if blocked { + return .standardMenuFunction( + text: "Unblock", + imageName: Icons.show + ) { + Task { await toggleBlock(callback) } + } + } + return .standardMenuFunction( + text: "Block", + imageName: Icons.hide, + confirmationPrompt: AppConstants.blockInstancePrompt + ) { + Task { await toggleBlock(callback) } + } + } + + func menuFunctions(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> [MenuFunction] { if let url { - return [.shareMenuFunction(url: url)] + var functions: [MenuFunction] = [ + .shareMenuFunction(url: url), + .openUrlMenuFunction(text: "View on Web", imageName: Icons.browser, destination: url), + .divider + ] + if (siteInformation.version ?? .infinity) >= .init("0.19.0") { + functions.append(blockMenuFunction(callback)) + } + return functions } return [] } diff --git a/Mlem/Models/Content/Instance/InstanceModel+Uptime.swift b/Mlem/Models/Content/Instance/InstanceModel+Uptime.swift new file mode 100644 index 000000000..b2ba7629c --- /dev/null +++ b/Mlem/Models/Content/Instance/InstanceModel+Uptime.swift @@ -0,0 +1,60 @@ +// +// InstanceModel+Uptime.swift +// Mlem +// +// Created by Sjmarf on 28/01/2024. +// + +import SwiftUI + +extension InstanceModel { + // Instances watched by lemmy-status.org + static let uptimeSupportedInstances: [String] = [ + "aussie.zone", + "beehaw.org", + "discuss.online", + "discuss.tchncs.de", + "dubvee.org", + "feddit.de", + "feddit.dk", + "hexbear.net", + "infosec.pub", + "jlai.lu", + "lemdro.id", + "lemm.ee", + "lemmings.world", + "lemmy.blahaj.zone", + "lemmy.ca", + "lemmy.dbzer0.com", + "lemmy.eco.br", + "lemmy.ml", + "lemmy.myserv.one", + "lemmy.nz", + "lemmy.world", + "lemmy.zip", + "literature.cafe", + "mander.xyz", + "midwest.social", + "programming.dev", + "sh.itjust.works", + "slrpnk.net", + "sopuli.xyz", + "startrek.website", + "szmer.info", + "toast.ooo" + ] + + var canFetchUptime: Bool { InstanceModel.uptimeSupportedInstances.contains(name) } + + var uptimeDataUrl: URL? { + guard canFetchUptime else { return nil } + let name = "_\(name.replacingOccurrences(of: ".", with: "-"))" + return URL(string: "https://lemmy-status.org/api/v1/endpoints/\(name)/statuses?page=1") + } + + var uptimeFrontendUrl: URL? { + guard canFetchUptime else { return nil } + let name = "_\(name.replacingOccurrences(of: ".", with: "-"))" + return URL(string: "https://lemmy-status.org/endpoints/\(name)") + } +} diff --git a/Mlem/Models/Content/Instance/InstanceModel.swift b/Mlem/Models/Content/Instance/InstanceModel.swift index 144d30764..eb7d17703 100644 --- a/Mlem/Models/Content/Instance/InstanceModel.swift +++ b/Mlem/Models/Content/Instance/InstanceModel.swift @@ -5,9 +5,23 @@ // Created by Sjmarf on 13/01/2024. // +import Dependencies import SwiftUI struct InstanceModel { + @Dependency(\.apiClient) var apiClient + @Dependency(\.hapticManager) var hapticManager + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier + @Dependency(\.siteInformation) var siteInformation + + enum InstanceError: Error { + case invalidUrl + case noPostReturned + case noSiteReturned + case couldNotResolveCommunity + } + var displayName: String! var description: String? var avatar: URL? @@ -41,6 +55,24 @@ struct InstanceModel { var applicationsEmailAdmins: Bool? var reportsEmailAdmins: Bool? + // Not included in any API types; assumed to be false + var blocked: Bool = false + + // This is included in APISite, but should ONLY be set when fetched from local instance + var localSiteId: Int? + + init(domainName: String) throws { + var components = URLComponents() + components.scheme = "https" + components.host = domainName + if let url = components.url { + self.url = url + self.displayName = name + } else { + throw InstanceError.invalidUrl + } + } + init(from response: SiteResponse) { update(with: response) } @@ -49,8 +81,11 @@ struct InstanceModel { update(with: siteView) } - init(from site: APISite) { + init(from site: APISite, isLocal: Bool = false) { update(with: site) + if isLocal { + self.localSiteId = site.instanceId + } } init(from stub: InstanceStub) { @@ -146,6 +181,75 @@ struct InstanceModel { } return nil } + + func toggleBlock(_ callback: @escaping (_ item: Self) -> Void = { _ in }) async { + var new = self + new.blocked = !blocked + RunLoop.main.perform { [new] in + callback(new) + } + do { + let localSiteId: Int + if let id = self.localSiteId { + localSiteId = id + } else { + localSiteId = try await fetchSiteId() + } + + let response: BlockInstanceResponse + if !blocked { + response = try await apiClient.blockSite(id: localSiteId, shouldBlock: true) + } else { + response = try await apiClient.blockSite(id: localSiteId, shouldBlock: false) + } + new.blocked = response.blocked + RunLoop.main.perform { [new] in + callback(new) + } + await notifier.add(.success(response.blocked ? "Blocked instance" : "Unblocked instance")) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + let phrase = !blocked ? "block" : "unblock" + errorHandler.handle( + .init(title: "Failed to \(phrase) instance", style: .toast, underlyingError: error) + ) + } + } + + private func fetchSiteId() async throws -> Int { + let externalClient = APIClient( + transport: { urlSession, urlRequest in try await urlSession.data(for: urlRequest) } + ) + externalClient.session = .unauthenticated(url.appendingPathComponent("api/v3")) + let response = try await externalClient.loadPosts( + communityId: nil, + page: 1, + cursor: nil, + sort: .new, + type: .local, + limit: 1, + savedOnly: false, + communityName: nil + ) + guard let post = response.posts.first else { + throw InstanceError.noPostReturned + } + switch try await apiClient.resolve(query: post.community.actorId.absoluteString) { + case let .community(community): + let response = try await apiClient.loadCommunityDetails(id: community.community.id) + if let id = response.site?.instanceId { + return id + } else { + throw InstanceError.noSiteReturned + } + default: + throw InstanceError.couldNotResolveCommunity + } + } + + static func mock() -> InstanceModel { + .init(from: SiteResponse.mock()) + } } extension InstanceModel: Identifiable { @@ -161,5 +265,6 @@ extension InstanceModel: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(url) hasher.combine(creationDate) + hasher.combine(blocked) } } diff --git a/Mlem/Models/Content/Modlog/ModlogEntry.swift b/Mlem/Models/Content/Modlog/ModlogEntry.swift new file mode 100644 index 000000000..54c52017f --- /dev/null +++ b/Mlem/Models/Content/Modlog/ModlogEntry.swift @@ -0,0 +1,495 @@ +// +// ModlogEntry.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-11. +// + +import Dependencies +// swiftlint:disable file_length +import Foundation +import SwiftUI + +struct ModlogIcon { + let imageName: String + let color: Color +} + +enum ModlogReason { + case inapplicable, noneGiven + case reason(String) +} + +enum ModlogExpiration { + case inapplicable, permanent + case date(Date) +} + +// swiftlint:disable:next type_body_length +struct ModlogEntry: Hashable, Equatable { + let action: ModlogAction + let date: Date + let description: String + let reason: ModlogReason + let expires: ModlogExpiration + let additionalContext: String? + let icon: ModlogIcon + let contextLinks: [MenuFunction] + + init(from apiType: APIModRemovePostView, canViewRemovedPost: Bool) { + @Dependency(\.siteInformation) var siteInformation + + self.action = .postRemoval + self.date = apiType.modRemovePost.when_ + + let agent = genModeratorAgent(agent: apiType.moderator) + self.description = apiType.modRemovePost.removed ? + "\(agent) removed post \"\(apiType.post.name)\" from \(apiType.community.fullyQualifiedName)" : + "\(agent) restored post \"\(apiType.post.name)\" to \(apiType.community.fullyQualifiedName)" + + self.reason = genReason(reason: apiType.modRemovePost.reason) + self.expires = .inapplicable + + self.additionalContext = apiType.post.removed != apiType.modRemovePost.removed ? + "Post has since been \(apiType.post.removed ? "removed" : "restored")" : + nil + + self.icon = apiType.modRemovePost.removed ? + .init(imageName: Icons.removed, color: .red) : + .init(imageName: Icons.restored, color: .green) + + self.contextLinks = [ + ModlogMenuFunction.moderator(apiType.moderator), + ModlogMenuFunction.post(!apiType.post.removed || canViewRemovedPost ? apiType.post : nil), + ModlogMenuFunction.community(apiType.community) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModLockPostView) { + self.action = .postLock + self.date = apiType.modLockPost.when_ + + let agent = genModeratorAgent(agent: apiType.moderator) + let verb = apiType.modLockPost.locked ? "locked" : "unlocked" + self.description = "\(agent) \(verb) post \"\(apiType.post.name)\" in \(apiType.community.fullyQualifiedName)" + + self.reason = .inapplicable + self.expires = .inapplicable + + let icon = apiType.modLockPost.locked ? Icons.locked : Icons.unlocked + self.icon = .init(imageName: icon, color: .orange) + + self.additionalContext = apiType.post.locked != apiType.modLockPost.locked ? + "Post has since been \(apiType.post.locked ? "locked" : "unlocked")" : + nil + + self.contextLinks = [ + ModlogMenuFunction.moderator(apiType.moderator), + ModlogMenuFunction.post(apiType.post), + ModlogMenuFunction.community(apiType.community) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModFeaturePostView) { + self.action = .postPin + self.date = apiType.modFeaturePost.when_ + + let agent = genModeratorAgent(agent: apiType.moderator) + let description: String + if apiType.modFeaturePost.isFeaturedCommunity { + description = apiType.modFeaturePost.featured ? + "\(agent) pinned post \"\(apiType.post.name)\" to \(apiType.community.fullyQualifiedName)" : + "\(agent) unpinned post \"\(apiType.post.name)\" from \(apiType.community.fullyQualifiedName)" + } else { + description = apiType.modFeaturePost.featured ? + "\(agent) pinned post \"\(apiType.post.name)\" (from \(apiType.community.fullyQualifiedName)) to Local" : + "\(agent) unpinned post \"\(apiType.post.name)\" (from \(apiType.community.fullyQualifiedName)) from Local" + } + self.description = description + + self.reason = .inapplicable + self.expires = .inapplicable + self.additionalContext = apiType.post.featuredCommunity != apiType.modFeaturePost.featured ? + "Post has since been \(apiType.post.featuredCommunity ? "pinned" : "unpinned")" : + nil + + self.icon = .init( + imageName: apiType.modFeaturePost.featured ? Icons.pinned : Icons.unpinned, + color: apiType.modFeaturePost.isFeaturedCommunity ? .moderation : .red + ) + + self.contextLinks = [ + ModlogMenuFunction.moderator(apiType.moderator), + ModlogMenuFunction.post(apiType.post), + ModlogMenuFunction.community(apiType.community) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModRemoveCommentView) { + self.action = .commentRemoval + self.date = apiType.modRemoveComment.when_ + + let agent = genModeratorAgent(agent: apiType.moderator) + let verb = apiType.modRemoveComment.removed ? "removed" : "restored" + self.description = "\(agent) \(verb) comment \"\(apiType.comment.content)\" (posted in \(apiType.community.fullyQualifiedName))" + + self.reason = genReason(reason: apiType.modRemoveComment.reason) + self.expires = .inapplicable + self.additionalContext = apiType.comment.removed != apiType.modRemoveComment.removed ? + "Comment has since been \(apiType.comment.removed ? "removed" : "restored")" : + nil + + self.icon = apiType.modRemoveComment.removed ? + .init(imageName: Icons.removed, color: .red) : + .init(imageName: Icons.restored, color: .green) + + self.contextLinks = [ + ModlogMenuFunction.moderator(apiType.moderator), + ModlogMenuFunction.post(apiType.post), + ModlogMenuFunction.community(apiType.community) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModRemoveCommunityView, canViewRemovedCommunity: Bool) { + self.action = .communityRemoval + self.date = apiType.modRemoveCommunity.when_ + + // it's calld "ModRemoveCommunityView" but only admins can do it + let agent = genAdministratorAgent(agent: apiType.moderator) + let verb = apiType.modRemoveCommunity.removed ? "removed" : "restored" + self.description = "\(agent) \(verb) community \(apiType.community.fullyQualifiedName)" + + self.reason = genReason(reason: apiType.modRemoveCommunity.reason) + self.expires = .inapplicable + self.additionalContext = apiType.community.removed != apiType.modRemoveCommunity.removed ? + "Community has since been \(apiType.community.removed ? "removed" : "restored")" : + nil + + self.icon = apiType.modRemoveCommunity.removed ? + .init(imageName: Icons.removed, color: .red) : + .init(imageName: Icons.restored, color: .green) + + self.contextLinks = [ + ModlogMenuFunction.moderator(apiType.moderator), + ModlogMenuFunction.community(!apiType.community.removed || canViewRemovedCommunity ? apiType.community : nil) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModBanFromCommunityView) { + self.action = .communityBan + self.date = apiType.modBanFromCommunity.when_ + + let agent = genModeratorAgent(agent: apiType.moderator) + let verb = apiType.modBanFromCommunity.banned ? "banned" : "unbanned" + self.description = "\(agent) \(verb) \(apiType.bannedPerson.fullyQualifiedName) from \(apiType.community.fullyQualifiedName)" + + self.reason = genReason(reason: apiType.modBanFromCommunity.reason) + self.expires = apiType.modBanFromCommunity.banned ? genExpires(expires: apiType.modBanFromCommunity.expires) : .inapplicable + self.additionalContext = nil // current ban status in community unavailable from API [Eric 2024.03.17] + + self.icon = apiType.modBanFromCommunity.banned ? + .init(imageName: Icons.communityBanned, color: .red) : + .init(imageName: Icons.communityUnbanned, color: .green) + + self.contextLinks = [ + ModlogMenuFunction.moderator(apiType.moderator), + ModlogMenuFunction.user(apiType.bannedPerson, verb.capitalized), + ModlogMenuFunction.community(apiType.community) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModBanView) { + self.action = .instanceBan + self.date = apiType.modBan.when_ + + let agent = genAdministratorAgent(agent: apiType.moderator) + let verb = apiType.modBan.banned ? "banned" : "unbanned" + // swiftlint:disable:next line_length + self.description = "\(agent) \(verb) \(apiType.bannedPerson.fullyQualifiedName) from \(apiType.moderator?.actorId.host() ?? "instance")" + + self.reason = genReason(reason: apiType.modBan.reason) + self.expires = apiType.modBan.banned ? genExpires(expires: apiType.modBan.expires) : .inapplicable + + let actionImpliesCurrentlyBanned: Bool + if apiType.modBan.banned { + if let expires = apiType.modBan.expires { + actionImpliesCurrentlyBanned = expires >= Date.now + } else { + actionImpliesCurrentlyBanned = true + } + } else { + actionImpliesCurrentlyBanned = false + } + self.additionalContext = apiType.bannedPerson.banned != actionImpliesCurrentlyBanned ? + "User has since been \(apiType.bannedPerson.banned ? "banned" : "unbanned")" : + nil + + self.icon = apiType.modBan.banned ? + .init(imageName: Icons.instanceBanned, color: .red) : + .init(imageName: Icons.instanceUnbanned, color: .green) + + self.contextLinks = [ + ModlogMenuFunction.moderator(apiType.moderator), + ModlogMenuFunction.user(apiType.bannedPerson, verb.capitalized) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModAddCommunityView) { + self.action = .moderatorAdd + self.date = apiType.modAddCommunity.when_ + + let agent = genModeratorAgent(agent: apiType.moderator) + let verb = apiType.modAddCommunity.removed ? "removed" : "appointed" + // swiftlint:disable:next line_length + self.description = "\(agent) \(verb) \(apiType.moddedPerson.fullyQualifiedName) as moderator of \(apiType.community.fullyQualifiedName)" + + self.reason = .inapplicable + self.expires = .inapplicable + self.additionalContext = nil + + self.icon = apiType.modAddCommunity.removed ? + .init(imageName: Icons.unmodFill, color: .red) : + .init(imageName: Icons.moderationFill, color: .moderation) + + self.contextLinks = [ + ModlogMenuFunction.moderator(apiType.moderator), + ModlogMenuFunction.user(apiType.moddedPerson, apiType.modAddCommunity.removed ? "Unmodded" : "Modded") + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModTransferCommunityView) { + self.action = .communityTransfer + self.date = apiType.modTransferCommunity.when_ + + let agent = genModeratorAgent(agent: apiType.moderator) + self.description = "\(agent) transferred \(apiType.community.fullyQualifiedName) to \(apiType.moddedPerson.fullyQualifiedName)" + + self.reason = .inapplicable + self.expires = .inapplicable + self.additionalContext = nil + + self.icon = .init(imageName: Icons.leftRight, color: .moderation) + + self.contextLinks = [ + ModlogMenuFunction.moderator(apiType.moderator), + ModlogMenuFunction.user(apiType.moddedPerson, "Promoted"), + ModlogMenuFunction.community(apiType.community) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModAddView) { + self.action = .administratorAdd + self.date = apiType.modAdd.when_ + + let agent = genAdministratorAgent(agent: apiType.moderator) + let verb = apiType.modAdd.removed ? "removed" : "appointed" + let instance = apiType.moderator?.actorId.host() ?? "instance" + self.description = "\(agent) \(verb) \(apiType.moddedPerson.fullyQualifiedName) as administrator of \(instance)" + + self.reason = .inapplicable + self.expires = .inapplicable + self.additionalContext = nil + + self.icon = apiType.modAdd.removed ? + .init(imageName: Icons.unAdmin, color: .indigo) : + .init(imageName: Icons.adminFill, color: .teal) + + self.contextLinks = [ + ModlogMenuFunction.administrator(apiType.moderator), + ModlogMenuFunction.user(apiType.moddedPerson, apiType.modAdd.removed ? "Demoted" : "Promoted") + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIAdminPurgePersonView) { + self.action = .personPurge + self.date = apiType.adminPurgePerson.when_ + + let agent = genAdministratorAgent(agent: apiType.admin) + self.description = "\(agent) purged a person" + + self.reason = genReason(reason: apiType.adminPurgePerson.reason) + self.expires = .inapplicable + self.additionalContext = nil + + self.icon = .init(imageName: Icons.purge, color: .primary) + + self.contextLinks = [ + ModlogMenuFunction.administrator(apiType.admin) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIAdminPurgeCommunityView) { + self.action = .communityPurge + self.date = apiType.adminPurgeCommunity.when_ + + let agent = genAdministratorAgent(agent: apiType.admin) + self.description = "\(agent) purged a community" + + self.reason = genReason(reason: apiType.adminPurgeCommunity.reason) + self.expires = .inapplicable + self.additionalContext = nil + + self.icon = .init(imageName: Icons.purge, color: .primary) + + self.contextLinks = [ + ModlogMenuFunction.administrator(apiType.admin) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIAdminPurgePostView) { + self.action = .postPurge + self.date = apiType.adminPurgePost.when_ + + let agent = genAdministratorAgent(agent: apiType.admin) + self.description = "\(agent) purged a post from \(apiType.community.fullyQualifiedName)" + + self.reason = genReason(reason: apiType.adminPurgePost.reason) + self.expires = .inapplicable + self.additionalContext = nil + + self.icon = .init(imageName: Icons.purge, color: .primary) + + self.contextLinks = [ + ModlogMenuFunction.administrator(apiType.admin), + ModlogMenuFunction.community(apiType.community) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIAdminPurgeCommentView) { + self.action = .commentPurge + self.date = apiType.adminPurgeComment.when_ + + let agent = genAdministratorAgent(agent: apiType.admin) + self.description = "\(agent) purged a comment from \"\(apiType.post.name)\"" + + self.reason = genReason(reason: apiType.adminPurgeComment.reason) + self.expires = .inapplicable + self.additionalContext = nil + + self.icon = .init(imageName: Icons.purge, color: .primary) + + self.contextLinks = [ + ModlogMenuFunction.administrator(apiType.admin), + ModlogMenuFunction.post(apiType.post) + ].compactMap { $0.toMenuFunction() } + } + + init(from apiType: APIModHideCommunityView) { + self.action = .communityHide + self.date = apiType.modHideCommunity.when_ + + let agent = genAdministratorAgent(agent: apiType.admin) + let verb = apiType.modHideCommunity.hidden ? "hid" : "unhid" + self.description = "\(agent) \(verb) community \(apiType.community.fullyQualifiedName)" + + self.reason = genReason(reason: apiType.modHideCommunity.reason) + self.expires = .inapplicable + self.additionalContext = nil + + self.icon = apiType.modHideCommunity.hidden ? + .init(imageName: Icons.hide, color: .red) : + .init(imageName: Icons.show, color: .green) + + self.contextLinks = [ + ModlogMenuFunction.administrator(apiType.admin), + ModlogMenuFunction.community(apiType.community) + ].compactMap { $0.toMenuFunction() } + } + + static func == (lhs: ModlogEntry, rhs: ModlogEntry) -> Bool { + lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(date) + hasher.combine(description) + hasher.combine(contextLinks.map(\.id)) + } +} + +// MARK: helpers + +// TODO: resolve links to communities from other instances +private enum ModlogMenuFunction { + case administrator(APIPerson?) + case moderator(APIPerson?) + case user(APIPerson, String) // user, verb + case post(APIPost?) + case community(APICommunity?) + // comment not available because Lemmy API does not currently support comment object resolution + + func toMenuFunction() -> MenuFunction? { + switch self { + case let .administrator(administrator): + guard let administrator else { return nil } + return .openUrlMenuFunction( + text: "View Administrator", + imageName: Icons.moderation, + destination: administrator.actorId + ) + case let .moderator(moderator): + guard let moderator else { return nil } + return .openUrlMenuFunction( + text: "View Moderator", + imageName: Icons.moderation, + destination: moderator.actorId + ) + case let .user(user, verb): + return .openUrlMenuFunction( + text: "View \(verb) User", + imageName: Icons.user, + destination: user.actorId + ) + case let .post(post): + guard let post else { return nil } + return .openUrlMenuFunction( + text: "View Post", + imageName: Icons.posts, + destination: post.apId + ) + case let .community(community): + guard let community else { return nil } + return .openUrlMenuFunction( + text: "View Community", + imageName: Icons.communityButton, + destination: community.actorId + ) + } + } +} + +private func genAdministratorAgent(agent: APIPerson?) -> String { + agent?.fullyQualifiedName ?? "Administrator" +} + +private func genModeratorAgent(agent: APIPerson?) -> String { + agent?.fullyQualifiedName ?? "Moderator" +} + +private func genReason(reason: String?) -> ModlogReason { + if let strippedReason = reason?.trimmingCharacters(in: .whitespacesAndNewlines), !strippedReason.isEmpty { + return .reason(strippedReason) + } + return .noneGiven +} + +private func genExpires(expires: Date?) -> ModlogExpiration { + if let expires { + return .date(expires) + } + return .permanent +} + +extension APIPerson { + var fullyQualifiedName: String { + "\(name)@\(actorId.host() ?? "unknown")" + } +} + +extension APICommunity { + var fullyQualifiedName: String { + "\(name)@\(actorId.host() ?? "unknown")" + } +} + +// swiftlint:enable file_length diff --git a/Mlem/Models/Content/Post/PostModel+MenuFunctions.swift b/Mlem/Models/Content/Post/PostModel+MenuFunctions.swift index ae3ce38fe..9b7d08474 100644 --- a/Mlem/Models/Content/Post/PostModel+MenuFunctions.swift +++ b/Mlem/Models/Content/Post/PostModel+MenuFunctions.swift @@ -6,17 +6,309 @@ // import Foundation +import SwiftUI + +// swiftlint:disable file_length extension PostModel { // swiftlint:disable function_body_length - func menuFunctions(editorTracker: EditorTracker, postTracker: StandardPostTracker?) -> [MenuFunction] { + /// Produces menu functions for this post + /// - Parameters: + /// - editorTracker: global EditorTracker + /// - postTracker: optional StandardPostTracker. If present, the block function will remove posts from the tracker by the blocked user. + /// - community: optional CommunityModel. If this and modToolTracker are present, moderator functions will be included in the menu. + /// - modToolTracker: optional ModToolTracker. If this and community are present, moderator functions will be included in the menu. + /// - Returns: menu functions for this post + @MainActor func combinedMenuFunctions( + isExpanded: Bool = false, + editorTracker: EditorTracker, + showSelectText: Bool = true, + postTracker: StandardPostTracker? = nil, + commentTracker: CommentTracker? = nil, + community: CommunityModel? = nil, + modToolTracker: ModToolTracker? = nil + ) -> [MenuFunction] { + @AppStorage("moderatorActionGrouping") var moderatorActionGrouping: ModerationActionGroupingMode = .none + @AppStorage("developerMode") var developerMode = false + + var functions: [MenuFunction] = .init() + + functions.append( + contentsOf: personalMenuFunctions( + editorTracker: editorTracker, + showSelectText: showSelectText, + postTracker: postTracker, + community: community, + modToolTracker: modToolTracker + ) + ) + + if let community, let modToolTracker { + functions.append(.divider) + let modFunctions = modMenuFunctions( + isExpanded: isExpanded, + community: community, + modToolTracker: modToolTracker, + postTracker: postTracker, + commentTracker: commentTracker + ) + if !isExpanded, moderatorActionGrouping != .none { + functions.append( + .groupMenuFunction(text: "Moderation", imageName: Icons.moderation, children: modFunctions) + ) + } else { + functions.append(contentsOf: modFunctions) + } + } + + #if DEBUG + if developerMode { + functions.append(.divider) + functions.append( + buildDeveloperMenu( + editorTracker: editorTracker, + postTracker: postTracker + ) + ) + } + #endif + + return functions + } + + @MainActor func personalMenuFunctions( + editorTracker: EditorTracker, + showSelectText: Bool = true, + postTracker: StandardPostTracker? = nil, + community: CommunityModel? = nil, + modToolTracker: ModToolTracker? = nil + ) -> [MenuFunction] { + var functions: [MenuFunction] = .init() + + functions.append(contentsOf: topRowMenuFunctions(editorTracker: editorTracker)) + + if showSelectText { + var text = post.name + if let body = post.body, body.isNotEmpty { + text += "\n\n\(body)" + } + functions.append(MenuFunction.standardMenuFunction( + text: "Select Text", + imageName: Icons.select + ) { + editorTracker.openEditor(with: SelectTextModel(text: text)) + }) + } + + if creator.isActiveAccount { + // Edit + functions.append(MenuFunction.standardMenuFunction( + text: "Edit", + imageName: Icons.edit + ) { + editorTracker.openEditor(with: PostEditorModel(post: self)) + }) + + // Delete + functions.append(MenuFunction.standardMenuFunction( + text: "Delete", + imageName: Icons.delete, + confirmationPrompt: "Are you sure you want to delete this post? This cannot be undone.", + enabled: !post.deleted + ) { + Task(priority: .userInitiated) { + await self.delete() + } + }) + } + + // Share + functions.append(MenuFunction.shareMenuFunction(url: post.apId)) + + if !creator.isActiveAccount { + if modToolTracker == nil { + // Report + functions.append(MenuFunction.standardMenuFunction( + text: "Report", + imageName: Icons.moderationReport, + isDestructive: true + ) { + editorTracker.openEditor( + with: ConcreteEditorModel(post: self, operation: PostOperation.reportPost) + ) + }) + } + + if let postTracker { + functions.append(contentsOf: blockMenuFunctions(postTracker: postTracker)) + } + } + + return [.controlGroupMenuFunction(children: functions)] + } + + @MainActor func modMenuFunctions( + isExpanded: Bool = false, + community: CommunityModel, + modToolTracker: ModToolTracker, + postTracker: StandardPostTracker? = nil, + commentTracker: CommentTracker? = nil + ) -> [MenuFunction] { var functions: [MenuFunction] = .init() + @AppStorage("showAllModeratorActions") var showAllModeratorActions = false + let showAllActions = isExpanded || showAllModeratorActions + @AppStorage("moderatorActionGrouping") var moderatorActionGrouping: ModerationActionGroupingMode = .none + + if showAllActions { + if siteInformation.isAdmin { + functions.append(MenuFunction.standardMenuFunction( + text: "Pin", + imageName: Icons.pin, + prompt: "Pin/Unpin from...", + actions: [ + .init(text: "Community") { + Task { + await self.toggleFeatured(featureType: .community) + await self.notifier.add(.success("\(self.post.featuredCommunity ? "P" : "Unp")inned post")) + } + }, + .init(text: "Instance") { + Task { + await self.toggleFeatured(featureType: .local) + await self.notifier.add(.success("\(self.post.featuredLocal ? "P" : "Unp")inned post")) + } + } + ] + )) + } else { + functions.append(MenuFunction.toggleableMenuFunction( + toggle: post.featuredCommunity, + trueText: "Unpin", + trueImageName: Icons.unpin, + falseText: "Pin", + falseImageName: Icons.pin + ) { + Task { + await self.toggleFeatured(featureType: .community) + await self.notifier.add(.success("\(self.post.featuredCommunity ? "P" : "Unp")inned post")) + } + }) + } + + functions.append(MenuFunction.toggleableMenuFunction( + toggle: post.locked, + trueText: "Unlock", + trueImageName: Icons.unlock, + falseText: "Lock", + falseImageName: Icons.lock + ) { + Task { + await self.toggleLocked() + await self.notifier.add(.success("\(self.post.locked ? "L" : "Unl")ocked post")) + } + }) + + // TODO: 0.19 deprecation + if siteInformation.isAdmin || ((siteInformation.version ?? .zero) > .init("0.19.3")) { + functions.append(MenuFunction.navigationMenuFunction( + text: "View Votes", + imageName: Icons.votes, + destination: .postVotes(self) + )) + } + } + + if creator.userId != siteInformation.userId { + functions.append(MenuFunction.toggleableMenuFunction( + toggle: post.removed, + trueText: "Restore", + trueImageName: Icons.restore, + falseText: "Remove", + falseImageName: Icons.remove, + isDestructive: .whenFalse + ) { + modToolTracker.removePost(self, shouldRemove: !self.post.removed) + }) + } + + if siteInformation.isAdmin { + functions.append(MenuFunction.standardMenuFunction( + text: "Purge", + imageName: Icons.purge, + isDestructive: true + ) { + modToolTracker.purgeContent(self) + } + ) + } + + if creator.userId != siteInformation.userId { + if siteInformation.isAdmin { + functions.append(.divider) + } + + // for admins, default to instance ban iff not a moderator of this community + if siteInformation.isAdmin, !siteInformation.moderatedCommunities.contains(community.communityId) { + functions.append(MenuFunction.toggleableMenuFunction( + toggle: creator.banned, + trueText: "Unban User", + trueImageName: Icons.instanceUnban, + falseText: "Ban User", + falseImageName: Icons.instanceBan, + isDestructive: .whenFalse + ) { + modToolTracker.banUser( + self.creator, + from: community, + bannedFromCommunity: self.creatorBannedFromCommunity, + shouldBan: !self.creator.banned, + userRemovalWalker: .init(postTracker: postTracker, commentTracker: commentTracker) + ) + }) + } else { + functions.append(MenuFunction.toggleableMenuFunction( + toggle: creatorBannedFromCommunity, + trueText: "Unban User", + trueImageName: Icons.communityUnban, + falseText: "Ban User", + falseImageName: Icons.communityBan, + isDestructive: .whenFalse + ) { + modToolTracker.banUser( + self.creator, + from: community, + bannedFromCommunity: self.creatorBannedFromCommunity, + shouldBan: !self.creatorBannedFromCommunity, + userRemovalWalker: .init(postTracker: postTracker, commentTracker: commentTracker) + ) + }) + } + + if siteInformation.isAdmin { + functions.append(MenuFunction.standardMenuFunction( + text: "Purge User", + imageName: Icons.purge, + isDestructive: true + ) { + modToolTracker.purgeContent(self.creator) + } + ) + } + } + + return functions + } + + // swiftlint:enable function_body_length + + private func topRowMenuFunctions(editorTracker: EditorTracker) -> [MenuFunction] { + var functions = [MenuFunction]() + // Upvote functions.append(MenuFunction.standardMenuFunction( text: votes.myVote == .upvote ? "Undo Upvote" : "Upvote", imageName: votes.myVote == .upvote ? Icons.upvoteSquareFill : Icons.upvoteSquare, - destructiveActionPrompt: nil, enabled: true ) { Task(priority: .userInitiated) { @@ -28,7 +320,6 @@ extension PostModel { functions.append(MenuFunction.standardMenuFunction( text: votes.myVote == .downvote ? "Undo Downvote" : "Downvote", imageName: votes.myVote == .downvote ? Icons.downvoteSquareFill : Icons.downvoteSquare, - destructiveActionPrompt: nil, enabled: true ) { Task(priority: .userInitiated) { @@ -39,20 +330,18 @@ extension PostModel { // Save functions.append(MenuFunction.standardMenuFunction( text: saved ? "Unsave" : "Save", - imageName: saved ? Icons.unsave : Icons.save, - destructiveActionPrompt: nil, + imageName: saved ? Icons.saveFill : Icons.save, enabled: true ) { Task(priority: .userInitiated) { await self.toggleSave() } }) - + // Reply functions.append(MenuFunction.standardMenuFunction( text: "Reply", imageName: Icons.reply, - destructiveActionPrompt: nil, enabled: true ) { editorTracker.openEditor( @@ -60,88 +349,143 @@ extension PostModel { ) }) - if creator.isActiveAccount { - // Edit - functions.append(MenuFunction.standardMenuFunction( - text: "Edit", - imageName: Icons.edit, - destructiveActionPrompt: nil, - enabled: true - ) { - editorTracker.openEditor(with: PostEditorModel(post: self)) - }) - - // Delete - functions.append(MenuFunction.standardMenuFunction( - text: "Delete", - imageName: Icons.delete, - destructiveActionPrompt: "Are you sure you want to delete this post? This cannot be undone.", - enabled: !post.deleted - ) { - Task(priority: .userInitiated) { - await self.delete() + return functions + } + + // swiftlint:disable:next function_body_length + private func blockMenuFunctions(postTracker: StandardPostTracker) -> [MenuFunction] { + let blockUserCallback = { + Task(priority: .userInitiated) { + await self.creator.toggleBlock { self.creator = $0 } + if self.creator.blocked { + await postTracker.applyFilter(.blockedUser(self.creator.userId)) + await self.notifier.add(.failure("Blocked \(self.creator.name ?? "user")")) + } else { + await self.notifier.add(.failure("Failed to block user")) } - }) + } + return () } - // Share - if let url = URL(string: post.apId) { - functions.append(MenuFunction.shareMenuFunction(url: url)) + let unblockUserCallback = { + Task(priority: .userInitiated) { + await self.creator.toggleBlock { self.creator = $0 } + if !self.creator.blocked { + await self.notifier.add(.success("Unblocked \(self.creator.name ?? "user")")) + } else { + await self.notifier.add(.failure("Failed to unblock user")) + } + } + return () } - if !creator.isActiveAccount { - // Report - functions.append(MenuFunction.standardMenuFunction( - text: "Report", - imageName: Icons.moderationReport, - destructiveActionPrompt: AppConstants.reportPostPrompt, - enabled: true - ) { - editorTracker.openEditor( - with: ConcreteEditorModel(post: self, operation: PostOperation.reportPost) - ) - }) + let blockCommunityCallback = { + Task(priority: .userInitiated) { + try await self.community.toggleBlock { self.community = $0 } + if self.community.blocked ?? false { + await postTracker.applyFilter(.blockedCommunity(self.community.communityId)) + await self.notifier.add(.success("Blocked \(self.community.name ?? "community")")) + } else { + await self.notifier.add(.failure("Failed to block community")) + } + } + return () + } + + let unblockCommunityCallback = { + Task(priority: .userInitiated) { + await postTracker.removeFilter(.blockedCommunity(self.community.communityId)) + try await self.community.toggleBlock { self.community = $0 } + if !(self.community.blocked ?? false) { + await self.notifier.add(.success("Unblocked \(self.community.name ?? "community")")) + } else { + await self.notifier.add(.failure("Failed to unblock community")) + } + } + return () + } + + var functions: [MenuFunction] = .init() + if !(community.blocked ?? true), !creator.blocked { + var blockActions: [MenuFunctionPopup.Action] = [ + .init(text: "Block User", isDestructive: true, callback: blockUserCallback), + .init(text: "Block Community", isDestructive: true, callback: blockCommunityCallback) + ] - if let postTracker { - // Block User - functions.append(MenuFunction.standardMenuFunction( - text: "Block User", - imageName: Icons.userBlock, - destructiveActionPrompt: AppConstants.blockUserPrompt, - enabled: true - ) { - Task(priority: .userInitiated) { - await self.creator.toggleBlock { self.creator = $0 } - if self.creator.blocked { - await postTracker.applyFilter(.blockedUser(self.creator.userId)) - await self.notifier.add(.failure("Blocked \(self.creator.name ?? "user")")) - } else { - await self.notifier.add(.failure("Failed to block user")) - } - } - }) - - // Block Community - functions.append(MenuFunction.standardMenuFunction( - text: "Block Community", + functions.append( + .standardMenuFunction( + text: "Block...", imageName: Icons.hide, - destructiveActionPrompt: AppConstants.blockCommunityPrompt, - enabled: true - ) { - Task(priority: .userInitiated) { - try await self.community.toggleBlock { self.community = $0 } - if self.community.blocked ?? false { - await postTracker.applyFilter(.blockedCommunity(self.community.communityId)) - await self.notifier.add(.failure("Blocked \(self.community.name ?? "community")")) - } else { - await self.notifier.add(.failure("Failed to block community")) - } - } - }) + isDestructive: true, + prompt: "Block User or Community?", + actions: blockActions + ) + ) + } else { + if creator.blocked { + functions.append( + .standardMenuFunction( + text: "Unblock User", + imageName: Icons.show, + callback: unblockUserCallback + ) + ) + } else { + functions.append( + .standardMenuFunction( + text: "Block User", + imageName: Icons.hide, + confirmationPrompt: AppConstants.blockUserPrompt, + callback: blockUserCallback + ) + ) + } + if community.blocked ?? false { + functions.append( + .standardMenuFunction( + text: "Unblock Community", + imageName: Icons.show, + callback: unblockCommunityCallback + ) + ) + } else { + functions.append( + .standardMenuFunction( + text: "Block Community", + imageName: Icons.hide, + confirmationPrompt: AppConstants.blockCommunityPrompt, + callback: blockCommunityCallback + ) + ) } } - return functions } - // swiftlint:enable function_body_length + + #if DEBUG + private func buildDeveloperMenu( + editorTracker: EditorTracker, + postTracker: StandardPostTracker? + ) -> MenuFunction { + MenuFunction.groupMenuFunction( + text: "Developer", + imageName: "wrench", + children: [ + .standardMenuFunction( + text: "Toggle Read State", + imageName: "book.and.wrench", + enabled: true, + callback: { + Task { + let newState = !self.read + await self.markRead(newState) + } + } + ) + ] + ) + } + #endif } + +// swiftlint:enable file_length diff --git a/Mlem/Models/Content/Post/PostModel.swift b/Mlem/Models/Content/Post/PostModel.swift index 8d306d97f..5f6fc1e63 100644 --- a/Mlem/Models/Content/Post/PostModel.swift +++ b/Mlem/Models/Content/Post/PostModel.swift @@ -7,13 +7,17 @@ import Dependencies import Foundation +import SwiftUI +// swiftlint:disable type_body_length /// Internal model to represent a post /// Note: this is just the first pass at decoupling the internal models from the API models--to avoid massive merge conflicts and an unreviewably large PR, I've kept the structure practically identical, and will slowly morph it over the course of several PRs. Eventually all of the API types that this model uses will go away and everything downstream of the repositories won't ever know there's an API at all :) -class PostModel: ContentIdentifiable, ObservableObject { +class PostModel: ContentIdentifiable, Removable, Purgable, ObservableObject { @Dependency(\.hapticManager) var hapticManager @Dependency(\.errorHandler) var errorHandler + @Dependency(\.apiClient) var apiClient @Dependency(\.postRepository) var postRepository + @Dependency(\.commentRepository) var communityRepository @Dependency(\.siteInformation) var siteInformation @Dependency(\.notifier) var notifier @@ -29,22 +33,36 @@ class PostModel: ContentIdentifiable, ObservableObject { @Published var deleted: Bool var published: Date var updated: Date? + @Published var creatorBannedFromCommunity: Bool var links: [LinkType] + var purged: Bool = false var uid: ContentModelIdentifier { .init(contentType: .post, contentId: postId) } // prevents a voting operation from ocurring while another is ocurring var voting: Bool = false + var linkHost: String? { + guard case .link = postType else { + return nil + } + + if let rawLink = post.url, var host = URL(string: rawLink)?.host() { + host.trimPrefix("www.") + return host + } + return "website" + } + /// Creates a PostModel from an APIPostView /// - Parameter apiPostView: APIPostView to create a PostModel representation of init(from apiPostView: APIPostView) { self.postId = apiPostView.post.id self.post = apiPostView.post self.creator = UserModel(from: apiPostView.creator) - self.creator.blocked = apiPostView.creatorBlocked + creator.blocked = apiPostView.creatorBlocked self.community = CommunityModel(from: apiPostView.community, subscribed: apiPostView.subscribed.isSubscribed) - self.community.blocked = false + community.blocked = false self.votes = VotesModel(from: apiPostView.counts, myVote: apiPostView.myVote) self.commentCount = apiPostView.counts.comments self.unreadCommentCount = apiPostView.unreadComments @@ -53,6 +71,7 @@ class PostModel: ContentIdentifiable, ObservableObject { self.deleted = apiPostView.post.deleted self.published = apiPostView.post.published self.updated = apiPostView.post.updated + self.creatorBannedFromCommunity = apiPostView.creatorBannedFromCommunity self.links = PostModel.parseLinks(from: post.body) } @@ -82,7 +101,8 @@ class PostModel: ContentIdentifiable, ObservableObject { read: Bool? = nil, deleted: Bool? = nil, published: Date? = nil, - updated: Date? = nil + updated: Date? = nil, + creatorBannedFromCommunity: Bool? = nil ) { self.postId = postId ?? other.postId self.post = post ?? other.post @@ -96,6 +116,7 @@ class PostModel: ContentIdentifiable, ObservableObject { self.deleted = deleted ?? other.deleted self.published = published ?? other.published self.updated = updated ?? other.updated + self.creatorBannedFromCommunity = creatorBannedFromCommunity ?? other.creatorBannedFromCommunity self.links = PostModel.parseLinks(from: self.post.body) } @@ -114,6 +135,8 @@ class PostModel: ContentIdentifiable, ObservableObject { read = postModel.read published = postModel.published updated = postModel.updated + creatorBannedFromCommunity = postModel.creatorBannedFromCommunity + links = postModel.links } @@ -137,6 +160,11 @@ class PostModel: ContentIdentifiable, ObservableObject { deleted = newDeleted } + @MainActor + func setCreatorBannedFromCommunity(_ newCreatorBannedFromCommunity: Bool) { + creatorBannedFromCommunity = newCreatorBannedFromCommunity + } + // MARK: Interaction Methods func vote(inputOp: ScoringOperation) async { @@ -181,8 +209,8 @@ class PostModel: ContentIdentifiable, ObservableObject { func toggleSave() async { hapticManager.play(haptic: .success, priority: .low) + @AppStorage("upvoteOnSave") var upvoteOnSave = false let shouldSave: Bool = !saved - let upvoteOnSave = UserDefaults.standard.bool(forKey: "upvoteOnSave") // state fake let original: PostModel = .init(from: self) @@ -226,6 +254,29 @@ class PostModel: ContentIdentifiable, ObservableObject { } } + func toggleFeatured(featureType: APIPostFeatureType) async { + // no state fake because it would be extremely tedious for little value add now but very easy to do post-2.0 + do { + let isFeatured = (featureType == .local) ? post.featuredLocal : post.featuredCommunity + let response = try await apiClient.featurePost(id: postId, shouldFeature: !isFeatured, featureType: featureType) + await reinit(from: PostModel(from: response)) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + } + } + + func toggleLocked() async { + // no state fake because it would be extremely tedious for little value add now but very easy to do post-2.0 + do { + let response = try await apiClient.lockPost(id: postId, shouldLock: !post.locked) + await reinit(from: PostModel(from: response)) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + } + } + func delete() async { // state fake let original: PostModel = .init(from: self) @@ -242,6 +293,43 @@ class PostModel: ContentIdentifiable, ObservableObject { } } + func remove(reason: String?, shouldRemove: Bool) async -> Bool { + // no need to state fake because removal masked by sheet + do { + let response = try await apiClient.removePost( + id: postId, + shouldRemove: shouldRemove, + reason: reason + ) + await reinit(from: response) + return true + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + } + return false + } + + func purge(reason: String?) async -> Bool { + DispatchQueue.main.async { + self.purged = true + } + do { + let response = try await apiClient.purgePost(id: postId, reason: reason) + if !response.success { + throw APIClientError.unexpectedResponse + } + return true + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + DispatchQueue.main.async { + self.purged = false + } + } + return false + } + // MARK: Utility Methods var postType: PostType { @@ -288,3 +376,5 @@ extension PostModel: Equatable { lhs.id == rhs.id } } + +// swiftlint:enable type_body_length diff --git a/Mlem/Models/Content/User/UserModel+MenuFunctions.swift b/Mlem/Models/Content/User/UserModel+MenuFunctions.swift index c064d5196..7c87b92e2 100644 --- a/Mlem/Models/Content/User/UserModel+MenuFunctions.swift +++ b/Mlem/Models/Content/User/UserModel+MenuFunctions.swift @@ -6,53 +6,113 @@ // import Foundation +import SwiftUI extension UserModel { + func blockCallback(_ callback: @escaping (_ item: Self) -> Void = { _ in }) { + let blocked = blocked ?? false + Task { + var new = self + await new.toggleBlock(callback) + if new.blocked != blocked { + await notifier.add(.success("\(blocked ? "Unblocked" : "Blocked") user")) + } else { + await notifier.add(.failure("Failed to \(blocked ? "block" : "block") user")) + } + } + } + func blockMenuFunction(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> MenuFunction { + if blocked { + return .standardMenuFunction( + text: "Unblock", + imageName: Icons.show, + callback: { blockCallback(callback) } + ) + } + return .standardMenuFunction( + text: "Block", + imageName: Icons.hide, + confirmationPrompt: AppConstants.blockUserPrompt, + callback: { blockCallback(callback) } + ) + } + + func banMenuFunction(modToolTracker: ModToolTracker) -> MenuFunction { + .toggleableMenuFunction( + toggle: banned, + trueText: "Unban", + trueImageName: Icons.instanceBan, + falseText: "Ban", + falseImageName: Icons.instanceBan, + isDestructive: .whenFalse, + callback: { + modToolTracker.banUser(self, shouldBan: !banned) + } + ) + } + + func purgeMenuFunction(modToolTracker: ModToolTracker) -> MenuFunction { .standardMenuFunction( - text: blocked ? "Unblock" : "Block", - imageName: blocked ? Icons.show : Icons.hide, - destructiveActionPrompt: blocked ? nil : AppConstants.blockUserPrompt, - enabled: true, + text: "Purge", + imageName: Icons.purge, + isDestructive: true, callback: { - Task { - var new = self - await new.toggleBlock(callback) - } + modToolTracker.purgeContent(self) } ) } - func menuFunctions(_ callback: @escaping (_ item: Self) -> Void = { _ in }) -> [MenuFunction] { + func menuFunctions( + _ callback: @escaping (_ item: Self) -> Void = { _ in }, + modToolTracker: ModToolTracker? = nil + ) -> [MenuFunction] { var functions: [MenuFunction] = .init() - if let instanceHost = profileUrl.host() { - let instance: InstanceModel? - if let site { - instance = .init(from: site) - } else { - instance = nil - } - functions.append( - .navigationMenuFunction( - text: instanceHost, - imageName: Icons.instance, - destination: .instance(instanceHost, instance) + do { + if let instanceHost = profileUrl.host() { + let instance: InstanceModel + if let site { + instance = .init(from: site, isLocal: true) + } else { + instance = try .init(domainName: instanceHost) + } + functions.append( + .navigationMenuFunction( + text: instanceHost, + imageName: Icons.instance, + destination: .instance(instance) + ) ) - ) + } + } catch { + print("Failed to add instance menu function!") } functions.append( .standardMenuFunction( text: "Copy Username", imageName: Icons.copy, - destructiveActionPrompt: nil, - enabled: true, callback: copyFullyQualifiedUsername ) ) functions.append(.shareMenuFunction(url: profileUrl)) - if siteInformation.myUserInfo?.localUserView.person.id != userId { + + let isOwnUser = (siteInformation.myUser?.userId ?? -1) == userId + + // TODO: 2.0 appoint moderator as menu function + + if !isOwnUser { functions.append(blockMenuFunction(callback)) } + + // This has to be outside of the below `if` statement so that it shows when "Appoint As Moderator" is appended + functions.append(.divider) + + if !isOwnUser { + if siteInformation.isAdmin, !(isAdmin ?? false), let modToolTracker { + functions.append(banMenuFunction(modToolTracker: modToolTracker)) + functions.append(purgeMenuFunction(modToolTracker: modToolTracker)) + } + } return functions } } diff --git a/Mlem/Models/Content/User/UserModel.swift b/Mlem/Models/Content/User/UserModel.swift index ae0c68759..bdd05b8c0 100644 --- a/Mlem/Models/Content/User/UserModel.swift +++ b/Mlem/Models/Content/User/UserModel.swift @@ -9,12 +9,13 @@ import Dependencies import Foundation import SwiftUI -struct UserModel { +struct UserModel: Purgable { @Dependency(\.personRepository) var personRepository @Dependency(\.hapticManager) var hapticManager @Dependency(\.siteInformation) var siteInformation @Dependency(\.errorHandler) var errorHandler @Dependency(\.notifier) var notifier + @Dependency(\.apiClient) var apiClient // Ids var userId: Int! @@ -82,8 +83,11 @@ struct UserModel { /// Creates a UserModel from an APIPerson. Note that using this initialiser nullifies count values, since /// those are only accessable from APIPersonView. /// - Parameter apiPerson: APIPerson to create a UserModel representation of - init(from person: APIPerson) { + init(from person: APIPerson, blocked: Bool? = nil) { update(with: person) + if let blocked { + self.blocked = blocked + } } mutating func update(with response: GetPersonDetailsResponse) { @@ -118,8 +122,9 @@ struct UserModel { deleted = person.deleted isBot = person.botAccount - isAdmin = person.admin - + if let admin = person.admin { + isAdmin = person.admin + } creationDate = person.published updatedDate = person.updated banExpirationDate = person.banExpires @@ -141,9 +146,16 @@ struct UserModel { func getFlairs( postContext: APIPost? = nil, commentContext: APIComment? = nil, - communityContext: CommunityModel? = nil + communityContext: CommunityModel? = nil, + bannedFromCommunity: Bool? = false ) -> [UserFlair] { var ret: [UserFlair] = .init() + if banned { + ret.append(.bannedFromInstance) + } + if bannedFromCommunity ?? false { + ret.append(.bannedFromCommunity) + } if let post = postContext, post.creatorId == self.userId { ret.append(.op) } @@ -163,9 +175,6 @@ struct UserModel { if isBot { ret.append(.bot) } - if banned { - ret.append(.banned) - } return ret } @@ -186,6 +195,54 @@ struct UserModel { } } + mutating func toggleBan( + expires: Int? = nil, + reason: String? = nil, + removeData: Bool = false, + _ callback: @escaping (_ item: Self) -> Void = { _ in } + ) async { + banned.toggle() + RunLoop.main.perform { [self] in + callback(self) + } + do { + let response = try await apiClient.banPerson( + id: userId, + shouldBan: banned, + expires: expires, + reason: reason, + removeData: removeData + ) + banned = response.banned + RunLoop.main.perform { [self] in + callback(self) + } + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + } + } + + mutating func purge(reason: String?) async -> Bool { + do { + let response = try await apiClient.purgePerson(id: userId, reason: reason) + if !response.success { + throw APIClientError.unexpectedResponse + } + return true + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + } + return false + } + + mutating func addModeratedCommunity(_ newCommunity: CommunityModel) { + var newCommunities = moderatedCommunities ?? .init() + newCommunities.append(newCommunity) + moderatedCommunities = newCommunities + } + static func mock() -> UserModel { self.init(from: APIPerson.mock()) } @@ -229,6 +286,7 @@ extension UserModel: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(uid) hasher.combine(blocked) + hasher.combine(banned) hasher.combine(postCount) hasher.combine(commentCount) hasher.combine(displayName) @@ -236,5 +294,6 @@ extension UserModel: Hashable { hasher.combine(avatar) hasher.combine(banner) hasher.combine(matrixUserId) + hasher.combine(moderatedCommunities) } } diff --git a/Mlem/Models/Menu Function.swift b/Mlem/Models/Menu Function.swift index ad87b203f..7968f304f 100644 --- a/Mlem/Models/Menu Function.swift +++ b/Mlem/Models/Menu Function.swift @@ -19,13 +19,45 @@ enum MenuFunction: Identifiable { return shareImageFunction.id case let .navigation(navigationMenuFunction): return navigationMenuFunction.id + case let .openUrl(openUrlMenuFunction): + return openUrlMenuFunction.id + case let .disclosureGroup(groupMenuFunction): + return groupMenuFunction.id + case let .controlGroup(groupMenuFunction): + return groupMenuFunction.id + case .divider: + return UUID().uuidString } } + case divider // not a menu function per se, but adds a divider to the menu case standard(StandardMenuFunction) case shareUrl(ShareMenuFunction) case shareImage(ShareImageFunction) case navigation(NavigationMenuFunction) + case openUrl(OpenUrlMenuFunction) + case disclosureGroup(DisclosureGroupMenuFunction) + case controlGroup(ControlGroupMenuFunction) +} + +struct MenuFunctionPopup { + struct Action { + var text: String + var isDestructive: Bool = false + var callback: () -> Void + } + + let prompt: String? + let actions: [Action] +} + +enum MenuFunctionActionType { + case standard(callback: () -> Void) + case popup(MenuFunctionPopup) +} + +enum MenuFunctionDestructiveCondition { + case never, always, whenTrue, whenFalse } // some convenience initializers because MenuFunction.standard(StandardMenuFunction...) is ugly @@ -33,19 +65,102 @@ extension MenuFunction { static func standardMenuFunction( text: String, imageName: String, - destructiveActionPrompt: String?, - enabled: Bool, + isDestructive: Bool = false, + enabled: Bool = true, + callback: @escaping () -> Void + ) -> MenuFunction { + MenuFunction.standard(StandardMenuFunction( + text: text, + imageName: imageName, + isDestructive: isDestructive, + role: .standard(callback: callback), + enabled: enabled + )) + } + + static func standardMenuFunction( + text: String, + imageName: String, + confirmationPrompt: String, + enabled: Bool = true, callback: @escaping () -> Void ) -> MenuFunction { MenuFunction.standard(StandardMenuFunction( text: text, imageName: imageName, - destructiveActionPrompt: destructiveActionPrompt, - enabled: enabled, - callback: callback + isDestructive: true, + role: .popup(.init(prompt: confirmationPrompt, actions: [ + .init(text: "Yes", isDestructive: true, callback: callback) + ])), + enabled: enabled + )) + } + + static func standardMenuFunction( + text: String, + imageName: String, + isDestructive: Bool = false, + enabled: Bool = true, + prompt: String, + actions: [MenuFunctionPopup.Action] + ) -> MenuFunction { + MenuFunction.standard(StandardMenuFunction( + text: text, + imageName: imageName, + isDestructive: isDestructive, + role: .popup(.init(prompt: prompt, actions: actions)), + enabled: enabled + )) + } + + static func groupMenuFunction( + text: String, + imageName: String, + children: [MenuFunction] + ) -> MenuFunction { + MenuFunction.disclosureGroup(DisclosureGroupMenuFunction( + text: text, + imageName: imageName, + children: children )) } + static func controlGroupMenuFunction( + children: [MenuFunction] + ) -> MenuFunction { + MenuFunction.controlGroup(ControlGroupMenuFunction(children: children)) + } + + // swiftlint:disable:next function_parameter_count + static func toggleableMenuFunction( + toggle: Bool, + trueText: String, + trueImageName: String, + falseText: String, + falseImageName: String, + isDestructive: MenuFunctionDestructiveCondition = .never, + enabled: Bool = true, + callback: @escaping () -> Void + ) -> MenuFunction { + if toggle { + return standardMenuFunction( + text: trueText, + imageName: trueImageName, + isDestructive: isDestructive == .whenTrue || isDestructive == .always, + enabled: enabled, + callback: callback + ) + } else { + return standardMenuFunction( + text: falseText, + imageName: falseImageName, + isDestructive: isDestructive == .whenFalse || isDestructive == .always, + enabled: enabled, + callback: callback + ) + } + } + static func navigationMenuFunction( text: String, imageName: String, @@ -58,6 +173,18 @@ extension MenuFunction { )) } + static func openUrlMenuFunction( + text: String, + imageName: String, + destination: URL + ) -> MenuFunction { + MenuFunction.openUrl(OpenUrlMenuFunction( + text: text, + imageName: imageName, + destination: destination + )) + } + static func shareMenuFunction(url: URL) -> MenuFunction { MenuFunction.shareUrl(ShareMenuFunction(url: url)) } @@ -86,13 +213,25 @@ struct ShareImageFunction: Identifiable { /// MenuFunction to perform a generic menu action struct StandardMenuFunction: Identifiable { - var id: String { text } + var id: String { UUID().uuidString } let text: String let imageName: String - let destructiveActionPrompt: String? + let isDestructive: Bool + var role: MenuFunctionActionType let enabled: Bool - let callback: () -> Void +} + +struct DisclosureGroupMenuFunction: Identifiable { + var id: String { text } + let text: String + let imageName: String + var children: [MenuFunction] +} + +struct ControlGroupMenuFunction: Identifiable { + var id: String { children.reduce("CONTROL") { $0 + $1.id } } + var children: [MenuFunction] } struct NavigationMenuFunction: Identifiable { @@ -102,3 +241,11 @@ struct NavigationMenuFunction: Identifiable { let imageName: String let destination: AppRoute } + +struct OpenUrlMenuFunction: Identifiable { + var id: String { text } + + let text: String + let imageName: String + let destination: URL +} diff --git a/Mlem/Models/Navigation Contexts/Lazy Load Post Link.swift b/Mlem/Models/Navigation Contexts/Lazy Load Post Link.swift index 6d52c1382..95cef557a 100644 --- a/Mlem/Models/Navigation Contexts/Lazy Load Post Link.swift +++ b/Mlem/Models/Navigation Contexts/Lazy Load Post Link.swift @@ -16,8 +16,8 @@ struct LazyLoadPostLinkWithContext: Equatable, Identifiable, Hashable { hasher.combine(id) } - var id: Int { post.id } + var id: Int { postId } - let post: APIPost + let postId: Int var scrollTarget: Int? } diff --git a/Mlem/Models/Navigation Contexts/ModlogLink.swift b/Mlem/Models/Navigation Contexts/ModlogLink.swift new file mode 100644 index 000000000..1486dd051 --- /dev/null +++ b/Mlem/Models/Navigation Contexts/ModlogLink.swift @@ -0,0 +1,27 @@ +// +// ModlogLink.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-11. +// + +import Foundation + +enum ModlogLink: Hashable { + case userInstance + case instance(InstanceModel) + case community(CommunityModel) + + func hash(into hasher: inout Hasher) { + switch self { + case .userInstance: + hasher.combine("userInstance") + case let .instance(instance): + hasher.combine("instance") + hasher.combine(instance) + case let .community(community): + hasher.combine("community") + hasher.combine(community) + } + } +} diff --git a/Mlem/Models/PictrsImageModel.swift b/Mlem/Models/PictrsImageModel.swift index be6b9e2db..c73ed07e6 100644 --- a/Mlem/Models/PictrsImageModel.swift +++ b/Mlem/Models/PictrsImageModel.swift @@ -5,8 +5,8 @@ // Created by Sjmarf on 29/09/2023. // -import SwiftUI import PhotosUI +import SwiftUI struct ImageUploadResponse: Codable { public let msg: String? @@ -19,7 +19,7 @@ struct PictrsFile: Codable, Equatable { } struct PictrsImageModel { - enum UploadState { + enum UploadState: Equatable { case waiting case readyToUpload(data: Data) case uploading(progress: Double) diff --git a/Mlem/Models/Settings/LayoutWidgets/LayoutWidget.swift b/Mlem/Models/Settings/LayoutWidgets/LayoutWidget.swift index 84be6a802..a9d7e70ea 100644 --- a/Mlem/Models/Settings/LayoutWidgets/LayoutWidget.swift +++ b/Mlem/Models/Settings/LayoutWidgets/LayoutWidget.swift @@ -7,6 +7,41 @@ import SwiftUI +struct InfoStackContent { + let colorizeVotes: Bool + let votes: DetailedVotes + let published: Date + let updated: Date? + let commentCount: Int + let unreadCommentCount: Int + let saved: Bool +} + +enum EnrichedLayoutWidget { + case upvote(myVote: ScoringOperation, upvote: () async -> Void) + case downvote(myVote: ScoringOperation, downvote: () async -> Void) + case save(saved: Bool, save: () async -> Void) + case reply(reply: () -> Void) + case share(shareUrl: URL) + case upvoteCounter(votes: VotesModel, upvote: () async -> Void) + case downvoteCounter(votes: VotesModel, downvote: () async -> Void) + case scoreCounter(votes: VotesModel, upvote: () async -> Void, downvote: () async -> Void) + case resolve(resolved: Bool, resolve: () async -> Void) + case remove(removed: Bool, remove: () -> Void) + case purge(purged: Bool, purge: () -> Void) + case ban(banned: Bool, instanceBan: Bool, ban: () -> Void) + case infoStack( + colorizeVotes: Bool, + votes: VotesModel, + published: Date, + updated: Date?, + commentCount: Int, + unreadCommentCount: Int, + saved: Bool + ) + case spacer // internal type for displaying a "blank interaction bar" +} + indirect enum LayoutWidgetType: String, Hashable, Codable, CaseIterable { var id: Self { self } @@ -19,52 +54,44 @@ indirect enum LayoutWidgetType: String, Hashable, Codable, CaseIterable { case upvoteCounter case downvoteCounter case scoreCounter + case resolve + case remove + case purge + case ban static var allCases: [LayoutWidgetType] { [.infoStack, .upvote, .downvote, .save, .reply, .share, .upvoteCounter, .downvoteCounter, .scoreCounter] } + static var upvoteContaining: Set = [.upvote, .upvoteCounter, .scoreCounter] + static var downvoteContaining: Set = [.downvote, .downvoteCounter, .scoreCounter] + var width: CGFloat { switch self { - case .infoStack: - return .infinity - case .upvote: - return 40 - case .downvote: - return 40 - case .save: - return 40 - case .reply: - return 40 - case .share: - return 40 - case .upvoteCounter: - return 70 - case .downvoteCounter: - return 70 - case .scoreCounter: - return 90 + case .infoStack: .infinity + case .upvote, .downvote, .save, .reply, .share, .resolve, .remove, .purge, .ban: 40 + case .upvoteCounter: 70 + case .downvoteCounter: 70 + case .scoreCounter: 90 } } var cost: Float { switch self { - case .scoreCounter: - return 3 - case .upvoteCounter: - return 2 - case .downvoteCounter: - return 2 - case .infoStack: - return 0 - default: - return 1 + case .scoreCounter: 3 + case .upvoteCounter, .downvoteCounter: 2 + case .infoStack: 0 + default: 1 } } var canRemove: Bool { self != .infoStack } + + static let defaultPostWidgets: [Self] = [.scoreCounter, .infoStack, .save, .reply] + static let defaultCommentWidgets: [Self] = [.scoreCounter, .infoStack, .save, .reply] + static let defaultModeratorWidgets: [Self] = [.resolve, .remove, .infoStack, .purge, .ban] } class LayoutWidget: Equatable, Hashable { diff --git a/Mlem/Models/Trackers/Editor Tracker.swift b/Mlem/Models/Trackers/Editor Tracker.swift index 94bedfdcf..c13c100cf 100644 --- a/Mlem/Models/Trackers/Editor Tracker.swift +++ b/Mlem/Models/Trackers/Editor Tracker.swift @@ -9,6 +9,7 @@ import Foundation class EditorTracker: ObservableObject { @Published var editResponse: ConcreteEditorModel? @Published var editPost: PostEditorModel? + @Published var selectText: SelectTextModel? func openEditor(with editResponse: ConcreteEditorModel) { self.editResponse = editResponse @@ -17,4 +18,13 @@ class EditorTracker: ObservableObject { func openEditor(with editPost: PostEditorModel) { self.editPost = editPost } + + func openEditor(with selectText: SelectTextModel) { + self.selectText = selectText + } +} + +struct SelectTextModel: Identifiable { + var id: Int { text.hashValue } + var text: String } diff --git a/Mlem/Models/Trackers/Feeds/Modlog/ModlogAction.swift b/Mlem/Models/Trackers/Feeds/Modlog/ModlogAction.swift new file mode 100644 index 000000000..e33aa259a --- /dev/null +++ b/Mlem/Models/Trackers/Feeds/Modlog/ModlogAction.swift @@ -0,0 +1,107 @@ +// +// ModlogAction.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-17. +// + +import Foundation + +enum ModlogAction: CaseIterable { + case all, postRemoval, postLock, postPin, commentRemoval, communityRemoval, communityBan, instanceBan, + moderatorAdd, communityTransfer, administratorAdd, personPurge, communityPurge, postPurge, commentPurge, communityHide + + var label: String { + switch self { + case .all: + "All" + case .postRemoval: + "Removed Post" + case .postLock: + "Locked Post" + case .postPin: + "Pinned Post" + case .commentRemoval: + "Removed Comment" + case .communityRemoval: + "Removed Community" + case .communityBan: + "Banned from Community" + case .instanceBan: + "Banned from Instance" + case .moderatorAdd: + "Appointed Moderator" + case .communityTransfer: + "Transferred Community" + case .administratorAdd: + "Appointed Administrator" + case .personPurge: + "Purged Person" + case .communityPurge: + "Purged Community" + case .postPurge: + "Purged Post" + case .commentPurge: + "Purged Comment" + case .communityHide: + "Hid Community" + } + } + + static var communityActionCases: [ModlogAction] { + [.postLock, .postPin, .moderatorAdd, .communityTransfer] + } + + static var removalCases: [ModlogAction] { + [.postRemoval, .commentRemoval, .communityRemoval] + } + + static var banCases: [ModlogAction] { + [.communityBan, .instanceBan] + } + + static var instanceActionCases: [ModlogAction] { + [.administratorAdd, .communityHide] + } + + static var purgeCases: [ModlogAction] { + [.postPurge, .commentPurge, .communityPurge, .personPurge] + } + + var toApiType: APIModlogActionType? { + switch self { + case .all: + nil + case .postRemoval: + .modRemovePost + case .postLock: + .modLockPost + case .postPin: + .modFeaturePost + case .commentRemoval: + .modRemoveComment + case .communityRemoval: + .modRemoveCommunity + case .communityBan: + .modBanFromCommunity + case .instanceBan: + .modBan + case .moderatorAdd: + .modAddCommunity + case .communityTransfer: + .modTransferCommunity + case .administratorAdd: + .modAdd + case .personPurge: + .adminPurgePerson + case .communityPurge: + .adminPurgeCommunity + case .postPurge: + .adminPurgePost + case .commentPurge: + .adminPurgeComment + case .communityHide: + .modHideCommunity + } + } +} diff --git a/Mlem/Models/Trackers/Feeds/Modlog/ModlogChildTracker.swift b/Mlem/Models/Trackers/Feeds/Modlog/ModlogChildTracker.swift new file mode 100644 index 000000000..867d61ac3 --- /dev/null +++ b/Mlem/Models/Trackers/Feeds/Modlog/ModlogChildTracker.swift @@ -0,0 +1,87 @@ +// +// ModlogChildTracker.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-14. +// + +import Dependencies +import Foundation + +/// Class to handle modlog children. Because the API paginates the modlog per-action type, modlog needs to be handled using a multi-tracker; however, because all modlog entries are represented with ModlogEntry, we can define a single generic child tracker instead of needing 14 different ones +class ModlogChildTracker: ChildTracker { + @Dependency(\.apiClient) var apiClient + + private let actionType: ModlogAction + private let instanceUrl: URL? + private let communityId: Int? + private let firstPageProvider: ModlogTracker + + init( + internetSpeed: InternetSpeed, + sortType: TrackerSort.Case, + actionType: ModlogAction, + instance: URL?, + communityId: Int?, + firstPageProvider: ModlogTracker + ) { + self.actionType = actionType + self.instanceUrl = instance + self.communityId = communityId + self.firstPageProvider = firstPageProvider + + super.init(internetSpeed: internetSpeed, sortType: sortType) + } + + override func toParent(item: ModlogEntry) -> ModlogEntry { item } + + init( + internetSpeed: InternetSpeed, + sortType: TrackerSort.Case, + actionType: ModlogAction, + modlogLink: ModlogLink, + firstPageProvider: ModlogTracker + ) { + self.actionType = actionType + switch modlogLink { + case .userInstance: + self.instanceUrl = nil + self.communityId = nil + case let .instance(instance): + self.instanceUrl = instance.url + self.communityId = nil + case let .community(community): + self.instanceUrl = nil + self.communityId = community.communityId + } + self.firstPageProvider = firstPageProvider + + super.init(internetSpeed: internetSpeed, sortType: sortType) + } + + override func fetchPage(page: Int) async throws -> FetchResponse { + // if first page, attempt to fetch from parent tracker + if page == 1 { + if let items = try await firstPageProvider.getPreloadedItems( + for: actionType, + instanceUrl: instanceUrl, + communityId: communityId + ) { + return .init(items: items, cursor: nil, numFiltered: 0) + } else { + assertionFailure("Got no items from parent tracker!") + } + } + + // otherwise (or fallback in prod) get from API + let items = try await apiClient.getModlog( + for: instanceUrl, + communityId: communityId, + page: page, + limit: internetSpeed.pageSize, + type: actionType.toApiType + ) + + return .init(items: items, cursor: nil, numFiltered: 0) + } +} diff --git a/Mlem/Models/Trackers/Feeds/Modlog/ModlogTracker.swift b/Mlem/Models/Trackers/Feeds/Modlog/ModlogTracker.swift new file mode 100644 index 000000000..a5825daf6 --- /dev/null +++ b/Mlem/Models/Trackers/Feeds/Modlog/ModlogTracker.swift @@ -0,0 +1,42 @@ +// +// ModlogTracker.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-14. +// + +import Dependencies +import Foundation +import Semaphore +import SwiftUI + +class ModlogTracker: ParentTracker { + var initialItems: [ModlogEntry]? + + private let initialLoadingSemaphore: AsyncSemaphore = .init(value: 1) + + func getPreloadedItems(for actionType: ModlogAction, instanceUrl: URL?, communityId: Int?) async throws -> [ModlogEntry]? { + @Dependency(\.apiClient) var apiClient + @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast + + // only one thread at a time--this ensures that the initial load is only performed once. Subsequent threads calling this method will await the semaphore and fall into the first block. + await initialLoadingSemaphore.wait() + defer { initialLoadingSemaphore.signal() } + + // if initial load has already been performed, simply return items + if let initialItems { + return initialItems.filter { $0.action == actionType } + } + + // otherwise load items + let newItems = try await apiClient.getModlog( + for: instanceUrl, + communityId: communityId, + page: 1, + limit: internetSpeed.pageSize, + type: nil + ) + initialItems = newItems + return newItems.filter { $0.action == actionType } + } +} diff --git a/Mlem/Models/Trackers/Feeds/StandardPostTracker.swift b/Mlem/Models/Trackers/Feeds/StandardPostTracker.swift index b5d1c2135..ce5d836f4 100644 --- a/Mlem/Models/Trackers/Feeds/StandardPostTracker.swift +++ b/Mlem/Models/Trackers/Feeds/StandardPostTracker.swift @@ -50,7 +50,7 @@ class StandardPostTracker: StandardTracker { // TODO: ERIC keyword filters could be more elegant var filteredKeywords: [String] - var feedType: FeedType + var feedType: PostFeedType private(set) var postSortType: PostSortType private var filters: [PostFilter: Int] @@ -64,7 +64,7 @@ class StandardPostTracker: StandardTracker { maxConcurrentRequestCount: 40 ) - init(internetSpeed: InternetSpeed, sortType: PostSortType, showReadPosts: Bool, feedType: FeedType) { + init(internetSpeed: InternetSpeed, sortType: PostSortType, showReadPosts: Bool, feedType: PostFeedType) { @Dependency(\.persistenceRepository) var persistenceRepository assert(feedType != .saved, "Cannot create StandardPostTracker for saved feed!") @@ -136,7 +136,7 @@ class StandardPostTracker: StandardTracker { } @MainActor - func changeFeedType(to newFeedType: FeedType) async { + func changeFeedType(to newFeedType: PostFeedType) async { // don't do anything if feed type not changed guard feedType != newFeedType else { return @@ -225,12 +225,13 @@ class StandardPostTracker: StandardTracker { /// Given a post, determines whether it should be filtered /// - Returns: the first reason according to which the post should be filtered, if applicable, or nil if the post should not be filtered private func shouldFilterPost(_ postModel: PostModel, filters: [PostFilter]) -> PostFilter? { + let isModerator = siteInformation.moderatedCommunities.contains(postModel.community.communityId) for filter in filters { switch filter { case .read: if postModel.read { return filter } case .keyword: - if postModel.post.name.lowercased().contains(filteredKeywords) { return filter } + if !isModerator, postModel.post.name.lowercased().contains(filteredKeywords) { return filter } case let .blockedUser(userId): if postModel.creator.userId == userId { return filter } case let .blockedCommunity(communityId): diff --git a/Mlem/Models/Trackers/Generics/ChildTracker.swift b/Mlem/Models/Trackers/Generics/ChildTracker.swift index b56666bc2..fc57d708a 100644 --- a/Mlem/Models/Trackers/Generics/ChildTracker.swift +++ b/Mlem/Models/Trackers/Generics/ChildTracker.swift @@ -4,17 +4,27 @@ // // Created by Eric Andrews on 2023-10-16. // + import Foundation +class TrackerStream { + weak var parentTracker: (any ParentTrackerProtocol)? + var cursor: Int + + init(parentTracker: (any ParentTrackerProtocol)? = nil) { + self.parentTracker = parentTracker + self.cursor = 0 + } +} + class ChildTracker: StandardTracker, ChildTrackerProtocol { - private weak var parentTracker: (any ParentTrackerProtocol)? - private var streamCursor: Int = 0 + private var streams: [UUID: TrackerStream] = .init() - private(set) var sortType: TrackerSortType + private(set) var sortType: TrackerSort.Case var allItems: [ParentItem] { items.map { toParent(item: $0) }} - init(internetSpeed: InternetSpeed, sortType: TrackerSortType) { + init(internetSpeed: InternetSpeed, sortType: TrackerSort.Case) { self.sortType = sortType super.init(internetSpeed: internetSpeed) } @@ -23,22 +33,27 @@ class ChildTracker: StandardTracker< preconditionFailure("This method must be implemented by the inheriting class") } - func setParentTracker(_ newParent: any ParentTrackerProtocol) { - parentTracker = newParent + func addParentTracker(_ newParent: any ParentTrackerProtocol) { + streams[newParent.uuid] = .init(parentTracker: newParent) } /// Gets the next item in the feed stream and increments the cursor /// - Returns: next item in the feed stream - /// - Warning: This is NOT a thread-safe function! Only one thread at a time may call this function! - func consumeNextItem() -> ParentItem? { + /// - Warning: This is NOT a thread-safe function! Only one thread at a time per stream may call this function! + func consumeNextItem(streamId: UUID) -> ParentItem? { + guard let stream = streams[streamId], stream.parentTracker != nil else { + print("[\(Item.self) tracker] (consumeNextItem) could not find stream or parent for \(streamId)") + return nil + } + assert( - streamCursor < items.count, - "consumeNextItem called on a tracker without a next item (cursor: \(streamCursor), count: \(items.count))!" + stream.cursor < items.count, + "consumeNextItem called on a tracker without a next item (cursor: \(stream.cursor), count: \(items.count))!" ) - if streamCursor < items.count { - streamCursor += 1 - return toParent(item: items[streamCursor - 1]) + if stream.cursor < items.count { + stream.cursor += 1 + return toParent(item: items[stream.cursor - 1]) } return nil @@ -47,12 +62,17 @@ class ChildTracker: StandardTracker< /// Gets the sort value of the next item in feed stream for a given sort type without affecting the cursor. The sort type must match the sort type of this tracker. /// - Parameter sortType: type of sorting being performed /// - Returns: sorting value of the next tracker item corresponding to the given sort type - /// - Warning: This is NOT a thread-safe function! Only one thread at a time may call this function! - func nextItemSortVal(sortType: TrackerSortType) async throws -> TrackerSortVal? { + /// - Warning: This is NOT a thread-safe function! Only one thread at a time per stream may call this function! + func nextItemSortVal(streamId: UUID, sortType: TrackerSort.Case) async throws -> TrackerSort? { assert(sortType == self.sortType, "Conflicting types for sortType! This will lead to unexpected sorting behavior.") + + guard let stream = streams[streamId], stream.parentTracker != nil else { + print("[\(Item.self) tracker] (nextItemSortVal) could not find stream or parent for \(streamId)") + return nil + } - if streamCursor < items.count { - return items[streamCursor].sortVal(sortType: sortType) + if stream.cursor < items.count { + return items[stream.cursor].sortVal(sortType: sortType) } else { // if done loading, return nil if loadingState == .done { @@ -62,28 +82,41 @@ class ChildTracker: StandardTracker< // otherwise, wait for the next page to load and try to return the first value // if the next page is already loading, this call to loadNextPage will be noop, but still wait until that load completes thanks to the semaphore await loadMoreItems() - return streamCursor < items.count ? items[streamCursor].sortVal(sortType: sortType) : nil + return stream.cursor < items.count ? items[stream.cursor].sortVal(sortType: sortType) : nil } } /// Resets the cursor to 0 but does not unload any items - func resetCursor() { - streamCursor = 0 + func resetCursor(streamId: UUID) { + guard let stream = streams[streamId], stream.parentTracker != nil else { + print("[\(Item.self) tracker] (resetCursor) could not find stream or parent for \(streamId)") + return + } + + stream.cursor = 0 } - func refresh(clearBeforeRefresh: Bool, notifyParent: Bool = true) async throws { + func refresh(streamId: UUID, clearBeforeRefresh: Bool, notifyParent: Bool = true) async throws { + guard let stream = streams[streamId], let parentTracker = stream.parentTracker else { + print("[\(Item.self) tracker] (refresh) could not find stream or parent for \(streamId)") + return + } + try await refresh(clearBeforeRefresh: clearBeforeRefresh) - streamCursor = 0 + stream.cursor = 0 - if notifyParent, let parentTracker { - await parentTracker.refresh(clearBeforeFetch: clearBeforeRefresh) - } + await parentTracker.refresh(clearBeforeFetch: clearBeforeRefresh) } - func reset(notifyParent: Bool = true) async { + func reset(streamId: UUID, notifyParent: Bool = true) async { + guard let stream = streams[streamId], let parentTracker = stream.parentTracker else { + print("[\(Item.self) tracker] (reset) could not find stream or parent for \(streamId)") + return + } + await clear() - streamCursor = 0 - if notifyParent, let parentTracker { + stream.cursor = 0 + if notifyParent { await parentTracker.reset() } } @@ -92,15 +125,16 @@ class ChildTracker: StandardTracker< let newItems = items.filter(filter) let removed = items.count - newItems.count - streamCursor = 0 + for stream in streams.values { + stream.cursor = 0 + } await setItems(newItems) return removed } - /// Filters items from the parent tracker according to the given filtering criterion - /// - Parameter filter: function that, given a TrackerItem, returns true if the item should REMAIN in the tracker - func filterFromParent(with filter: @escaping (any TrackerItem) -> Bool) async { - await parentTracker?.filter(with: filter) + /// Changes the sort type to the specified type, but does NOT refresh! This method should only be called from ParentTracker, which handles the refresh logic so that sort orders don't become tangled up + func changeSortType(to newSortType: TrackerSort.Case) { + sortType = newSortType } } diff --git a/Mlem/Models/Trackers/Generics/ChildTrackerProtocol.swift b/Mlem/Models/Trackers/Generics/ChildTrackerProtocol.swift index cb97ea78c..bd1f88592 100644 --- a/Mlem/Models/Trackers/Generics/ChildTrackerProtocol.swift +++ b/Mlem/Models/Trackers/Generics/ChildTrackerProtocol.swift @@ -16,19 +16,21 @@ protocol ChildTrackerProtocol: AnyObject { // MARK: stream support methods - func setParentTracker(_ newParent: any ParentTrackerProtocol) + func addParentTracker(_ newParent: any ParentTrackerProtocol) - func consumeNextItem() -> ParentItem? + func consumeNextItem(streamId: UUID) -> ParentItem? - func nextItemSortVal(sortType: TrackerSortType) async throws -> TrackerSortVal? + func nextItemSortVal(streamId: UUID, sortType: TrackerSort.Case) async throws -> TrackerSort? - func resetCursor() + func resetCursor(streamId: UUID) // MARK: loading methods - func reset(notifyParent: Bool) async + func reset(streamId: UUID, notifyParent: Bool) async - func refresh(clearBeforeRefresh: Bool, notifyParent: Bool) async throws + func refresh(streamId: UUID, clearBeforeRefresh: Bool, notifyParent: Bool) async throws @discardableResult func filter(with filter: @escaping (Item) -> Bool) async -> Int + + func changeSortType(to newSortType: TrackerSort.Case) } diff --git a/Mlem/Models/Trackers/Generics/CoreTracker.swift b/Mlem/Models/Trackers/Generics/CoreTracker.swift index 4464432f0..cd5d65924 100644 --- a/Mlem/Models/Trackers/Generics/CoreTracker.swift +++ b/Mlem/Models/Trackers/Generics/CoreTracker.swift @@ -8,7 +8,7 @@ import Foundation /// Class providing common tracker functionality for StandardTracker and ParentTracker -class CoreTracker: ObservableObject { +class CoreTracker: ObservableObject, TrackerProtocol { @Published var items: [Item] = .init() @Published private(set) var loadingState: LoadingState = .idle diff --git a/Mlem/Models/Trackers/Generics/ParentTracker.swift b/Mlem/Models/Trackers/Generics/ParentTracker.swift index 74bbe402a..72256417b 100644 --- a/Mlem/Models/Trackers/Generics/ParentTracker.swift +++ b/Mlem/Models/Trackers/Generics/ParentTracker.swift @@ -15,21 +15,30 @@ class ParentTracker: CoreTracker, ParentTrackerProtocol private var childTrackers: [any ChildTrackerProtocol] = .init() private let loadingSemaphore: AsyncSemaphore = .init(value: 1) - private(set) var sortType: TrackerSortType + private(set) var sortType: TrackerSort.Case + + let uuid: UUID - init(internetSpeed: InternetSpeed, sortType: TrackerSortType, childTrackers: [any ChildTrackerProtocol]) { - self.childTrackers = childTrackers + init( + internetSpeed: InternetSpeed, + sortType: TrackerSort.Case, + childTrackers: [any ChildTrackerProtocol] + ) { self.sortType = sortType + self.uuid = .init() super.init(internetSpeed: internetSpeed) - - for child in self.childTrackers { - child.setParentTracker(self) + + self.childTrackers = childTrackers + + childTrackers.forEach { child in + child.addParentTracker(self) } } - func addChildTracker(_ newChild: some ChildTrackerProtocol) { - newChild.setParentTracker(self) + func addChildTracker(_ newChild: some ChildTrackerProtocol, preheat: Bool = false) { + newChild.addParentTracker(self) + childTrackers.append(newChild) } // MARK: main actor methods @@ -68,7 +77,7 @@ class ParentTracker: CoreTracker, ParentTrackerProtocol private func resetChildren() async { // note: this could in theory be run in parallel, but these calls should be super quick so it shouldn't matter for child in childTrackers { - await child.reset(notifyParent: false) + await child.reset(streamId: uuid, notifyParent: false) } } @@ -116,6 +125,15 @@ class ParentTracker: CoreTracker, ParentTrackerProtocol let newItems = await fetchNextItems(numItems: max(remaining, abs(AppConstants.infiniteLoadThresholdOffset) + 1)) await setItems(newItems) } + + /// Changes the sort type of this tracker and all child trackers to the new sort type, then refreshes the feed + func changeSortType(to newSortType: TrackerSort.Case) async { + sortType = newSortType + for tracker in childTrackers { + tracker.changeSortType(to: newSortType) + } + await refresh(clearBeforeFetch: true) + } // MARK: private loading methods @@ -146,20 +164,20 @@ class ParentTracker: CoreTracker, ParentTrackerProtocol } private func computeNextItem() async -> Item? { - var sortVal: TrackerSortVal? + var sortVal: TrackerSort? var trackerToConsume: (any ChildTrackerProtocol)? - for tracker in childTrackers { + for child in childTrackers { (sortVal, trackerToConsume) = await compareNextTrackerItem( sortType: sortType, lhsVal: sortVal, lhsTracker: trackerToConsume, - rhsTracker: tracker + rhsTracker: child ) } if let trackerToConsume { - guard let nextItem = trackerToConsume.consumeNextItem() as? Item else { + guard let nextItem = trackerToConsume.consumeNextItem(streamId: uuid) as? Item else { assertionFailure("Could not convert child item to Item!") return nil } @@ -171,20 +189,20 @@ class ParentTracker: CoreTracker, ParentTrackerProtocol } private func compareNextTrackerItem( - sortType: TrackerSortType, - lhsVal: TrackerSortVal?, + sortType: TrackerSort.Case, + lhsVal: TrackerSort?, lhsTracker: (any ChildTrackerProtocol)?, rhsTracker: any ChildTrackerProtocol - ) async -> (TrackerSortVal?, (any ChildTrackerProtocol)?) { + ) async -> (TrackerSort?, (any ChildTrackerProtocol)?) { do { - guard let rhsVal = try await rhsTracker.nextItemSortVal(sortType: sortType) else { + guard let rhsVal = try await rhsTracker.nextItemSortVal(streamId: uuid, sortType: sortType) else { return (lhsVal, lhsTracker) } guard let lhsVal else { return (rhsVal, rhsTracker) } - + return lhsVal > rhsVal ? (lhsVal, lhsTracker) : (rhsVal, rhsTracker) } catch { errorHandler.handle(error) diff --git a/Mlem/Models/Trackers/Generics/ParentTrackerProtocol.swift b/Mlem/Models/Trackers/Generics/ParentTrackerProtocol.swift index 566808c3a..6756755c5 100644 --- a/Mlem/Models/Trackers/Generics/ParentTrackerProtocol.swift +++ b/Mlem/Models/Trackers/Generics/ParentTrackerProtocol.swift @@ -8,7 +8,9 @@ import Foundation protocol ParentTrackerProtocol: AnyObject { associatedtype Item: TrackerItem - + + var uuid: UUID { get } + func loadIfThreshold(_ item: Item) func refresh(clearBeforeFetch: Bool) async @@ -16,4 +18,6 @@ protocol ParentTrackerProtocol: AnyObject { func reset() async func filter(with filter: @escaping (Item) -> Bool) async + + func changeSortType(to newSortType: TrackerSort.Case) async } diff --git a/Mlem/Models/Trackers/Generics/README - Generic Trackers.md b/Mlem/Models/Trackers/Generics/README - Generic Trackers.md index 60f7de79c..136bcde40 100644 --- a/Mlem/Models/Trackers/Generics/README - Generic Trackers.md +++ b/Mlem/Models/Trackers/Generics/README - Generic Trackers.md @@ -16,7 +16,7 @@ There are three types of trackers: `StandardTracker`, `ChildTracker`, and `Paren `ChildTracker` and `ParentTracker` should always be used in conjunction! They handle feeds with mixed item types (e.g., the inbox feed). `ChildTracker` is a modified version of `StandardTracker`, and can safely be used to drive its own feed in addition to the mixed feed (as is done in the inbox). `ParentTracker` offers a similar interface, but functions radically differently: it relies on its `ChildTracker`s to load items! -To create a multi-tracker, first create a protocol `MyTrackerItem` conforming to `TrackerItem` and an enum `AnyMyTrackerItem` conforming to `MyTrackerItem`. For each child type, create an extension conforming it to `MyTrackerItem` and add a case to `AnyMyTrackerItem` for that type with the associated value of the content type. With that done, create one child tracker for each child type (`class FooTracker: ChildTracker`) and a single parent tracker inheriting from `ParentTracker` (`class MyTracker: ParentTracker`) and a single parent tracker inheriting from `ParentTracker` (`class MyTracker: ParentTracker`). To instantiate a multi-tracker, first instantiate each child tracker, then pass them in to the parent tracker at its initialization. diff --git a/Mlem/Models/Trackers/Generics/TrackerItem.swift b/Mlem/Models/Trackers/Generics/TrackerItem.swift index cb251a3ed..141deeeea 100644 --- a/Mlem/Models/Trackers/Generics/TrackerItem.swift +++ b/Mlem/Models/Trackers/Generics/TrackerItem.swift @@ -8,7 +8,7 @@ import Foundation protocol TrackerItem: Equatable { var uid: ContentModelIdentifier { get } - func sortVal(sortType: TrackerSortType) -> TrackerSortVal + func sortVal(sortType: TrackerSort.Case) -> TrackerSort static func == (lhs: any TrackerItem, rhs: any TrackerItem) -> Bool } diff --git a/Mlem/Models/Trackers/Generics/TrackerProtocol.swift b/Mlem/Models/Trackers/Generics/TrackerProtocol.swift new file mode 100644 index 000000000..d9bb39c22 --- /dev/null +++ b/Mlem/Models/Trackers/Generics/TrackerProtocol.swift @@ -0,0 +1,18 @@ +// +// TrackerProtocol.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-14. +// + +import Foundation + +/// Protocol for common tracker logic +protocol TrackerProtocol: ObservableObject { + associatedtype Item: TrackerItem + + var items: [Item] { get } + var loadingState: LoadingState { get } + func loadMoreItems() async + func loadIfThreshold(_ item: Item) +} diff --git a/Mlem/Models/Trackers/Generics/TrackerSort.swift b/Mlem/Models/Trackers/Generics/TrackerSort.swift index 014e9b182..32fa334c8 100644 --- a/Mlem/Models/Trackers/Generics/TrackerSort.swift +++ b/Mlem/Models/Trackers/Generics/TrackerSort.swift @@ -6,34 +6,42 @@ // import Foundation -enum TrackerSortType { - case published -} - -enum TrackerSortVal: Comparable { - case published(Date) +enum TrackerSort: Comparable { + case new(Date) + case old(Date) - static func typeEquals(lhs: TrackerSortVal, rhs: TrackerSortVal) -> Bool { - switch lhs { - case .published: - switch rhs { - case .published: - return true - } + // case without associated values for easy type comparison + enum Case { + case new, old + } + + var `case`: Case { + switch self { + case .new: .new + case .old: .old } } - static func < (lhs: TrackerSortVal, rhs: TrackerSortVal) -> Bool { - guard typeEquals(lhs: lhs, rhs: rhs) else { + static func < (lhs: TrackerSort, rhs: TrackerSort) -> Bool { + guard lhs.case == rhs.case else { assertionFailure("Compare called on trackersortvals with different types") return true } switch lhs { - case let .published(lhsDate): + case let .new(lhsDate): switch rhs { - case let .published(rhsDate): + case let .new(rhsDate): return lhsDate < rhsDate + default: + return true + } + case let .old(lhsDate): + switch rhs { + case let .old(rhsDate): + return lhsDate > rhsDate + default: + return true } } } diff --git a/Mlem/Models/Trackers/Inbox/ChildTrackers/CommentReportTracker.swift b/Mlem/Models/Trackers/Inbox/ChildTrackers/CommentReportTracker.swift new file mode 100644 index 000000000..83b58e1eb --- /dev/null +++ b/Mlem/Models/Trackers/Inbox/ChildTrackers/CommentReportTracker.swift @@ -0,0 +1,34 @@ +// +// CommentReportTracker.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-27. +// + +import Dependencies +import Foundation + +class CommentReportTracker: ChildTracker { + @Dependency(\.apiClient) var apiClient + + var unreadOnly: Bool + + init(internetSpeed: InternetSpeed, sortType: TrackerSort.Case, unreadOnly: Bool) { + self.unreadOnly = unreadOnly + super.init(internetSpeed: internetSpeed, sortType: sortType) + } + + override func fetchPage(page: Int) async throws -> FetchResponse { + let newItems = try await apiClient.loadCommentReports( + page: page, + limit: internetSpeed.pageSize, + unresolvedOnly: unreadOnly, + communityId: nil + ) + return .init(items: newItems, cursor: nil, numFiltered: 0) + } + + override func toParent(item: CommentReportModel) -> AnyInboxItem { + .commentReport(item) + } +} diff --git a/Mlem/Models/Trackers/Inbox/MentionTracker.swift b/Mlem/Models/Trackers/Inbox/ChildTrackers/MentionTracker.swift similarity index 90% rename from Mlem/Models/Trackers/Inbox/MentionTracker.swift rename to Mlem/Models/Trackers/Inbox/ChildTrackers/MentionTracker.swift index 69f2ea102..dc244d99e 100644 --- a/Mlem/Models/Trackers/Inbox/MentionTracker.swift +++ b/Mlem/Models/Trackers/Inbox/ChildTrackers/MentionTracker.swift @@ -13,7 +13,7 @@ class MentionTracker: ChildTracker { var unreadOnly: Bool - init(internetSpeed: InternetSpeed, sortType: TrackerSortType, unreadOnly: Bool) { + init(internetSpeed: InternetSpeed, sortType: TrackerSort.Case, unreadOnly: Bool) { self.unreadOnly = unreadOnly super.init(internetSpeed: internetSpeed, sortType: sortType) } diff --git a/Mlem/Models/Trackers/Inbox/ChildTrackers/MessageReportTracker.swift b/Mlem/Models/Trackers/Inbox/ChildTrackers/MessageReportTracker.swift new file mode 100644 index 000000000..954ae97cb --- /dev/null +++ b/Mlem/Models/Trackers/Inbox/ChildTrackers/MessageReportTracker.swift @@ -0,0 +1,29 @@ +// +// MessageReportTracker.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Dependencies +import Foundation + +class MessageReportTracker: ChildTracker { + @Dependency(\.apiClient) var apiClient + + var unreadOnly: Bool + + init(internetSpeed: InternetSpeed, sortType: TrackerSort.Case, unreadOnly: Bool) { + self.unreadOnly = unreadOnly + super.init(internetSpeed: internetSpeed, sortType: sortType) + } + + override func fetchPage(page: Int) async throws -> FetchResponse { + let newItems = try await apiClient.loadMessageReports(page: page, limit: internetSpeed.pageSize, unresolvedOnly: unreadOnly) + return .init(items: newItems, cursor: nil, numFiltered: 0) + } + + override func toParent(item: MessageReportModel) -> AnyInboxItem { + .messageReport(item) + } +} diff --git a/Mlem/Models/Trackers/Inbox/MessageTracker.swift b/Mlem/Models/Trackers/Inbox/ChildTrackers/MessageTracker.swift similarity index 90% rename from Mlem/Models/Trackers/Inbox/MessageTracker.swift rename to Mlem/Models/Trackers/Inbox/ChildTrackers/MessageTracker.swift index f0135396f..31c237560 100644 --- a/Mlem/Models/Trackers/Inbox/MessageTracker.swift +++ b/Mlem/Models/Trackers/Inbox/ChildTrackers/MessageTracker.swift @@ -12,7 +12,7 @@ class MessageTracker: ChildTracker { var unreadOnly: Bool - init(internetSpeed: InternetSpeed, sortType: TrackerSortType, unreadOnly: Bool) { + init(internetSpeed: InternetSpeed, sortType: TrackerSort.Case, unreadOnly: Bool) { self.unreadOnly = unreadOnly super.init(internetSpeed: internetSpeed, sortType: sortType) } diff --git a/Mlem/Models/Trackers/Inbox/ChildTrackers/PostReportTracker.swift b/Mlem/Models/Trackers/Inbox/ChildTrackers/PostReportTracker.swift new file mode 100644 index 000000000..c0ef31726 --- /dev/null +++ b/Mlem/Models/Trackers/Inbox/ChildTrackers/PostReportTracker.swift @@ -0,0 +1,34 @@ +// +// PostReportTracker.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Dependencies +import Foundation + +class PostReportTracker: ChildTracker { + @Dependency(\.apiClient) var apiClient + + var unreadOnly: Bool + + init(internetSpeed: InternetSpeed, sortType: TrackerSort.Case, unreadOnly: Bool) { + self.unreadOnly = unreadOnly + super.init(internetSpeed: internetSpeed, sortType: sortType) + } + + override func fetchPage(page: Int) async throws -> FetchResponse { + let newItems = try await apiClient.loadPostReports( + page: page, + limit: internetSpeed.pageSize, + unresolvedOnly: unreadOnly, + communityId: nil + ) + return .init(items: newItems, cursor: nil, numFiltered: 0) + } + + override func toParent(item: PostReportModel) -> AnyInboxItem { + .postReport(item) + } +} diff --git a/Mlem/Models/Trackers/Inbox/ChildTrackers/RegistrationApplicationTracker.swift b/Mlem/Models/Trackers/Inbox/ChildTrackers/RegistrationApplicationTracker.swift new file mode 100644 index 000000000..e79cd5dc8 --- /dev/null +++ b/Mlem/Models/Trackers/Inbox/ChildTrackers/RegistrationApplicationTracker.swift @@ -0,0 +1,33 @@ +// +// RegistrationApplicationTracker.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-05. +// + +import Dependencies +import Foundation + +class RegistrationApplicationTracker: ChildTracker { + @Dependency(\.apiClient) var apiClient + + var unreadOnly: Bool + + init(internetSpeed: InternetSpeed, sortType: TrackerSort.Case, unreadOnly: Bool) { + self.unreadOnly = unreadOnly + super.init(internetSpeed: internetSpeed, sortType: sortType) + } + + override func fetchPage(page: Int) async throws -> FetchResponse { + let newItems = try await apiClient.loadRegistrationApplications( + page: page, + limit: internetSpeed.pageSize, + unresolvedOnly: unreadOnly + ) + return .init(items: newItems, cursor: nil, numFiltered: 0) + } + + override func toParent(item: RegistrationApplicationModel) -> AnyInboxItem { + .registrationApplication(item) + } +} diff --git a/Mlem/Models/Trackers/Inbox/ReplyTracker.swift b/Mlem/Models/Trackers/Inbox/ChildTrackers/ReplyTracker.swift similarity index 85% rename from Mlem/Models/Trackers/Inbox/ReplyTracker.swift rename to Mlem/Models/Trackers/Inbox/ChildTrackers/ReplyTracker.swift index eb5fb11fc..f90b9a656 100644 --- a/Mlem/Models/Trackers/Inbox/ReplyTracker.swift +++ b/Mlem/Models/Trackers/Inbox/ChildTrackers/ReplyTracker.swift @@ -13,13 +13,12 @@ class ReplyTracker: ChildTracker { var unreadOnly: Bool - init(internetSpeed: InternetSpeed, sortType: TrackerSortType, unreadOnly: Bool) { + init(internetSpeed: InternetSpeed, sortType: TrackerSort.Case, unreadOnly: Bool) { self.unreadOnly = unreadOnly super.init(internetSpeed: internetSpeed, sortType: sortType) } override func fetchPage(page: Int) async throws -> FetchResponse { - // TODO: can this return a cursor? let newItems = try await inboxRepository.loadReplies(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) return .init(items: newItems, cursor: nil, numFiltered: 0) } diff --git a/Mlem/Models/Trackers/Inbox/InboxItem.swift b/Mlem/Models/Trackers/Inbox/InboxItem.swift index 11d56c646..2d58696f9 100644 --- a/Mlem/Models/Trackers/Inbox/InboxItem.swift +++ b/Mlem/Models/Trackers/Inbox/InboxItem.swift @@ -12,14 +12,26 @@ protocol InboxItem: Identifiable, ContentIdentifiable, TrackerItem { var published: Date { get } var uid: ContentModelIdentifier { get } var creatorId: Int { get } + var banStatusCreatorId: Int { get } + var creatorBannedFromInstance: Bool { get } + var creatorBannedFromCommunity: Bool { get } var read: Bool { get } var id: Int { get } + + func toAnyInboxItem() -> AnyInboxItem + + func setCreatorBannedFromCommunity(_ newBanned: Bool) + func setCreatorBannedFromInstance(_ newBanned: Bool) } enum AnyInboxItem: InboxItem { case reply(ReplyModel) case mention(MentionModel) case message(MessageModel) + case commentReport(CommentReportModel) + case postReport(PostReportModel) + case messageReport(MessageReportModel) + case registrationApplication(RegistrationApplicationModel) var value: any InboxItem { switch self { @@ -29,6 +41,14 @@ enum AnyInboxItem: InboxItem { return mention case let .message(message): return message + case let .commentReport(commentReport): + return commentReport + case let .postReport(postReport): + return postReport + case let .messageReport(messageReport): + return messageReport + case let .registrationApplication(application): + return application } } @@ -38,9 +58,25 @@ enum AnyInboxItem: InboxItem { var creatorId: Int { value.creatorId } + var banStatusCreatorId: Int { value.banStatusCreatorId } + + var creatorBannedFromCommunity: Bool { value.creatorBannedFromCommunity } + + var creatorBannedFromInstance: Bool { value.creatorBannedFromInstance } + var read: Bool { value.read } var id: Int { value.id } - func sortVal(sortType: TrackerSortType) -> TrackerSortVal { value.sortVal(sortType: sortType) } + func sortVal(sortType: TrackerSort.Case) -> TrackerSort { value.sortVal(sortType: sortType) } + + func toAnyInboxItem() -> AnyInboxItem { self } + + func setCreatorBannedFromCommunity(_ newBanned: Bool) { + value.setCreatorBannedFromCommunity(newBanned) + } + + func setCreatorBannedFromInstance(_ newBanned: Bool) { + value.setCreatorBannedFromInstance(newBanned) + } } diff --git a/Mlem/Models/Trackers/Inbox/UnreadTracker.swift b/Mlem/Models/Trackers/Inbox/UnreadTracker.swift index fee5f5c5e..acc1c03f7 100644 --- a/Mlem/Models/Trackers/Inbox/UnreadTracker.swift +++ b/Mlem/Models/Trackers/Inbox/UnreadTracker.swift @@ -5,99 +5,152 @@ // Created by Eric Andrews on 2023-07-26. // +import Dependencies import Foundation -class UnreadTracker: ObservableObject { - @Published private(set) var replies: Int - @Published private(set) var mentions: Int - @Published private(set) var messages: Int - @Published private(set) var total: Int +struct UnreadCount { + private(set) var count: Int - init() { - self.replies = 0 - self.mentions = 0 - self.messages = 0 - self.total = 0 + init(count: Int) { + self.count = count } - @MainActor - func update(with counts: APIPersonUnreadCounts) { - replies = counts.replies - mentions = counts.mentions - messages = counts.privateMessages - total = counts.replies + counts.mentions + counts.privateMessages + init() { + self.count = 0 } - @MainActor - func reset() { - replies = 0 - mentions = 0 - messages = 0 - total = 0 - } + mutating func reset() { count = 0 } - @MainActor - func readReply() { - replies -= 1 - total -= 1 + mutating func read() { + if count > 0 { + count -= 1 + } else { + assertionFailure("read() called but count was <= 0!") + } } - @MainActor - func unreadReply() { - replies += 1 - total += 1 + mutating func unread() { count += 1 } + + mutating func toggleRead(originalState: Bool) { + if originalState { + unread() + } else { + read() + } } +} + +class UnreadTracker: ObservableObject { + @Dependency(\.apiClient) var apiClient + @Dependency(\.personRepository) var personRepository + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation - @MainActor - func readMention() { - mentions -= 1 - total -= 1 + @Published var replies: UnreadCount + @Published var mentions: UnreadCount + @Published var messages: UnreadCount + @Published var commentReports: UnreadCount + @Published var postReports: UnreadCount + @Published var messageReports: UnreadCount + @Published var registrationApplications: UnreadCount + + @Published var sumPersonal: Bool + @Published var sumModerator: Bool + @Published var sumMessageReports: Bool + @Published var sumRegistrationApplications: Bool + + var total: Int { + replies.count + + mentions.count + + messages.count + + commentReports.count + + postReports.count + + messageReports.count + + registrationApplications.count } - @MainActor - func unreadMention() { - mentions += 1 - total += 1 + var personal: Int { replies.count + mentions.count + messages.count } + var mod: Int { commentReports.count + postReports.count } + var modAndAdmin: Int { commentReports.count + postReports.count + messageReports.count + registrationApplications.count } + var inboxBadgeCount: Int { + (sumPersonal ? personal : 0) + + (sumModerator ? mod : 0) + + (sumMessageReports ? messageReports.count : 0) + + (sumRegistrationApplications ? registrationApplications.count : 0) } - @MainActor - func readMessage() { - messages -= 1 - total -= 1 + init(sumPersonal: Bool, sumModerator: Bool, sumMessageReports: Bool, sumRegistrationApplications: Bool) { + self.replies = .init() + self.mentions = .init() + self.messages = .init() + self.commentReports = .init() + self.postReports = .init() + self.messageReports = .init() + self.registrationApplications = .init() + + self.sumPersonal = sumPersonal + self.sumModerator = sumModerator + self.sumMessageReports = sumMessageReports + self.sumRegistrationApplications = sumRegistrationApplications } @MainActor - func unreadMessage() { - messages += 1 - total += 1 + func reset() { + replies.reset() + mentions.reset() + messages.reset() + commentReports.reset() + postReports.reset() + messageReports.reset() + registrationApplications.reset() } - // convenience methods to flip a read state (if originalState is true (read), will unread a message; if false, will read a message) + func update() async { + async let asyncPersonalCounts = await fetchUnreadPersonalCounts() + async let asyncReportCounts = await fetchUnreadReportCounts() + async let asyncApplicationCount = await fetchUnreadRegistrationApplicationCounts() + + let (personalCounts, reportCounts, applicationCount) = await (asyncPersonalCounts, asyncReportCounts, asyncApplicationCount) + + DispatchQueue.main.async { + self.replies = .init(count: personalCounts.replies) + self.mentions = .init(count: personalCounts.mentions) + self.messages = .init(count: personalCounts.privateMessages) + self.commentReports = .init(count: reportCounts.commentReports) + self.postReports = .init(count: reportCounts.postReports) + self.messageReports = .init(count: reportCounts.privateMessageReports ?? 0) + self.registrationApplications = .init(count: applicationCount.registrationApplications) + } + } - @MainActor - func toggleReplyRead(originalState: Bool) { - if originalState { - unreadReply() - } else { - readReply() + private func fetchUnreadPersonalCounts() async -> APIPersonUnreadCounts { + do { + return try await personRepository.getUnreadCounts() + } catch { + errorHandler.handle(error) } + return .init(replies: 0, mentions: 0, privateMessages: 0) } - @MainActor - func toggleMentionRead(originalState: Bool) { - if originalState { - unreadMention() - } else { - readMention() + private func fetchUnreadReportCounts() async -> APIGetReportCountResponse { + do { + if siteInformation.isAdmin || !siteInformation.moderatedCommunities.isEmpty { + return try await apiClient.getUnreadReports(for: nil) + } + } catch { + errorHandler.handle(error) } + return .init(communityId: nil, commentReports: 0, postReports: 0, privateMessageReports: 0) } - @MainActor - func toggleMessageRead(originalState: Bool) { - if originalState { - unreadMessage() - } else { - readMessage() + private func fetchUnreadRegistrationApplicationCounts() async -> APIGetUnreadRegistrationApplicationCountResponse { + do { + if siteInformation.isAdmin { + return try await apiClient.getUnreadRegistrationApplications() + } + } catch { + errorHandler.handle(error) } + return .init(registrationApplications: 0) } } diff --git a/Mlem/Models/Trackers/LayoutWidgetTracker.swift b/Mlem/Models/Trackers/LayoutWidgetTracker.swift index c6aaacaee..1221021b6 100644 --- a/Mlem/Models/Trackers/LayoutWidgetTracker.swift +++ b/Mlem/Models/Trackers/LayoutWidgetTracker.swift @@ -8,15 +8,26 @@ import Dependencies import Foundation -struct LayoutWidgetGroups: Codable { +struct LayoutWidgetGroups { var post: [LayoutWidgetType] var comment: [LayoutWidgetType] + var moderator: [LayoutWidgetType] +} + +extension LayoutWidgetGroups: Codable { + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.post = (try? values.decode([LayoutWidgetType].self, forKey: .post)) ?? LayoutWidgetType.defaultPostWidgets + self.comment = (try? values.decode([LayoutWidgetType].self, forKey: .comment)) ?? LayoutWidgetType.defaultCommentWidgets + self.moderator = (try? values.decode([LayoutWidgetType].self, forKey: .moderator)) ?? LayoutWidgetType.defaultModeratorWidgets + } } extension LayoutWidgetGroups { init() { - self.post = [.scoreCounter, .infoStack, .save, .reply] - self.comment = [.scoreCounter, .infoStack, .save, .reply] + self.post = LayoutWidgetType.defaultPostWidgets + self.comment = LayoutWidgetType.defaultCommentWidgets + self.moderator = LayoutWidgetType.defaultModeratorWidgets } } diff --git a/Mlem/Models/Trackers/ModToolTracker.swift b/Mlem/Models/Trackers/ModToolTracker.swift new file mode 100644 index 000000000..533c9ad00 --- /dev/null +++ b/Mlem/Models/Trackers/ModToolTracker.swift @@ -0,0 +1,116 @@ +// +// ModToolTracker.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-14. +// + +import Foundation +import SwiftUI + +enum ModTool: Hashable, Identifiable { + // community + case editCommunity(CommunityModel) // community to edit + case removeCommunity(CommunityModel, Bool) // community to remove, should remove + + case purgeContent(any Purgable, UserRemovalWalker) + + case banUser(UserModel, CommunityModel?, Bool?, Bool, UserRemovalWalker, (() -> Void)?) + // user to ban, community to ban from, is banned from community, should ban, callback + + case addMod(Binding?, Binding?) // user to add as mod, community to add mod to + + // post + case removePost(any Removable, Bool, (() -> Void)?) // post to remove, should remove, callback + + // comment + case removeComment(any Removable, Bool, (() -> Void)?) // comment to remove, should remove + + case denyApplication(RegistrationApplicationModel) + + static func == (lhs: ModTool, rhs: ModTool) -> Bool { + lhs.hashValue == rhs.hashValue + } + + var id: Int { hashValue } + + func hash(into hasher: inout Hasher) { + switch self { + case let .editCommunity(community): + hasher.combine("edit") + hasher.combine(community.uid) + case let .banUser(user, community, isBanned, shouldBan, _, _): + hasher.combine("communityBan") + hasher.combine(user.uid) + hasher.combine(community?.uid) + hasher.combine(isBanned) + hasher.combine(shouldBan) + case let .purgeContent(content, _): + hasher.combine("purge") + hasher.combine(content.uid) + case let .addMod(user, community): + hasher.combine("addMod") + hasher.combine(user?.wrappedValue.uid) + hasher.combine(community?.wrappedValue.uid) + case let .removePost(post, shouldRemove, _): + hasher.combine("removePost") + hasher.combine(post) + hasher.combine(shouldRemove) + case let .removeComment(comment, shouldRemove, _): + hasher.combine("removeComment") + hasher.combine(comment) + hasher.combine(shouldRemove) + case let .removeCommunity(community, shouldRemove): + hasher.combine("removeCommunity") + hasher.combine(community.uid) + hasher.combine(shouldRemove) + case let .denyApplication(application): + hasher.combine("denyApplication") + hasher.combine(application) + } + } +} + +/// Tracker for opening mod tools +class ModToolTracker: ObservableObject { + @Published var openTool: ModTool? + + func editCommunity(_ community: CommunityModel) { + openTool = .editCommunity(community) + } + + func banUser( + _ user: UserModel, + from community: CommunityModel? = nil, + bannedFromCommunity: Bool = false, + shouldBan: Bool, + userRemovalWalker: UserRemovalWalker = .init(), + callback: (() -> Void)? = nil + ) { + openTool = .banUser(user, community, bannedFromCommunity, shouldBan, userRemovalWalker, callback) + } + + func addModerator(user: Binding?, to community: Binding?) { + openTool = .addMod(user, community) + } + + func removePost(_ post: any Removable, shouldRemove: Bool, callback: (() -> Void)? = nil) { + openTool = .removePost(post, shouldRemove, callback) + } + + func removeComment(_ comment: any Removable, shouldRemove: Bool, callback: (() -> Void)? = nil) { + openTool = .removeComment(comment, shouldRemove, callback) + } + + func removeCommunity(_ community: CommunityModel, shouldRemove: Bool) { + openTool = .removeCommunity(community, shouldRemove) + } + + func purgeContent(_ content: Purgable, userRemovalWalker: UserRemovalWalker = .init()) { + openTool = .purgeContent(content, userRemovalWalker) + } + + func denyApplication(_ application: RegistrationApplicationModel) { + openTool = .denyApplication(application) + } +} diff --git a/Mlem/Models/Trackers/SiteInformationTracker.swift b/Mlem/Models/Trackers/SiteInformationTracker.swift index 6c1c7baef..43b4334d9 100644 --- a/Mlem/Models/Trackers/SiteInformationTracker.swift +++ b/Mlem/Models/Trackers/SiteInformationTracker.swift @@ -13,12 +13,50 @@ class SiteInformationTracker: ObservableObject { @Dependency(\.apiClient) var apiClient @Dependency(\.errorHandler) var errorHandler @Dependency(\.accountsTracker) var accountsTracker + @Dependency(\.markReadBatcher) var markReadBatcher + @Dependency(\.personRepository) var personRepository @Published private(set) var instance: InstanceModel? @Published private(set) var enableDownvotes = true @Published var version: SiteVersion? @Published private(set) var allLanguages: [APILanguage] = .init() @Published var myUserInfo: APIMyUserInfo? + @Published var myUser: UserModel? + @Published var moderatedCommunities: Set = .init(minimumCapacity: 10) + + var userId: Int? { myUserInfo?.localUserView.person.id } + + /// Look up whether the user moderates a community by ID. Super efficient, backed by quick-access Set of ids + func isMod(communityId: Int) -> Bool { + moderatedCommunities.contains(communityId) + } + + /// Look up whether the user moderates a community by actor ID. Use in cases where you need to check moderation status with community info fetched from a different instance. Less efficient than lookup by id, performs an iterative search of moderated communities + func isMod(communityActorId: URL) -> Bool { + myUserInfo?.moderates.contains { moderatedCommunity in + moderatedCommunity.community.actorId == communityActorId + } ?? false + } + + func isModOrAdmin(communityId: Int) -> Bool { + isMod(communityId: communityId) || (myUser?.isAdmin ?? false) + } + + var isAdmin: Bool { + myUser?.isAdmin ?? false + } + + var feeds: [PostFeedType] { + if moderatorFeedAvailable { + [.all, .local, .subscribed, .moderated, .saved] + } else { + [.all, .local, .subscribed, .saved] + } + } + + var moderatorFeedAvailable: Bool { + !moderatedCommunities.isEmpty && (version ?? .zero) >= .init("0.19.0") + } func load(account: SavedAccount) { version = account.siteVersion @@ -36,7 +74,21 @@ class SiteInformationTracker: ObservableObject { } myUserInfo = response.myUser allLanguages = response.allLanguages + if let userInfo = response.myUser { + myUser = UserModel(from: userInfo.localUserView.person) + myUser?.isAdmin = response.admins.contains { $0.person.id == myUser?.userId } + + if let communities = response.myUser?.moderates { + myUser?.moderatedCommunities = communities.map { CommunityModel(from: $0.community) } + moderatedCommunities = Set(communities.map(\.community.id)) + } else { + moderatedCommunities = .init(minimumCapacity: 1) + } + } + if let version { + markReadBatcher.resolveSiteVersion(to: version) + } } catch { errorHandler.handle(error) } diff --git a/Mlem/Models/Trackers/UserRemovalWalker.swift b/Mlem/Models/Trackers/UserRemovalWalker.swift new file mode 100644 index 000000000..17dd00c1d --- /dev/null +++ b/Mlem/Models/Trackers/UserRemovalWalker.swift @@ -0,0 +1,58 @@ +// +// UserRemovalWalker.swift +// Mlem +// +// Created by Sjmarf on 26/03/2024. +// + +import Foundation + +struct UserRemovalWalker { + var postTracker: StandardPostTracker? + var commentTracker: CommentTracker? + var inboxTracker: InboxTracker? + var votesTracker: VotesTracker? + + func modify( + userId: Int, + postAction: (_ post: PostModel) -> Void, + commentAction: (_ comment: HierarchicalComment) -> Void, + inboxAction: (_ item: AnyInboxItem) -> Void, + voteAction: (_ vote: inout VoteModel) -> Void + ) { + if let postTracker { + for post in postTracker.items where post.creator.userId == userId { + postAction(post) + } + } + if let commentTracker { + for comment in commentTracker.comments where comment.commentView.comment.creatorId == userId { + commentAction(comment) + } + } + if let inboxTracker { + for item in inboxTracker.items where item.banStatusCreatorId == userId { + inboxAction(item) + } + } + if let votesTracker, let index = votesTracker.votes.firstIndex(where: { $0.id == userId }) { + voteAction(&votesTracker.votes[index]) + } + } + + func purge(userId: Int) { + if let postTracker { + for post in postTracker.items where post.creator.userId == userId { + post.purged = true + } + } + if let commentTracker { + for comment in commentTracker.comments where comment.commentView.comment.creatorId == userId { + comment.purged = true + } + } + if let votesTracker, let index = votesTracker.votes.firstIndex(where: { $0.id == userId }) { + votesTracker.votes.remove(at: index) + } + } +} diff --git a/Mlem/Models/UserFlair.swift b/Mlem/Models/UserFlair.swift index b000df65e..9344206c0 100644 --- a/Mlem/Models/UserFlair.swift +++ b/Mlem/Models/UserFlair.swift @@ -10,7 +10,8 @@ import SwiftUI enum UserFlair { case admin case bot - case banned + case bannedFromInstance + case bannedFromCommunity case moderator case developer case op @@ -20,12 +21,12 @@ enum UserFlair { case .admin: return .teal case .moderator: - return .green + return .moderation case .op: return .orange case .bot: return .indigo - case .banned: + case .bannedFromInstance, .bannedFromCommunity: return .red case .developer: return .purple @@ -35,15 +36,17 @@ enum UserFlair { var icon: String { switch self { case .admin: - return Icons.adminFlair + return Icons.adminFill case .moderator: return Icons.moderationFill case .op: return Icons.opFlair case .bot: return Icons.botFlair - case .banned: - return Icons.bannedFlair + case .bannedFromInstance: + return Icons.instanceBannedFlair + case .bannedFromCommunity: + return Icons.communityBannedFlair case .developer: return Icons.developerFlair } @@ -55,8 +58,10 @@ enum UserFlair { return "Administrator" case .bot: return "Bot Account" - case .banned: + case .bannedFromInstance: return "Banned" + case .bannedFromCommunity: + return "Banned from Community" case .moderator: return "Moderator" case .developer: diff --git a/Mlem/Navigation/Destination Values/SettingsValues.swift b/Mlem/Navigation/Destination Values/SettingsValues.swift index 42dd44603..c030899c8 100644 --- a/Mlem/Navigation/Destination Values/SettingsValues.swift +++ b/Mlem/Navigation/Destination Values/SettingsValues.swift @@ -14,11 +14,14 @@ enum SettingsPage: DestinationValue { case accountGeneral case accountAdvanced case accountLocal + case blockList case accountDiscussionLanguages case linkMatrixAccount case accounts case quickSwitcher case general + case moderation + case links case sorting case contentFilters case accessibility @@ -55,3 +58,7 @@ enum PostSettingsPage: DestinationValue { enum LicensesSettingsPage: DestinationValue { case licenseDocument(Document) } + +enum ModerationSettingsPage: DestinationValue { + case customizeWidgets +} diff --git a/Mlem/Navigation/DismissAction.swift b/Mlem/Navigation/DismissAction.swift index 9e016007e..8ecd7ddcf 100644 --- a/Mlem/Navigation/DismissAction.swift +++ b/Mlem/Navigation/DismissAction.swift @@ -19,6 +19,10 @@ final class Navigation: ObservableObject { /// Return `true` to indicate that an auxiliary action was performed. typealias AuxiliaryAction = () -> Bool + /// Specify behaviour to use when user triggers a navigation action. + var behaviour: Behaviour = .primaryAuxiliary + + /// Actions associated with specific locations along a navigation path. var pathActions: [Int: (dismiss: DismissAction?, auxiliaryAction: AuxiliaryAction?)] = [:] /// Navigation always performs dismiss action (if available), but may choose to perform an auxiliary action first. @@ -34,6 +38,7 @@ final class Navigation: ObservableObject { // MARK: - Navigation Behaviour extension Navigation { + /// enum Behaviour { /// Mimics Apple platforms tab bar navigation behaviour (i.e. pop to root regardless of navigation stack size, then scroll to top). case system @@ -41,6 +46,8 @@ extension Navigation { case primary /// Perform the auxiliary action(s) first, if specified, before proceeding with the primary action. case primaryAuxiliary + /// Do nothing, tyvm =) + case none } } @@ -212,16 +219,29 @@ struct PerformTabBarNavigation: ViewModifier { func body(content: Content) -> some View { content.onChange(of: selectedNavigationTabHashValue) { newValue in if newValue == tab.hashValue { - hapticManager.play(haptic: .gentleInfo, priority: .high) - // Customization based on user preference should occur here, for example: - // performSystemPopToRootBehaviour() - // noOp() - // performDimsissOnly() - performDismissAfterAuxiliary() + hapticManager.play(haptic: .gentleInfo, priority: .low) + performNavigation(behaviour: navigator.behaviour) } } } + private func performNavigation(behaviour: Navigation.Behaviour) { + // Customization based on user preference should occur here, for example: + switch behaviour { + case .system: + // performSystemPopToRootBehaviour() + break + case .primary: + // performDimsissOnly() + break + case .primaryAuxiliary: + performDismissAfterAuxiliary() + case .none: + // noOp() + break + } + } + /// Runs all auxiliary actions before calling system dismiss action. private func performDismissAfterAuxiliary() { #if DEBUG diff --git a/Mlem/Navigation/Routes/AppRoutes.swift b/Mlem/Navigation/Routes/AppRoutes.swift index 39ea2c761..0f8fb82ce 100644 --- a/Mlem/Navigation/Routes/AppRoutes.swift +++ b/Mlem/Navigation/Routes/AppRoutes.swift @@ -13,14 +13,16 @@ import Foundation /// enum AppRoute: Routable { case community(CommunityModel) - case instance(String? = nil, InstanceModel? = nil) + case instance(InstanceModel) + case instanceFediseerOpinionList(InstanceModel, data: FediseerData, type: FediseerOpinionType) case userProfile(UserModel, communityContext: CommunityModel? = nil) case postLinkWithContext(PostLinkWithContext) - // case newPostLinkWithContext(NewPostLinkWithContext) case lazyLoadPostLinkWithContext(LazyLoadPostLinkWithContext) + case modlog(ModlogLink) + // MARK: - Settings case settings(SettingsPage) @@ -29,6 +31,10 @@ enum AppRoute: Routable { case commentSettings(CommentSettingsPage) case postSettings(PostSettingsPage) case licenseSettings(LicensesSettingsPage) + case moderationSettings(ModerationSettingsPage) + + case postVotes(PostModel) + case commentVotes(HierarchicalComment) // swiftlint:disable cyclomatic_complexity static func makeRoute(_ value: some Hashable) throws -> AppRoute { @@ -41,6 +47,8 @@ enum AppRoute: Routable { return .postLinkWithContext(value) case let value as LazyLoadPostLinkWithContext: return .lazyLoadPostLinkWithContext(value) + case let value as ModlogLink: + return .modlog(value) case let value as SettingsPage: return .settings(value) case let value as AboutSettingsPage: @@ -53,6 +61,8 @@ enum AppRoute: Routable { return .postSettings(value) case let value as LicensesSettingsPage: return .licenseSettings(value) + case let value as ModerationSettingsPage: + return .moderationSettings(value) case let value as Self: /// Value is an enum case of type `Self` with either no associated value or pre-populated associated value. return value diff --git a/Mlem/Protocols/Removable.swift b/Mlem/Protocols/Removable.swift new file mode 100644 index 000000000..3a8577f80 --- /dev/null +++ b/Mlem/Protocols/Removable.swift @@ -0,0 +1,12 @@ +// +// Removable.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-31. +// + +import Foundation + +protocol Removable: Hashable { + mutating func remove(reason: String?, shouldRemove: Bool) async -> Bool +} diff --git a/Mlem/Repositories/InboxRepository.swift b/Mlem/Repositories/InboxRepository.swift index f9da9d541..273c98d03 100644 --- a/Mlem/Repositories/InboxRepository.swift +++ b/Mlem/Repositories/InboxRepository.swift @@ -34,26 +34,41 @@ class InboxRepository { } func voteOnCommentReply(_ reply: ReplyModel, vote: ScoringOperation) async throws -> ReplyModel { - // no haptics here as we defer to the `voteOnComment` method which will produce them if necessary - do { - let updatedCommentView = try await commentRepository.voteOnComment(id: reply.comment.id, vote: vote) - return ReplyModel( - commentReply: reply.commentReply, - comment: updatedCommentView.comment, - creator: UserModel(from: updatedCommentView.creator), - post: updatedCommentView.post, - community: CommunityModel(from: updatedCommentView.community), - recipient: reply.recipient, - numReplies: updatedCommentView.counts.childCount, - votes: VotesModel(from: updatedCommentView.counts, myVote: updatedCommentView.myVote), - creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, - subscribed: updatedCommentView.subscribed, - saved: updatedCommentView.saved, - creatorBlocked: updatedCommentView.creatorBlocked - ) - } catch { - throw error - } + let updatedCommentView = try await commentRepository.voteOnComment(id: reply.comment.id, vote: vote) + return ReplyModel( + commentReply: reply.commentReply, + comment: updatedCommentView.comment, + creator: UserModel(from: updatedCommentView.creator), + post: updatedCommentView.post, + community: CommunityModel(from: updatedCommentView.community), + recipient: reply.recipient, + numReplies: updatedCommentView.counts.childCount, + votes: VotesModel(from: updatedCommentView.counts, myVote: updatedCommentView.myVote), + creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, + subscribed: updatedCommentView.subscribed, + read: reply.read, + saved: updatedCommentView.saved, + creatorBlocked: updatedCommentView.creatorBlocked + ) + } + + func saveCommentReply(_ reply: ReplyModel, shouldSave: Bool) async throws -> ReplyModel { + let updatedCommentView = try await commentRepository.saveComment(id: reply.comment.id, shouldSave: shouldSave).commentView + return ReplyModel( + commentReply: reply.commentReply, + comment: updatedCommentView.comment, + creator: UserModel(from: updatedCommentView.creator), + post: updatedCommentView.post, + community: CommunityModel(from: updatedCommentView.community), + recipient: reply.recipient, + numReplies: updatedCommentView.counts.childCount, + votes: VotesModel(from: updatedCommentView.counts, myVote: updatedCommentView.myVote), + creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, + subscribed: updatedCommentView.subscribed, + read: reply.read, + saved: updatedCommentView.saved, + creatorBlocked: updatedCommentView.creatorBlocked + ) } func markReplyRead(id: Int, isRead: Bool) async throws -> ReplyModel { @@ -82,27 +97,42 @@ class InboxRepository { return MentionModel(from: response) } + func saveMention(_ mention: MentionModel, shouldSave: Bool) async throws -> MentionModel { + let updatedCommentView = try await commentRepository.saveComment(id: mention.comment.id, shouldSave: shouldSave).commentView + return MentionModel( + personMention: mention.personMention, + comment: updatedCommentView.comment, + creator: mention.creator, + post: updatedCommentView.post, + community: CommunityModel(from: updatedCommentView.community), + recipient: mention.recipient, + numReplies: updatedCommentView.counts.childCount, + votes: VotesModel(from: updatedCommentView.counts, myVote: updatedCommentView.myVote), + creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, + subscribed: updatedCommentView.subscribed, + read: mention.read, + saved: updatedCommentView.saved, + creatorBlocked: updatedCommentView.creatorBlocked + ) + } + func voteOnMention(_ mention: MentionModel, vote: ScoringOperation) async throws -> MentionModel { - // no haptics here as we defer to the `voteOnComment` method which will produce them if necessary - do { - let updatedCommentView = try await commentRepository.voteOnComment(id: mention.comment.id, vote: vote) - return MentionModel( - personMention: mention.personMention, - comment: updatedCommentView.comment, - creator: mention.creator, - post: updatedCommentView.post, - community: CommunityModel(from: updatedCommentView.community), - recipient: mention.recipient, - numReplies: updatedCommentView.counts.childCount, - votes: VotesModel(from: updatedCommentView.counts, myVote: updatedCommentView.myVote), - creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, - subscribed: updatedCommentView.subscribed, - saved: updatedCommentView.saved, - creatorBlocked: updatedCommentView.creatorBlocked - ) - } catch { - throw error - } + let updatedCommentView = try await commentRepository.voteOnComment(id: mention.comment.id, vote: vote) + return MentionModel( + personMention: mention.personMention, + comment: updatedCommentView.comment, + creator: mention.creator, + post: updatedCommentView.post, + community: CommunityModel(from: updatedCommentView.community), + recipient: mention.recipient, + numReplies: updatedCommentView.counts.childCount, + votes: VotesModel(from: updatedCommentView.counts, myVote: updatedCommentView.myVote), + creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, + subscribed: updatedCommentView.subscribed, + read: mention.read, + saved: updatedCommentView.saved, + creatorBlocked: updatedCommentView.creatorBlocked + ) } // MARK: - messages @@ -155,4 +185,6 @@ class InboxRepository { func reportMessage(id: Int, reason: String) async throws -> APIPrivateMessageReportView { try await apiClient.reportPrivateMessage(id: id, reason: reason) } + + // MARK: - comment reports } diff --git a/Mlem/Repositories/PersonRepository.swift b/Mlem/Repositories/PersonRepository.swift index fd2bd07f1..2a34b1406 100644 --- a/Mlem/Repositories/PersonRepository.swift +++ b/Mlem/Repositories/PersonRepository.swift @@ -37,8 +37,8 @@ class PersonRepository { /// - Parameter id: id of the user to get /// - Returns: UserModel for the given user func loadUser(for id: Int) async throws -> UserModel { - let response = try await apiClient.getPersonDetails(for: id, sort: .new, page: 0, limit: 0, savedOnly: false) - return UserModel(from: response.personView) + let response = try await apiClient.getPersonDetails(for: id, sort: .new, page: 1, limit: 1, savedOnly: false) + return UserModel(from: response) } /// Gets full user details for the given user @@ -71,7 +71,7 @@ class PersonRepository { )) } // TODO: support more sort types--API support is present - return (posts + comments).sorted { $0.sortVal(sortType: .published) > $1.sortVal(sortType: .published) } + return (posts + comments).sorted { $0.sortVal(sortType: .new) > $1.sortVal(sortType: .new) } } func loadUserDetails(for url: URL, limit: Int, savedOnly: Bool = false) async throws -> GetPersonDetailsResponse { diff --git a/Mlem/Repositories/PostRepository.swift b/Mlem/Repositories/PostRepository.swift index 6fbe0214c..d0c3a532a 100644 --- a/Mlem/Repositories/PostRepository.swift +++ b/Mlem/Repositories/PostRepository.swift @@ -8,6 +8,10 @@ import Dependencies import Foundation +enum PostError: Error { + case failure +} + class PostRepository { @Dependency(\.apiClient) private var apiClient @@ -55,6 +59,17 @@ class PostRepository { let success = try await apiClient.markPostAsRead(for: post.postId, read: read).success return PostModel(from: post, read: success ? read : post.read) } + + /// Attempts to mark the given posts as read. + /// - Parameters: + /// - postIds: postIds to mark as read + /// - read: Intended read state of the posts (true to mark read, false to mark unread) + func markRead(postIds: [Int], read: Bool) async throws { + let success = try await apiClient.markPostsAsRead(for: postIds, read: read).success + if !success { + throw PostError.failure + } + } /// Rates a given post. Does not care what the current vote state is; sends the given request no matter what (i.e., calling this with operation `.upvote` on an already upvoted post will not send a `.resetVote`, but will instead send a second idempotent `.upvote`) /// - Parameters: diff --git a/Mlem/Temp Image Viewer/ImageDetailView.swift b/Mlem/Temp Image Viewer/ImageDetailView.swift index 2797dc869..c450ac0ed 100644 --- a/Mlem/Temp Image Viewer/ImageDetailView.swift +++ b/Mlem/Temp Image Viewer/ImageDetailView.swift @@ -9,19 +9,13 @@ import Foundation import SwiftUI struct ImageDetailView: View { - @Environment(\.dismiss) var dismiss - let url: URL var body: some View { ZoomableImageView(url: url) .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - dismiss() - } label: { - Image(systemName: Icons.close) - } + ToolbarItem(placement: .topBarTrailing) { + CloseButtonView() } } } diff --git a/Mlem/Temp Image Viewer/ImageSaver.swift b/Mlem/Temp Image Viewer/ImageSaver.swift index 05b5a09c7..75d0ae216 100644 --- a/Mlem/Temp Image Viewer/ImageSaver.swift +++ b/Mlem/Temp Image Viewer/ImageSaver.swift @@ -10,16 +10,10 @@ import Foundation import Photos class ImageSaver: NSObject { - func writeToPhotoAlbum(imageData: Data) { - PHPhotoLibrary.shared().performChanges({ + func writeToPhotoAlbum(imageData: Data) async throws { + try await PHPhotoLibrary.shared().performChanges { let creationRequest = PHAssetCreationRequest.forAsset() creationRequest.addResource(with: .photo, data: imageData, options: nil) - }, completionHandler: { success, error in - if success { - print("Save finished!") - } else { - print("Error saving photo: \(String(describing: error?.localizedDescription))") - } - }) + } } } diff --git a/Mlem/Temp Image Viewer/ZoomableImageView.swift b/Mlem/Temp Image Viewer/ZoomableImageView.swift index 7deb3f2be..63a5daaf1 100644 --- a/Mlem/Temp Image Viewer/ZoomableImageView.swift +++ b/Mlem/Temp Image Viewer/ZoomableImageView.swift @@ -14,6 +14,7 @@ import SwiftUI struct ZoomableImageView: View { @Dependency(\.notifier) var notifier + @Dependency(\.errorHandler) var errorHandler let url: URL @@ -32,7 +33,7 @@ struct ZoomableImageView: View { .scaleEffect(zoom) .contextMenu { ForEach(genMenuFunctions(image: image)) { item in - MenuButton(menuFunction: item, confirmDestructive: nil) + MenuButton(menuFunction: item, menuFunctionPopup: .constant(nil)) } } .padding(.horizontal) // after context menu to avoid padding showing up in context menu @@ -66,9 +67,15 @@ struct ZoomableImageView: View { do { let (data, _) = try await ImagePipeline.shared.data(for: url) let imageSaver = ImageSaver() - imageSaver.writeToPhotoAlbum(imageData: data) + try await imageSaver.writeToPhotoAlbum(imageData: data) await notifier.add(.success("Image saved")) } catch { + await notifier.add( + .detailedFailure( + title: "Failed to Save Image", + subtitle: "You may need to allow Mlem to access your Photo Library in System Settings." + ) + ) print(String(describing: error)) } } @@ -78,9 +85,7 @@ struct ZoomableImageView: View { ret.append(MenuFunction.standardMenuFunction( text: "Details", - imageName: Icons.imageDetails, - destructiveActionPrompt: nil, - enabled: true + imageName: Icons.imageDetails ) { Task(priority: .userInitiated) { await showQuickLook() @@ -89,9 +94,7 @@ struct ZoomableImageView: View { ret.append(MenuFunction.standardMenuFunction( text: "Save", - imageName: Icons.import, - destructiveActionPrompt: nil, - enabled: true + imageName: Icons.import ) { Task(priority: .userInitiated) { await saveImage() diff --git a/Mlem/Views/Shared/Accounts/DeleteAccountView.swift b/Mlem/Views/Shared/Accounts/DeleteAccountView.swift index b75f47d60..3549ff038 100644 --- a/Mlem/Views/Shared/Accounts/DeleteAccountView.swift +++ b/Mlem/Views/Shared/Accounts/DeleteAccountView.swift @@ -30,17 +30,15 @@ struct DeleteAccountView: View { var body: some View { VStack(alignment: .center, spacing: 20) { - Image(systemName: Icons.warning) - .resizable() - .scaledToFit() - .foregroundStyle(.red) - .frame(width: AppConstants.hugeAvatarSize, height: AppConstants.hugeAvatarSize) - Text("Really delete \(account.username)?") .font(.title) .fontWeight(.bold) - Text("Please note that this will *permanently* remove it from \(account.hostName ?? "the instance"), not just Mlem!") + WarningView( + iconName: Icons.warning, + text: "This will permanently remove it from \(account.hostName ?? "the instance"), not just Mlem!", + inList: false + ) deleteConfirmation diff --git a/Mlem/Views/Shared/Cached Image.swift b/Mlem/Views/Shared/Cached Image.swift index a91c19232..253b127b2 100644 --- a/Mlem/Views/Shared/Cached Image.swift +++ b/Mlem/Views/Shared/Cached Image.swift @@ -5,6 +5,7 @@ // Created by tht7 on 26/06/2023. // +import Dependencies import Foundation import MarkdownUI import Nuke @@ -13,11 +14,15 @@ import QuickLook import SwiftUI struct CachedImage: View { + @Dependency(\.notifier) var notifier + @Dependency(\.errorHandler) var errorHandler let url: URL? let shouldExpand: Bool + let hasContextMenu: Bool // state vars to track the current image size and whether that size needs to be recomputed when the image actually loads. Combined with the image size cache, this produces good scrolling behavior except in the case where we scroll past an image and it derenders before it ever gets a chance to load, in which case that image will cause a slight hiccup on the way back up. That's kind of an unsolvable problem, since we can't know the size before we load the image at all, but that's fine because it shouldn't really happen during normal use. If we really want to guarantee smooth feed scrolling we can squish any image with no cached size into a square, but that feels like squishing a lot of images for the sake of a fringe case. @State var size: CGSize + let fixedSize: CGSize? @State var shouldRecomputeSize: Bool @EnvironmentObject private var imageDetailSheetState: ImageDetailSheetState @@ -37,6 +42,7 @@ struct CachedImage: View { init( url: URL?, shouldExpand: Bool = true, + hasContextMenu: Bool = false, maxHeight: CGFloat = .infinity, fixedSize: CGSize? = nil, imageNotFound: @escaping () -> AnyView = imageNotFoundDefault, @@ -48,6 +54,7 @@ struct CachedImage: View { ) { self.url = url self.shouldExpand = shouldExpand + self.hasContextMenu = hasContextMenu self.maxHeight = maxHeight self.imageNotFound = imageNotFound self.errorBackgroundColor = errorBackgroundColor @@ -58,6 +65,7 @@ struct CachedImage: View { self.screenWidth = UIScreen.main.bounds.width - (AppConstants.postAndCommentSpacing * 2) + self.fixedSize = fixedSize // determine the size of the image if let fixedSize { // if we're given a size, just use it and to hell with the cache @@ -86,40 +94,26 @@ struct CachedImage: View { var body: some View { LazyImage(url: url) { state in if let imageContainer = state.imageContainer { - let imageView = Image(uiImage: imageContainer.image) - .resizable() - .aspectRatio(contentMode: contentMode) - .cornerRadius(cornerRadius) - .frame(idealWidth: size.width, maxHeight: size.height) - .blur(radius: blurRadius) - .clipped() - .allowsHitTesting(false) - .overlay(alignment: .top) { - // weeps in janky hack but this lets us tap the image only in the area we want - Rectangle() - .frame(maxHeight: size.height) - .opacity(0.00000000001) - } - .onAppear { - // if the image appears and its size isn't cached, compute its size and cache it - if shouldRecomputeSize { - let ratio = screenWidth / imageContainer.image.size.width - size = CGSize( - width: screenWidth, - height: min(maxHeight, imageContainer.image.size.height * ratio) - ) - cacheImageSize() - shouldRecomputeSize = false - } - } - if shouldExpand { - imageView - .onTapGesture { - imageDetailSheetState.url = url // show image detail - onTapCallback?() + let baseImage = Image(uiImage: imageContainer.image) + let coreImage = coreImage(baseImage: baseImage, containerSize: imageContainer.image.size) + + if fixedSize == nil { + imageWithTapGestures(image: coreImage) + .contextMenu { + if hasContextMenu { + contextMenuActions(image: Image(uiImage: imageContainer.image)) + } } } else { - imageView + imageWithTapGestures(image: coreImage) + .contextMenu { + if hasContextMenu { + contextMenuActions(image: Image(uiImage: imageContainer.image)) + } + } preview: { + baseImage + .resizable() + } } } else if state.error != nil { // Indicates an error @@ -150,6 +144,75 @@ struct CachedImage: View { } } + @ViewBuilder func coreImage(baseImage: Image, containerSize: CGSize) -> some View { + baseImage + .resizable() + .aspectRatio(contentMode: contentMode) + .cornerRadius(cornerRadius) + .frame(idealWidth: size.width, maxHeight: size.height) + .fixSize(fixedSize: fixedSize, fallbackSize: size) + .blur(radius: blurRadius) + .clipped() + .allowsHitTesting(false) + .overlay(alignment: .top) { + // weeps in janky hack but this lets us tap the image only in the area we want + Rectangle() + .frame(maxHeight: size.height) + .opacity(0.00000000001) + } + .onAppear { + // if the image appears and its size isn't cached, compute its size and cache it + if shouldRecomputeSize { + let ratio = screenWidth / containerSize.width + size = CGSize( + width: screenWidth, + height: min(maxHeight, containerSize.height * ratio) + ) + cacheImageSize() + shouldRecomputeSize = false + } + } + } + + @ViewBuilder func imageWithTapGestures(image: some View) -> some View { + if shouldExpand { + image + .onTapGesture { + if shouldExpand { + imageDetailSheetState.url = url // show image detail + onTapCallback?() + } + } + } else { + image + } + } + + @ViewBuilder + func contextMenuActions(image: Image) -> some View { + if hasContextMenu, let url { + Button("Save", systemImage: Icons.import) { + Task { + do { + let (data, _) = try await ImagePipeline.shared.data(for: url) + let imageSaver = ImageSaver() + try await imageSaver.writeToPhotoAlbum(imageData: data) + await notifier.add(.success("Image saved")) + } catch { + await notifier.add( + .detailedFailure( + title: "Failed to Save Image", + subtitle: "You may need to allow Mlem to access your Photo Library in System Settings." + ) + ) + print(String(describing: error)) + } + } + } + } + ShareLink(item: image, preview: .init("photo", image: image)) + } + static func imageNotFoundDefault() -> AnyView { AnyView(Image(systemName: Icons.missing) .resizable() @@ -168,3 +231,22 @@ struct CachedImage: View { } } } + +private struct OptionalFixedSizeImage: ViewModifier { + let fixedSize: CGSize? + let fallbackSize: CGSize + + func body(content: Content) -> some View { + if let fixedSize { + content.frame(width: fixedSize.width, height: fixedSize.height) + } else { + content.frame(idealWidth: fallbackSize.width, maxHeight: fallbackSize.height) + } + } +} + +private extension View { + func fixSize(fixedSize: CGSize?, fallbackSize: CGSize) -> some View { + modifier(OptionalFixedSizeImage(fixedSize: fixedSize, fallbackSize: fallbackSize)) + } +} diff --git a/Mlem/Views/Shared/CloseButtonView.swift b/Mlem/Views/Shared/CloseButtonView.swift new file mode 100644 index 000000000..ec40f3d39 --- /dev/null +++ b/Mlem/Views/Shared/CloseButtonView.swift @@ -0,0 +1,28 @@ +// +// CloseButtonView.swift +// Mlem +// +// Created by Sjmarf on 09/03/2024. +// + +import Foundation +import SwiftUI + +struct CloseButtonView: View { + @Environment(\.dismiss) var dismiss + + var body: some View { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 30) + .symbolRenderingMode(.palette) + .foregroundStyle(.secondary, .secondary.opacity(0.2)) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss") + } +} diff --git a/Mlem/Views/Shared/Comments/Comment Item Logic.swift b/Mlem/Views/Shared/Comments/Comment Item Logic.swift index e96f0a5c4..ea9583b48 100644 --- a/Mlem/Views/Shared/Comments/Comment Item Logic.swift +++ b/Mlem/Views/Shared/Comments/Comment Item Logic.swift @@ -8,6 +8,47 @@ import SwiftUI extension CommentItem { + // swiftlint:disable:next cyclomatic_complexity + func enrichLayoutWidgets() -> [EnrichedLayoutWidget] { + layoutWidgetTracker.groups.comment.compactMap { baseWidget in + let votes: VotesModel = .init(from: hierarchicalComment.commentView.counts, myVote: hierarchicalComment.commentView.myVote) + switch baseWidget { + case .infoStack: + return .infoStack( + colorizeVotes: false, + votes: votes, + published: hierarchicalComment.commentView.comment.published, + updated: hierarchicalComment.commentView.comment.updated, + commentCount: hierarchicalComment.commentView.counts.childCount, + unreadCommentCount: 0, + saved: hierarchicalComment.commentView.saved + ) + case .upvote: + return .upvote(myVote: hierarchicalComment.commentView.myVote ?? .resetVote, upvote: upvote) + case .downvote: + return .downvote(myVote: hierarchicalComment.commentView.myVote ?? .resetVote, downvote: downvote) + case .save: + return .save(saved: hierarchicalComment.commentView.saved, save: saveComment) + case .reply: + return .reply(reply: replyToComment) + case .share: + if let shareUrl = URL(string: hierarchicalComment.commentView.comment.apId) { + return .share(shareUrl: shareUrl) + } else { + return nil + } + case .upvoteCounter: + return .upvoteCounter(votes: votes, upvote: upvote) + case .downvoteCounter: + return .downvoteCounter(votes: votes, downvote: downvote) + case .scoreCounter: + return .scoreCounter(votes: votes, upvote: upvote, downvote: downvote) + default: + return nil + } + } + } + func voteOnComment(inputOp: ScoringOperation) async { hapticManager.play(haptic: .lightSuccess, priority: .low) let operation = hierarchicalComment.commentView.myVote == inputOp ? ScoringOperation.resetVote : inputOp @@ -178,126 +219,6 @@ extension CommentItem { // MARK: helpers - // swiftlint:disable function_body_length - func genMenuFunctions() -> [MenuFunction] { - var ret: [MenuFunction] = .init() - - // upvote - let (upvoteText, upvoteImg) = hierarchicalComment.commentView.myVote == .upvote ? - ("Undo Upvote", Icons.upvoteSquareFill) : - ("Upvote", Icons.upvoteSquare) - ret.append(MenuFunction.standardMenuFunction( - text: upvoteText, - imageName: upvoteImg, - destructiveActionPrompt: nil, - enabled: true - ) { - Task(priority: .userInitiated) { - await upvote() - } - }) - - // downvote - let (downvoteText, downvoteImg) = hierarchicalComment.commentView.myVote == .downvote ? - ("Undo Downvote", Icons.downvoteSquareFill) : - ("Downvote", Icons.downvoteSquare) - ret.append(MenuFunction.standardMenuFunction( - text: downvoteText, - imageName: downvoteImg, - destructiveActionPrompt: nil, - enabled: true - ) { - Task(priority: .userInitiated) { - await downvote() - } - }) - - // save - let (saveText, saveImg) = hierarchicalComment.commentView.saved ? - ("Unsave", Icons.unsave) : - ("Save", Icons.save) - ret.append(MenuFunction.standardMenuFunction( - text: saveText, - imageName: saveImg, - destructiveActionPrompt: nil, - enabled: true - ) { - Task(priority: .userInitiated) { - await saveComment() - } - }) - - // reply - ret.append(MenuFunction.standardMenuFunction( - text: "Reply", - imageName: Icons.reply, - destructiveActionPrompt: nil, - enabled: true - ) { - replyToComment() - }) - - // edit - if appState.isCurrentAccountId(hierarchicalComment.commentView.creator.id) { - ret.append(MenuFunction.standardMenuFunction( - text: "Edit", - imageName: Icons.edit, - destructiveActionPrompt: nil, - enabled: true - ) { - editComment() - }) - } - - // delete - if appState.isCurrentAccountId(hierarchicalComment.commentView.creator.id) { - ret.append(MenuFunction.standardMenuFunction( - text: "Delete", - imageName: Icons.delete, - destructiveActionPrompt: "Are you sure you want to delete this comment? This cannot be undone.", - enabled: !hierarchicalComment.commentView.comment.deleted - ) { - Task(priority: .userInitiated) { - await deleteComment() - } - }) - } - - // share - if let url = URL(string: hierarchicalComment.commentView.comment.apId) { - ret.append(MenuFunction.shareMenuFunction(url: url)) - } - - // report - ret.append(MenuFunction.standardMenuFunction( - text: "Report", - imageName: Icons.moderationReport, - destructiveActionPrompt: "Really report?", - enabled: true - ) { - editorTracker.openEditor(with: ConcreteEditorModel( - comment: hierarchicalComment.commentView, - operation: CommentOperation.reportComment - )) - }) - - // block - ret.append(MenuFunction.standardMenuFunction( - text: "Block User", - imageName: Icons.userBlock, - destructiveActionPrompt: AppConstants.blockUserPrompt, - enabled: true - ) { - Task(priority: .userInitiated) { - await blockUser(userId: hierarchicalComment.commentView.creator.id) - } - }) - - return ret - } - - // swiftlint:enable function_body_length - func blockUser(userId: Int) async { do { let response = try await apiClient.blockPerson(id: userId, shouldBlock: true) diff --git a/Mlem/Views/Shared/Comments/Comment Item.swift b/Mlem/Views/Shared/Comments/Comment Item.swift index d6cf99998..a1dbe9528 100644 --- a/Mlem/Views/Shared/Comments/Comment Item.swift +++ b/Mlem/Views/Shared/Comments/Comment Item.swift @@ -56,6 +56,7 @@ struct CommentItem: View { @EnvironmentObject var editorTracker: EditorTracker @EnvironmentObject var appState: AppState @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker + @EnvironmentObject var modToolTracker: ModToolTracker // MARK: Constants @@ -74,15 +75,7 @@ struct CommentItem: View { let enableSwipeActions: Bool let pageContext: PageContext - // MARK: Destructive confirmation - - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? - - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } + @State private var menuFunctionPopup: MenuFunctionPopup? // MARK: Computed @@ -137,6 +130,8 @@ struct CommentItem: View { EmptyView() } else if hierarchicalComment.isParentCollapsed, !hierarchicalComment.isCollapsed, hierarchicalComment.commentView.comment.parentId != nil { EmptyView() + } else if hierarchicalComment.purged { + EmptyView() } else { Group { commentBody(hierarchicalComment: hierarchicalComment) @@ -166,7 +161,9 @@ struct CommentItem: View { isParentCollapsed: $hierarchicalComment.isParentCollapsed, isCollapsed: $hierarchicalComment.isCollapsed, showPostContext: showPostContext, - menuFunctions: genMenuFunctions(), + combinedMenuFunctions: combinedMenuFunctions(), + personalMenuFunctions: personalMenuFunctions(), + modMenuFunctions: modMenuFunctions(), links: hierarchicalComment.links ) // top and bottom spacing uses default even when compact--it's *too* compact otherwise @@ -174,25 +171,7 @@ struct CommentItem: View { .padding(.horizontal, AppConstants.postAndCommentSpacing) if !hierarchicalComment.isCollapsed, !compactComments { - InteractionBarView( - votes: VotesModel(from: hierarchicalComment.commentView.counts, myVote: hierarchicalComment.commentView.myVote), - published: hierarchicalComment.commentView.comment.published, - updated: hierarchicalComment.commentView.comment.updated, - commentCount: hierarchicalComment.commentView.counts.childCount, - saved: hierarchicalComment.commentView.saved, - accessibilityContext: "comment", - widgets: layoutWidgetTracker.groups.comment, - upvote: upvote, - downvote: downvote, - save: saveComment, - reply: replyToComment, - shareURL: URL(string: hierarchicalComment.commentView.comment.apId), - shouldShowScore: shouldShowScoreInCommentBar, - showDownvotesSeparately: showCommentDownvotesSeparately, - shouldShowTime: shouldShowTimeInCommentBar, - shouldShowSaved: shouldShowSavedInCommentBar, - shouldShowReplies: shouldShowRepliesInCommentBar - ) + InteractionBarView(context: .comment, widgets: enrichLayoutWidgets()) } else { Spacer() .frame(height: AppConstants.postAndCommentSpacing) @@ -205,7 +184,7 @@ struct CommentItem: View { hierarchicalComment.children.count > 0, !isCommentReplyHidden { Divider() - CollapsedCommentReplies(numberOfReplies: .constant(hierarchicalComment.commentView.counts.childCount)) + CollapsedCommentReplies(numberOfReplies: .constant(hierarchicalComment.commentView.counts.childCount)) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) .onTapGesture { @@ -221,10 +200,6 @@ struct CommentItem: View { toggleCollapsed() } } - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) .background(Color.systemBackground) .addSwipeyActions( leading: enableSwipeActions ? [upvoteSwipeAction, downvoteSwipeAction] : [], @@ -232,10 +207,11 @@ struct CommentItem: View { ) .border(width: borderWidth, edges: [.leading], color: threadingColors[depth % threadingColors.count]) .contextMenu { - ForEach(genMenuFunctions()) { item in - MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) + ForEach(combinedMenuFunctions()) { item in + MenuButton(menuFunction: item, menuFunctionPopup: $menuFunctionPopup) } } + .destructiveConfirmation(menuFunctionPopup: $menuFunctionPopup) .onChange(of: collapseComments) { newValue in if pageContext == .posts { if newValue == false { diff --git a/Mlem/Views/Shared/Comments/CommentItem+MenuFunctions.swift b/Mlem/Views/Shared/Comments/CommentItem+MenuFunctions.swift new file mode 100644 index 000000000..7d711c336 --- /dev/null +++ b/Mlem/Views/Shared/Comments/CommentItem+MenuFunctions.swift @@ -0,0 +1,255 @@ +// +// CommentItem+MenuFunctions.swift +// Mlem +// +// Created by Sjmarf on 27/03/2024. +// + +import Foundation +import SwiftUI + +// swiftlint:disable function_body_length + +extension CommentItem { + func combinedMenuFunctions() -> [MenuFunction] { + @AppStorage("moderatorActionGrouping") var moderatorActionGrouping: ModerationActionGroupingMode = .none + let isMod = siteInformation.isModOrAdmin(communityId: hierarchicalComment.commentView.post.communityId) + + var functions: [MenuFunction] = .init() + + functions.append(contentsOf: personalMenuFunctions()) + if isMod { + if moderatorActionGrouping != .none { + functions.append( + .groupMenuFunction(text: "Moderation", imageName: Icons.moderation, children: modMenuFunctions()) + ) + } else { + functions.append(contentsOf: modMenuFunctions()) + } + } + return functions + } + + func personalMenuFunctions() -> [MenuFunction] { + let isMod = siteInformation.isModOrAdmin(communityId: hierarchicalComment.commentView.post.communityId) + + var functions: [MenuFunction] = .init() + + // upvote + let (upvoteText, upvoteImg) = hierarchicalComment.commentView.myVote == .upvote ? + ("Undo Upvote", Icons.upvoteSquareFill) : + ("Upvote", Icons.upvoteSquare) + functions.append(MenuFunction.standardMenuFunction( + text: upvoteText, + imageName: upvoteImg + ) { + Task(priority: .userInitiated) { + await upvote() + } + }) + + // downvote + let (downvoteText, downvoteImg) = hierarchicalComment.commentView.myVote == .downvote ? + ("Undo Downvote", Icons.downvoteSquareFill) : + ("Downvote", Icons.downvoteSquare) + functions.append(MenuFunction.standardMenuFunction( + text: downvoteText, + imageName: downvoteImg + ) { + Task(priority: .userInitiated) { + await downvote() + } + }) + + // save + let (saveText, saveImg) = hierarchicalComment.commentView.saved ? + ("Unsave", Icons.saveFill) : + ("Save", Icons.save) + functions.append(MenuFunction.standardMenuFunction( + text: saveText, + imageName: saveImg + ) { + Task(priority: .userInitiated) { + await saveComment() + } + }) + + // reply + functions.append(MenuFunction.standardMenuFunction( + text: "Reply", + imageName: Icons.reply + ) { + replyToComment() + }) + + let content = hierarchicalComment.commentView.comment.content + functions.append(MenuFunction.standardMenuFunction( + text: "Select Text", + imageName: Icons.select + ) { + editorTracker.openEditor(with: SelectTextModel(text: content)) + }) + + let isOwnComment = appState.isCurrentAccountId(hierarchicalComment.commentView.creator.id) + + if isOwnComment { + // edit + functions.append(MenuFunction.standardMenuFunction( + text: "Edit", + imageName: Icons.edit + ) { + editComment() + }) + + // delete + functions.append(MenuFunction.standardMenuFunction( + text: "Delete", + imageName: Icons.delete, + confirmationPrompt: "Are you sure you want to delete this comment? This cannot be undone.", + enabled: !hierarchicalComment.commentView.comment.deleted + ) { + Task(priority: .userInitiated) { + await deleteComment() + } + }) + } + + // share + if let url = URL(string: hierarchicalComment.commentView.comment.apId) { + functions.append(MenuFunction.shareMenuFunction(url: url)) + } + + if !isOwnComment { + if !isMod { + // report + functions.append(MenuFunction.standardMenuFunction( + text: "Report", + imageName: Icons.moderationReport, + isDestructive: true + ) { + editorTracker.openEditor(with: ConcreteEditorModel( + comment: hierarchicalComment.commentView, + operation: CommentOperation.reportComment + )) + }) + } + + // block + functions.append(MenuFunction.standardMenuFunction( + text: "Block User", + imageName: Icons.hide, + confirmationPrompt: AppConstants.blockUserPrompt + ) { + Task(priority: .userInitiated) { + await blockUser(userId: hierarchicalComment.commentView.creator.id) + } + }) + } + + return [.controlGroupMenuFunction(children: functions)] + } + + func modMenuFunctions() -> [MenuFunction] { + let isOwnComment = appState.isCurrentAccountId(hierarchicalComment.commentView.creator.id) + + var functions: [MenuFunction] = .init() + + // TODO: 0.19 deprecation + if siteInformation.isAdmin || ((siteInformation.version ?? .infinity) > .init("0.19.3")) { + functions.append(.navigationMenuFunction( + text: "View Votes", + imageName: Icons.votes, + destination: .commentVotes(hierarchicalComment) + )) + } + + if !isOwnComment { + functions.append(.toggleableMenuFunction( + toggle: hierarchicalComment.commentView.comment.removed, + trueText: "Restore", + trueImageName: Icons.restore, + falseText: "Remove", + falseImageName: Icons.remove, + isDestructive: .whenFalse + ) { + modToolTracker.removeComment( + hierarchicalComment, + shouldRemove: !self.hierarchicalComment.commentView.comment.removed + ) + }) + } + + if siteInformation.isAdmin { + functions.append(.standardMenuFunction( + text: "Purge", + imageName: Icons.purge, + isDestructive: true + ) { + modToolTracker.purgeContent( + hierarchicalComment, + userRemovalWalker: .init(commentTracker: commentTracker) + ) + }) + functions.append(.divider) + } + + if !isOwnComment { + let creatorBannedFromCommunity = hierarchicalComment.commentView.creatorBannedFromCommunity + let creatorBannedFromInstance = hierarchicalComment.commentView.creator.banned + + // for admins, default to instance ban iff not a moderator of this community + if siteInformation.isAdmin, !siteInformation.moderatedCommunities.contains(hierarchicalComment.commentView.community.id) { + functions.append(MenuFunction.toggleableMenuFunction( + toggle: creatorBannedFromInstance, + trueText: "Unban User", + trueImageName: Icons.instanceUnban, + falseText: "Ban User", + falseImageName: Icons.instanceBan, + isDestructive: .whenFalse + ) { + modToolTracker.banUser( + .init(from: hierarchicalComment.commentView.creator), + from: .init(from: hierarchicalComment.commentView.community), + bannedFromCommunity: creatorBannedFromCommunity, + shouldBan: !creatorBannedFromInstance, + userRemovalWalker: .init(commentTracker: commentTracker) + ) + }) + } else { + functions.append(MenuFunction.toggleableMenuFunction( + toggle: creatorBannedFromCommunity, + trueText: "Unban User", + trueImageName: Icons.communityUnban, + falseText: "Ban User", + falseImageName: Icons.communityBan, + isDestructive: .whenFalse + ) { + modToolTracker.banUser( + .init(from: hierarchicalComment.commentView.creator), + from: .init(from: hierarchicalComment.commentView.community), + bannedFromCommunity: creatorBannedFromCommunity, + shouldBan: !creatorBannedFromCommunity, + userRemovalWalker: .init(commentTracker: commentTracker) + ) + }) + } + + if siteInformation.isAdmin { + functions.append(.standardMenuFunction( + text: "Purge User", + imageName: Icons.purge, + isDestructive: true + ) { + modToolTracker.purgeContent( + UserModel(from: hierarchicalComment.commentView.creator), + userRemovalWalker: .init(commentTracker: commentTracker) + ) + }) + } + } + + return functions + } +} + +// swiftlint:enable function_body_length diff --git a/Mlem/Views/Shared/Comments/Components/CommentBodyView.swift b/Mlem/Views/Shared/Comments/Components/CommentBodyView.swift index dc112f545..751011108 100644 --- a/Mlem/Views/Shared/Comments/Components/CommentBodyView.swift +++ b/Mlem/Views/Shared/Comments/Components/CommentBodyView.swift @@ -5,14 +5,17 @@ // Created by Eric Andrews on 2023-07-03. // -import Foundation +import Dependencies import SwiftUI struct CommentBodyView: View { + @Dependency(\.siteInformation) var siteInformation + @AppStorage("shouldShowUserServerInComment") var shouldShowUserServerInComment: Bool = false @AppStorage("compactComments") var compactComments: Bool = false @AppStorage("showCommentDownvotesSeparately") var showCommentDownvotesSeparately: Bool = false @AppStorage("easyTapLinkDisplayMode") var easyTapLinkDisplayMode: EasyTapLinkDisplayMode = .contextual + @AppStorage("moderatorActionGrouping") var moderatorActionGrouping: ModerationActionGroupingMode = .none @Binding var isParentCollapsed: Bool @Binding var isCollapsed: Bool @@ -20,7 +23,9 @@ struct CommentBodyView: View { let commentView: APICommentView let showPostContext: Bool let commentorLabel: String - let menuFunctions: [MenuFunction] + var combinedMenuFunctions: [MenuFunction] + let personalMenuFunctions: [MenuFunction] + let modMenuFunctions: [MenuFunction] let links: [LinkType] var myVote: ScoringOperation { commentView.myVote ?? .resetVote } @@ -44,6 +49,10 @@ struct CommentBodyView: View { } } + var isMod: Bool { + siteInformation.isModOrAdmin(communityId: commentView.post.communityId) + } + var spacing: CGFloat { compactComments ? AppConstants.compactSpacing : AppConstants.postAndCommentSpacing } init( @@ -51,15 +60,19 @@ struct CommentBodyView: View { isParentCollapsed: Binding, isCollapsed: Binding, showPostContext: Bool, - menuFunctions: [MenuFunction], - links: [LinkType] + combinedMenuFunctions: [MenuFunction] = [], + personalMenuFunctions: [MenuFunction] = [], + modMenuFunctions: [MenuFunction] = [], + links: [LinkType] = [] ) { self._isParentCollapsed = isParentCollapsed self._isCollapsed = isCollapsed self.commentView = commentView self.showPostContext = showPostContext - self.menuFunctions = menuFunctions + self.combinedMenuFunctions = combinedMenuFunctions + self.personalMenuFunctions = personalMenuFunctions + self.modMenuFunctions = modMenuFunctions self.links = links let commentor = commentView.creator @@ -73,6 +86,7 @@ struct CommentBodyView: View { UserLinkView( person: commentView.creator, serverInstanceLocation: serverInstanceLocation, + bannedFromCommunity: commentView.creatorBannedFromCommunity, postContext: commentView.post, commentContext: commentView.comment ) @@ -86,27 +100,42 @@ struct CommentBodyView: View { compactScoreDisplay() } - EllipsisMenu(size: compactComments ? 20 : 24, menuFunctions: menuFunctions) + let menuSize: CGFloat = compactComments ? 20 : 24 + if moderatorActionGrouping == .separateMenu { + if isMod { + EllipsisMenu( + size: menuSize, + systemImage: siteInformation.isAdmin ? Icons.admin : Icons.moderation, + menuFunctions: modMenuFunctions + ) + .opacity(modMenuFunctions.isEmpty ? 0.5 : 1) + } + EllipsisMenu(size: menuSize, menuFunctions: personalMenuFunctions) + } else { + EllipsisMenu(size: menuSize, menuFunctions: combinedMenuFunctions) + } } // comment text or placeholder Group { - if commentView.comment.deleted { - Text("Comment was deleted") - .italic() - .foregroundColor(.secondary) - } else if commentView.comment.removed { - Text("Comment was removed") - .italic() - .foregroundColor(.secondary) - } else if !isCollapsed { - MarkdownView(text: commentView.comment.content, isNsfw: commentView.post.nsfw) - .frame(maxWidth: .infinity, alignment: .topLeading) - .transition(.markdownView()) - - if easyTapLinkDisplayMode != .disabled { - ForEach(links) { link in - EasyTapLinkView(linkType: link, showCaption: showLinkCaptions) + if !isCollapsed { + if commentView.comment.deleted { + Text("Comment was deleted") + .italic() + .foregroundColor(.secondary) + } else if commentView.comment.removed { + Text("Comment was removed") + .italic() + .foregroundColor(.secondary) + } else { + MarkdownView(text: commentView.comment.content, isNsfw: commentView.post.nsfw) + .frame(maxWidth: .infinity, alignment: .topLeading) + .transition(.markdownView()) + + if easyTapLinkDisplayMode != .disabled { + ForEach(links) { link in + EasyTapLinkView(linkType: link, showCaption: showLinkCaptions) + } } } } diff --git a/Mlem/Views/Shared/Comments/Components/Embedded Post.swift b/Mlem/Views/Shared/Comments/Components/Embedded Post.swift index 8688d3afb..fd9f39e9c 100644 --- a/Mlem/Views/Shared/Comments/Components/Embedded Post.swift +++ b/Mlem/Views/Shared/Comments/Components/Embedded Post.swift @@ -10,9 +10,9 @@ import SwiftUI struct EmbeddedPost: View { let community: APICommunity let post: APIPost - let comment: APIComment + let comment: APIComment? - init(community: APICommunity, post: APIPost, comment: APIComment) { + init(community: APICommunity, post: APIPost, comment: APIComment?) { self.community = community self.post = post self.comment = comment @@ -25,8 +25,8 @@ struct EmbeddedPost: View { // - enrich info var body: some View { NavigationLink(.lazyLoadPostLinkWithContext(.init( - post: post, - scrollTarget: comment.id + postId: post.id, + scrollTarget: comment?.id ))) { postLinkButton() } diff --git a/Mlem/Views/Shared/CommunityList/CommunityListRow.swift b/Mlem/Views/Shared/CommunityList/CommunityListRow.swift new file mode 100644 index 000000000..794c24045 --- /dev/null +++ b/Mlem/Views/Shared/CommunityList/CommunityListRow.swift @@ -0,0 +1,109 @@ +// +// CommunityListRow.swift +// Mlem +// +// Created by Sjmarf on 18/09/2023. +// + +import Dependencies +import SwiftUI + +enum CommunityComplication: CaseIterable { + case type, instance, subscribers +} + +extension [CommunityComplication] { + static let withTypeLabel: [CommunityComplication] = [.type, .instance, .subscribers] + static let withoutTypeLabel: [CommunityComplication] = [.instance, .subscribers] + static let instanceOnly: [CommunityComplication] = [.instance] +} + +struct CommunityListRow: View { + @Dependency(\.apiClient) private var apiClient + @Dependency(\.hapticManager) var hapticManager + + let community: CommunityModel + let trackerCallback: (_ item: CommunityModel) -> Void + let swipeActions: SwipeConfiguration? + let complications: [CommunityComplication] + let showBlockStatus: Bool + let navigationEnabled: Bool + + @State private var menuFunctionPopup: MenuFunctionPopup? + + @EnvironmentObject var editorTracker: EditorTracker + @EnvironmentObject var modToolTracker: ModToolTracker + + init( + _ community: CommunityModel, + complications: [CommunityComplication] = .withoutTypeLabel, + showBlockStatus: Bool = true, + swipeActions: SwipeConfiguration? = nil, + navigationEnabled: Bool = true, + trackerCallback: @escaping (_ item: CommunityModel) -> Void = { _ in } + ) { + self.community = community + self.complications = complications + self.showBlockStatus = showBlockStatus + self.swipeActions = swipeActions + self.navigationEnabled = navigationEnabled + self.trackerCallback = trackerCallback + } + + var body: some View { + communityRow + .opacity(((community.blocked ?? false) && showBlockStatus) ? 0.5 : 1) + .buttonStyle(.plain) + .padding(.vertical, 8) + .background(.background) + .draggable(community.communityUrl) { + HStack { + AvatarView(community: community, avatarSize: 24) + Text(community.name) + } + .padding(8) + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .destructiveConfirmation(menuFunctionPopup: $menuFunctionPopup) + .addSwipeyActions(swipeActions ?? community.swipeActions(trackerCallback, menuFunctionPopup: $menuFunctionPopup)) + .contextMenu { + ForEach( + community.menuFunctions( + editorTracker: editorTracker, + modToolTracker: modToolTracker, + trackerCallback + ) + ) { item in + MenuButton(menuFunction: item, menuFunctionPopup: $menuFunctionPopup) + } + } + } + + @ViewBuilder + var communityRow: some View { + if navigationEnabled { + NavigationLink(value: AppRoute.community(community)) { + CommunityListRowBody( + community: community, + complications: complications, + showBlockStatus: showBlockStatus, + navigationEnabled: true + ) + } + } else { + CommunityListRowBody( + community: community, + complications: complications, + showBlockStatus: showBlockStatus, + navigationEnabled: false + ) + } + } +} + +#Preview { + CommunityListRow( + .init(from: .mock()) + ) +} diff --git a/Mlem/Views/Shared/CommunityList/CommunityListRowBody.swift b/Mlem/Views/Shared/CommunityList/CommunityListRowBody.swift new file mode 100644 index 000000000..57dfd5a86 --- /dev/null +++ b/Mlem/Views/Shared/CommunityList/CommunityListRowBody.swift @@ -0,0 +1,99 @@ +// +// CommunityListRowBody.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-07. +// + +import Foundation +import SwiftUI + +struct CommunityListRowBody: View { + let community: CommunityModel + let complications: [CommunityComplication] + var showBlockStatus: Bool = true + let navigationEnabled: Bool + + var title: String { + var suffix = "" + if community.blocked ?? false, showBlockStatus { + suffix.append(" ∙ Blocked") + } + if community.nsfw { + suffix.append("∙ NSFW") + } + return community.name + suffix + } + + var caption: String { + var parts: [String] = [] + if complications.contains(.type) { + parts.append("Community") + } + if complications.contains(.instance), let host = community.communityUrl.host { + parts.append("@\(host)") + } + return parts.joined(separator: " ∙ ") + } + + 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 { + HStack(spacing: 10) { + if community.blocked ?? false, showBlockStatus { + Image(systemName: Icons.hide) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .padding(9) + } else { + AvatarView(community: community, avatarSize: 48, iconResolution: .fixed(128)) + } + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .lineLimit(1) + .foregroundStyle(community.nsfw ? .red : .primary) + Text(caption) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if complications.contains(.subscribers), let subscriberCount = community.subscriberCount { + HStack(spacing: 5) { + Text(subscriberCount.abbreviated) + .monospacedDigit() + Image(systemName: subscriberCountIcon) + } + .foregroundStyle(subscriberCountColor) + } + + if navigationEnabled { + Image(systemName: Icons.forward) + .imageScale(.small) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal) + .contentShape(Rectangle()) + } +} diff --git a/Mlem/Views/Shared/Components/Components/BanButtonView.swift b/Mlem/Views/Shared/Components/Components/BanButtonView.swift new file mode 100644 index 000000000..b33e531b2 --- /dev/null +++ b/Mlem/Views/Shared/Components/Components/BanButtonView.swift @@ -0,0 +1,48 @@ +// +// BanButtonView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-29. +// + +import Foundation +import SwiftUI + +struct BanButtonView: View { + let banned: Bool + let iconName: String + let iconNameFill: String + let ban: () -> Void + + init(banned: Bool, instanceBan: Bool, ban: @escaping () -> Void) { + self.banned = banned + if instanceBan { + self.iconName = Icons.instanceBan + self.iconNameFill = Icons.instanceBanned + } else { + self.iconName = Icons.communityBan + self.iconNameFill = Icons.communityBanFill + } + self.ban = ban + } + + var body: some View { + Button { + ban() + } label: { + Image(systemName: banned ? iconNameFill : iconName) + .resizable() + .scaledToFit() + .foregroundStyle(banned ? .white : .primary) + .frame(width: AppConstants.barIconSize, height: AppConstants.barIconSize) + .padding(AppConstants.barIconPadding) + .background(RoundedRectangle(cornerRadius: AppConstants.tinyItemCornerRadius) + .aspectRatio(1, contentMode: .fit) + .foregroundColor(banned ? .red : .clear)) + .padding(AppConstants.standardSpacing) + .contentShape(Rectangle()) + }.transaction { transaction in + transaction.disablesAnimations = true + } + } +} diff --git a/Mlem/Views/Shared/Components/Components/Ellipsis Menu.swift b/Mlem/Views/Shared/Components/Components/Ellipsis Menu.swift index 37843ac22..c73f79204 100644 --- a/Mlem/Views/Shared/Components/Components/Ellipsis Menu.swift +++ b/Mlem/Views/Shared/Components/Components/Ellipsis Menu.swift @@ -9,33 +9,25 @@ import SwiftUI struct EllipsisMenu: View { let size: CGFloat + var systemImage: String = Icons.menu let menuFunctions: [MenuFunction] - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? - - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } - + @State private var menuFunctionPopup: MenuFunctionPopup? + var body: some View { Menu { - ForEach(menuFunctions) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: confirmDestructive) + ForEach(menuFunctions) { item in + MenuButton(menuFunction: item, menuFunctionPopup: $menuFunctionPopup) } } label: { - Image(systemName: Icons.menu) - .frame(width: size, height: size) - .foregroundColor(.primary) + Image(systemName: systemImage) + .frame(width: 24, height: size) + .foregroundColor(menuFunctions.isEmpty ? .secondary : .primary) .background(RoundedRectangle(cornerRadius: AppConstants.tinyItemCornerRadius) .aspectRatio(1, contentMode: .fit) .foregroundColor(.clear)) } .onTapGesture {} // allows menu to pop up on first tap - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) + .destructiveConfirmation(menuFunctionPopup: $menuFunctionPopup) } } diff --git a/Mlem/Views/Shared/Components/Components/Menu Button.swift b/Mlem/Views/Shared/Components/Components/Menu Button.swift index ec3d2f59b..5557c64f4 100644 --- a/Mlem/Views/Shared/Components/Components/Menu Button.swift +++ b/Mlem/Views/Shared/Components/Components/Menu Button.swift @@ -10,21 +10,23 @@ import SwiftUI struct MenuButton: View { let menuFunction: MenuFunction - let confirmDestructive: ((StandardMenuFunction) -> Void)? + @Binding var menuFunctionPopup: MenuFunctionPopup? var body: some View { switch menuFunction { + case .divider: + Divider() case let .shareUrl(shareMenuFunction): ShareLink(item: shareMenuFunction.url) case let .shareImage(shareImageFunction): ShareLink(item: shareImageFunction.image, preview: .init("photo", image: shareImageFunction.image)) case let .standard(standardMenuFunction): - let role: ButtonRole? = standardMenuFunction.destructiveActionPrompt != nil ? .destructive : nil - Button(role: role) { - if standardMenuFunction.destructiveActionPrompt != nil, let confirmDestructive { - confirmDestructive(standardMenuFunction) - } else { - standardMenuFunction.callback() + Button(role: standardMenuFunction.isDestructive ? .destructive : nil) { + switch standardMenuFunction.role { + case let .standard(callback): + callback() + case let .popup(menuFunctionPopup): + self.menuFunctionPopup = menuFunctionPopup } } label: { Label(standardMenuFunction.text, systemImage: standardMenuFunction.imageName) @@ -34,6 +36,32 @@ struct MenuButton: View { NavigationLink(navigationMenuFunction.destination) { Label(navigationMenuFunction.text, systemImage: navigationMenuFunction.imageName) } + case let .openUrl(openUrlMenuFunction): + Link(destination: openUrlMenuFunction.destination) { + Label(openUrlMenuFunction.text, systemImage: openUrlMenuFunction.imageName) + } + case let .controlGroup(groupMenuFunction): + + if #available(iOS 16.4, *) { + ControlGroup { + ForEach(groupMenuFunction.children) { child in + MenuButton(menuFunction: child, menuFunctionPopup: $menuFunctionPopup) + } + } + .controlGroupStyle(.compactMenu) + } else { + ForEach(groupMenuFunction.children) { child in + MenuButton(menuFunction: child, menuFunctionPopup: $menuFunctionPopup) + } + } + case let .disclosureGroup(groupMenuFunction): + Menu { + ForEach(groupMenuFunction.children) { child in + MenuButton(menuFunction: child, menuFunctionPopup: $menuFunctionPopup) + } + } label: { + Label(groupMenuFunction.text, systemImage: groupMenuFunction.imageName) + } } } } diff --git a/Mlem/Views/Shared/Components/Components/PurgeButtonView.swift b/Mlem/Views/Shared/Components/Components/PurgeButtonView.swift new file mode 100644 index 000000000..369a769a9 --- /dev/null +++ b/Mlem/Views/Shared/Components/Components/PurgeButtonView.swift @@ -0,0 +1,37 @@ +// +// PurgeButtonView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-29. +// + +import Dependencies +import Foundation +import SwiftUI + +struct PurgeButtonView: View { + @Dependency(\.siteInformation) var siteInformation + + let purged: Bool + let purge: () -> Void + + var body: some View { + Button { + purge() + } label: { + Image(systemName: Icons.purge) + .resizable() + .scaledToFit() + .foregroundStyle(purged ? Color(uiColor: .systemBackground) : .primary) + .frame(width: AppConstants.barIconSize, height: AppConstants.barIconSize) + .padding(AppConstants.barIconPadding) + .background(RoundedRectangle(cornerRadius: AppConstants.tinyItemCornerRadius) + .aspectRatio(1, contentMode: .fit) + .foregroundColor(purged ? .primary : .clear)) + .opacity(siteInformation.isAdmin ? 1 : 0.5) + .padding(AppConstants.standardSpacing) + .contentShape(Rectangle()) + } + .disabled(!siteInformation.isAdmin) + } +} diff --git a/Mlem/Views/Shared/Components/Components/RemoveButtonView.swift b/Mlem/Views/Shared/Components/Components/RemoveButtonView.swift new file mode 100644 index 000000000..fd788cc63 --- /dev/null +++ b/Mlem/Views/Shared/Components/Components/RemoveButtonView.swift @@ -0,0 +1,35 @@ +// +// RemoveButtonView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-29. +// + +import Foundation +import SwiftUI + +struct RemoveButtonView: View { + let removed: Bool + let remove: () -> Void + + var body: some View { + Button { + remove() + } label: { + Image(systemName: removed ? Icons.removeFill : Icons.remove) + .resizable() + .scaledToFit() + .foregroundStyle(removed ? .white : .primary) + .frame(width: AppConstants.barIconSize, height: AppConstants.barIconSize) + .padding(AppConstants.barIconPadding) + .background(RoundedRectangle(cornerRadius: AppConstants.tinyItemCornerRadius) + .aspectRatio(1, contentMode: .fit) + .foregroundColor(removed ? .red : .clear)) + .padding(AppConstants.standardSpacing) + .contentShape(Rectangle()) + } + .transaction { transaction in + transaction.disablesAnimations = true + } + } +} diff --git a/Mlem/Views/Shared/Components/Components/ResolveButtonView.swift b/Mlem/Views/Shared/Components/Components/ResolveButtonView.swift new file mode 100644 index 000000000..5b53e5f47 --- /dev/null +++ b/Mlem/Views/Shared/Components/Components/ResolveButtonView.swift @@ -0,0 +1,37 @@ +// +// ResolveButtonView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-29. +// + +import Foundation +import SwiftUI + +struct ResolveButtonView: View { + let resolved: Bool + let resolve: () async -> Void + + var body: some View { + Button { + Task(priority: .userInitiated) { + await resolve() + } + } label: { + Image(systemName: resolved ? Icons.resolveFill : Icons.resolve) + .resizable() + .scaledToFit() + .foregroundStyle(resolved ? .white : .primary) + .frame(width: AppConstants.barIconSize, height: AppConstants.barIconSize) + .padding(AppConstants.barIconPadding) + .background(RoundedRectangle(cornerRadius: AppConstants.tinyItemCornerRadius) + .aspectRatio(1, contentMode: .fit) + .foregroundColor(resolved ? .green : .clear)) + .padding(AppConstants.standardSpacing) + .contentShape(Rectangle()) + } + .transaction { transaction in + transaction.disablesAnimations = true + } + } +} diff --git a/Mlem/Views/Shared/Components/Components/SaveButtonView.swift b/Mlem/Views/Shared/Components/Components/SaveButtonView.swift index 4001584a4..f1fbc5bf4 100644 --- a/Mlem/Views/Shared/Components/Components/SaveButtonView.swift +++ b/Mlem/Views/Shared/Components/Components/SaveButtonView.swift @@ -13,7 +13,7 @@ struct SaveButtonView: View { let isSaved: Bool let accessibilityContext: String - let save: () -> Void + let save: () async -> Void // ==== COMPUTED ==== // @@ -24,7 +24,9 @@ struct SaveButtonView: View { var body: some View { Button { - save() + Task(priority: .userInitiated) { + await save() + } } label: { Image(systemName: isSaved ? Icons.saveFill : Icons.save) .resizable() @@ -40,7 +42,11 @@ struct SaveButtonView: View { .fontWeight(.medium) // makes it look a little nicer } .accessibilityLabel(saveButtonText) - .accessibilityAction(.default) { save() } + .accessibilityAction(.default) { + Task(priority: .userInitiated) { + await save() + } + } .buttonStyle(.plain) .transaction { transaction in transaction.disablesAnimations = true diff --git a/Mlem/Views/Shared/Components/Components/Website Indicator View.swift b/Mlem/Views/Shared/Components/Components/WebsiteIndicatorView.swift similarity index 94% rename from Mlem/Views/Shared/Components/Components/Website Indicator View.swift rename to Mlem/Views/Shared/Components/Components/WebsiteIndicatorView.swift index b09ec2417..a740d32eb 100644 --- a/Mlem/Views/Shared/Components/Components/Website Indicator View.swift +++ b/Mlem/Views/Shared/Components/Components/WebsiteIndicatorView.swift @@ -1,5 +1,5 @@ // -// Website Indicator View.swift +// WebsiteIndicatorView.swift // Mlem // // Created by Eric Andrews on 2023-08-23. diff --git a/Mlem/Views/Shared/Components/EasyTapLinkView.swift b/Mlem/Views/Shared/Components/EasyTapLinkView.swift index 1a752f443..37f52d853 100644 --- a/Mlem/Views/Shared/Components/EasyTapLinkView.swift +++ b/Mlem/Views/Shared/Components/EasyTapLinkView.swift @@ -74,6 +74,13 @@ extension LinkType: Hashable, Identifiable { } var id: Int { hashValue } + + var isWebsite: Bool { + if case .website = self { + return true + } + return false + } } enum EasyTapLinkDisplayMode: String, SettingsOptions { @@ -114,12 +121,17 @@ struct EasyTapLinkView: View { .background(RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius) .foregroundColor(Color(UIColor.secondarySystemBackground))) .contextMenu { - Button("Copy", systemImage: Icons.copy) { - let pasteboard = UIPasteboard.general - pasteboard.url = linkType.url + if linkType.isWebsite { + Button("Open", systemImage: Icons.browser) { + openURL(linkType.url) + } + Button("Copy", systemImage: Icons.copy) { + let pasteboard = UIPasteboard.general + pasteboard.url = linkType.url + } + ShareLink(item: linkType.url) } - ShareLink(item: linkType.url) - } + } preview: { WebView(url: linkType.url) } } @ViewBuilder diff --git a/Mlem/Views/Shared/Components/EmbeddedCommentView.swift b/Mlem/Views/Shared/Components/EmbeddedCommentView.swift new file mode 100644 index 000000000..b1bf39cd9 --- /dev/null +++ b/Mlem/Views/Shared/Components/EmbeddedCommentView.swift @@ -0,0 +1,49 @@ +// +// EmbeddedCommentView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-28. +// + +import Foundation +import MarkdownUI +import SwiftUI + +struct EmbeddedCommentView: View { + let comment: APIComment + let post: PostModel? + let community: CommunityModel? + + var body: some View { + NavigationLink(.lazyLoadPostLinkWithContext(.init(postId: comment.postId, scrollTarget: comment.id))) { + content + } + } + + var content: some View { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + if let post { + Text(post.post.name) + .bold() + } + + if let communityNameComponents = community?.fullyQualifiedNameComponents { + HStack(alignment: .center, spacing: 0) { + Text("in \(communityNameComponents.0)") + Text("@\(communityNameComponents.1)").opacity(0.5) + } + .foregroundColor(.secondary) + .font(.footnote) + } + + MarkdownView(text: comment.content, isNsfw: false, isInline: true) + } + .padding(AppConstants.standardSpacing) + .background { + Rectangle() + .foregroundColor(.secondarySystemBackground) + .cornerRadius(AppConstants.standardSpacing) + } + .foregroundStyle(.secondary) + } +} diff --git a/Mlem/Views/Shared/Components/End Of Feed View.swift b/Mlem/Views/Shared/Components/End Of Feed View.swift index e23ed9625..31abe19aa 100644 --- a/Mlem/Views/Shared/Components/End Of Feed View.swift +++ b/Mlem/Views/Shared/Components/End Of Feed View.swift @@ -14,7 +14,7 @@ struct EndOfFeedViewContent { } enum EndOfFeedViewType { - case hobbit, cartoon + case hobbit, cartoon, turtle var viewContent: EndOfFeedViewContent { switch self { @@ -22,6 +22,8 @@ enum EndOfFeedViewType { return EndOfFeedViewContent(icon: Icons.endOfFeedHobbit, message: "I think I've found the bottom!") case .cartoon: return EndOfFeedViewContent(icon: Icons.endOfFeedCartoon, message: "That's all, folks!") + case .turtle: + return EndOfFeedViewContent(icon: Icons.endOfFeedTurtle, message: "It's turtles all the way down") } } } @@ -29,6 +31,7 @@ enum EndOfFeedViewType { struct EndOfFeedView: View { let loadingState: LoadingState let viewType: EndOfFeedViewType + let whatIsLoading: LoadingView.PossibleThingsToLoad var body: some View { Group { @@ -36,7 +39,7 @@ struct EndOfFeedView: View { case .idle: EmptyView() case .loading: - LoadingView(whatIsLoading: .posts) + LoadingView(whatIsLoading: whatIsLoading) case .done: HStack { Image(systemName: viewType.viewContent.icon) diff --git a/Mlem/Views/Shared/Components/Image Upload/LinkAttatchmentView.swift b/Mlem/Views/Shared/Components/Image Upload/LinkAttatchmentView.swift index 7433e3dbd..4a89fd57f 100644 --- a/Mlem/Views/Shared/Components/Image Upload/LinkAttatchmentView.swift +++ b/Mlem/Views/Shared/Components/Image Upload/LinkAttatchmentView.swift @@ -5,34 +5,24 @@ // Created by Sjmarf on 26/09/2023. // -import SwiftUI -import PhotosUI import Dependencies +import PhotosUI +import SwiftUI -struct LinkAttachmentView: View { +struct LinkAttachmentModifier: ViewModifier { @Dependency(\.apiClient) var apiClient: APIClient @AppStorage("promptUser.permission.privacy.allowImageUploads") var askedForPermissionToUploadImages: Bool = false @AppStorage("confirmImageUploads") var confirmImageUploads: Bool = false - - @ViewBuilder let content: Content @ObservedObject var model: LinkAttachmentModel - init( - model: LinkAttachmentModel, - @ViewBuilder content: @escaping () -> Content - ) { - self.content = content() - self.model = model - } - - var body: some View { + func body(content: Content) -> some View { content - .photosPicker(isPresented: $model.showingPhotosPicker, selection: $model.photosPickerItem, matching: .images) .fileImporter(isPresented: $model.showingFilePicker, allowedContentTypes: [.image]) { result in model.prepareToUpload(result: result) } + .photosPicker(isPresented: $model.showingPhotosPicker, selection: $model.photosPickerItem, matching: .images) .onChange(of: model.photosPickerItem) { newValue in if let newValue { Task { @@ -56,3 +46,9 @@ struct LinkAttachmentView: View { } } } + +extension View { + func linkAttachmentModel(model: LinkAttachmentModel) -> some View { + modifier(LinkAttachmentModifier(model: model)) + } +} diff --git a/Mlem/Views/Shared/Components/Image Upload/LinkUploadOptionsView.swift b/Mlem/Views/Shared/Components/Image Upload/LinkUploadOptionsView.swift index 77f3e9584..bb9857b41 100644 --- a/Mlem/Views/Shared/Components/Image Upload/LinkUploadOptionsView.swift +++ b/Mlem/Views/Shared/Components/Image Upload/LinkUploadOptionsView.swift @@ -20,13 +20,13 @@ struct LinkUploadOptionsView: View { var body: some View { Menu { Button(action: model.attachImageAction) { - Label("Photo Library", systemImage: "photo.on.rectangle") + Label("Photo Library", systemImage: Icons.choosePhoto) } Button(action: model.attachFileAction) { - Label("Choose File", systemImage: "folder") + Label("Choose File", systemImage: Icons.chooseFile) } Button(action: model.pasteFromClipboardAction) { - Label("Paste", systemImage: "doc.on.clipboard") + Label("Paste", systemImage: Icons.paste) } } label: { label diff --git a/Mlem/Views/Shared/Components/InteractionBarView.swift b/Mlem/Views/Shared/Components/InteractionBarView.swift index e498de61a..df999a21d 100644 --- a/Mlem/Views/Shared/Components/InteractionBarView.swift +++ b/Mlem/Views/Shared/Components/InteractionBarView.swift @@ -9,35 +9,102 @@ import Dependencies import Foundation import SwiftUI +enum InteractionBarContext { + case post, comment + + var accessibilityLabel: String { + switch self { + case .post: + "post" + case .comment: + "comment" + } + } +} + /// View grouping post interactions--upvote, downvote, save, reply, plus post info struct InteractionBarView: View { + // post + @AppStorage("showDownvotesSeparately") var showPostDownvotesSeparately: Bool = false + @AppStorage("shouldShowScoreInPostBar") var shouldShowScoreInPostBar: Bool = false + @AppStorage("shouldShowTimeInPostBar") var shouldShowTimeInPostBar: Bool = true + @AppStorage("shouldShowSavedInPostBar") var shouldShowSavedInPostBar: Bool = false + @AppStorage("shouldShowRepliesInPostBar") var shouldShowRepliesInPostBar: Bool = true + + // comment + @AppStorage("showCommentDownvotesSeparately") var showCommentDownvotesSeparately: Bool = false + @AppStorage("shouldShowScoreInCommentBar") var shouldShowScoreInCommentBar: Bool = false + @AppStorage("shouldShowTimeInCommentBar") var shouldShowTimeInCommentBar: Bool = true + @AppStorage("shouldShowSavedInCommentBar") var shouldShowSavedInCommentBar: Bool = false + @AppStorage("shouldShowRepliesInCommentBar") var shouldShowRepliesInCommentBar: Bool = true + @Dependency(\.siteInformation) var siteInformation // environment @EnvironmentObject var commentTracker: CommentTracker - // metadata - let votes: VotesModel - let published: Date - let updated: Date? - let commentCount: Int - var unreadCommentCount: Int = 0 - let saved: Bool + let context: InteractionBarContext + let widgets: [EnrichedLayoutWidget] - let accessibilityContext: String - let widgets: [LayoutWidgetType] - - let upvote: () async -> Void - let downvote: () async -> Void - let save: () async -> Void - let reply: () -> Void - let shareURL: URL? + init( + context: InteractionBarContext, + widgets: [EnrichedLayoutWidget] + ) { + self.context = context + self.widgets = widgets + } + + // MARK: Info Stack stuff + + func detailedVotes(from votes: VotesModel) -> DetailedVotes? { + let (showScore, showDownvotesSeparately): (Bool, Bool) = { + switch context { + case .post: + (shouldShowScoreInPostBar, showPostDownvotesSeparately) + case .comment: + (shouldShowScoreInCommentBar, showCommentDownvotesSeparately) + } + }() + + if showScore { + return .init( + score: votes.total, + upvotes: votes.upvotes, + downvotes: votes.downvotes, + myVote: votes.myVote, + showDownvotes: showDownvotesSeparately + ) + } + + return nil + } + + var showPublished: Bool { + switch context { + case .post: + shouldShowTimeInPostBar + case .comment: + shouldShowTimeInCommentBar + } + } + + var showSaved: Bool { + switch context { + case .post: + shouldShowSavedInPostBar + case .comment: + shouldShowSavedInCommentBar + } + } - var shouldShowScore: Bool = true - var showDownvotesSeparately: Bool = false - var shouldShowTime: Bool = true - var shouldShowSaved: Bool = false - var shouldShowReplies: Bool = true + var showReplies: Bool { + switch context { + case .post: + shouldShowRepliesInPostBar + case .comment: + shouldShowRepliesInCommentBar + } + } func infoStackAlignment(_ offset: Int) -> HorizontalAlignment { if offset == 0 { @@ -48,82 +115,79 @@ struct InteractionBarView: View { return .center } + // MARK: Rendering + var body: some View { HStack(spacing: 0) { ForEach(Array(widgets.enumerated()), id: \.offset) { offset, widget in - switch widget { - case .scoreCounter: - ScoreCounterView( - vote: votes.myVote, - score: votes.total, - upvote: upvote, - downvote: downvote - ) - - case .upvoteCounter: - if offset == widgets.count - 1 { - UpvoteCounterView(vote: votes.myVote, score: votes.upvotes, upvote: upvote) - } else { - UpvoteCounterView(vote: votes.myVote, score: votes.upvotes, upvote: upvote) - .padding(.trailing, -AppConstants.postAndCommentSpacing) - } - - case .downvoteCounter: - if siteInformation.enableDownvotes { - if offset == widgets.count - 1 { - DownvoteCounterView(vote: votes.myVote, score: votes.downvotes, downvote: downvote) - } else { - DownvoteCounterView(vote: votes.myVote, score: votes.downvotes, downvote: downvote) - .padding(.trailing, -AppConstants.postAndCommentSpacing) - } - } - - case .upvote: - UpvoteButtonView(vote: votes.myVote, upvote: upvote) - - case .downvote: - if siteInformation.enableDownvotes { - DownvoteButtonView(vote: votes.myVote, downvote: downvote) - } - - case .save: - SaveButtonView(isSaved: saved, accessibilityContext: accessibilityContext, save: { - Task(priority: .userInitiated) { - await save() - } - }) - - case .reply: - ReplyButtonView(accessibilityContext: accessibilityContext, reply: reply) - - case .share: - ShareButtonView(accessibilityContext: accessibilityContext, url: shareURL) - - case .infoStack: - InfoStackView( - votes: shouldShowScore - ? DetailedVotes( - score: votes.total, - upvotes: votes.upvotes, - downvotes: votes.downvotes, - myVote: votes.myVote, - showDownvotes: showDownvotesSeparately - ) - : nil, - published: shouldShowTime ? published : nil, - updated: shouldShowTime ? updated : nil, - commentCount: shouldShowReplies ? commentCount : nil, - unreadCommentCount: unreadCommentCount, - saved: shouldShowSaved ? saved : nil, - alignment: infoStackAlignment(offset), - colorizeVotes: false - ) - .padding(AppConstants.postAndCommentSpacing) - .frame(minWidth: 0, maxWidth: .infinity) - } + buildWidget(for: widget, offset: offset) } } .foregroundStyle(.primary) .font(.callout) } + + // swiftlint:disable cyclomatic_complexity function_body_length + @ViewBuilder + func buildWidget(for widget: EnrichedLayoutWidget, offset: Int) -> some View { + switch widget { + case let .upvote(myVote, upvote): + UpvoteButtonView(vote: myVote, upvote: upvote) + case let .downvote(myVote, downvote): + DownvoteButtonView(vote: myVote, downvote: downvote) + case let .save(saved, save): + SaveButtonView(isSaved: saved, accessibilityContext: context.accessibilityLabel, save: save) + case let .reply(reply): + ReplyButtonView(accessibilityContext: context.accessibilityLabel, reply: reply) + case let .share(shareUrl): + ShareButtonView(accessibilityContext: context.accessibilityLabel, url: shareUrl) + case let .upvoteCounter(votes, upvote): + if offset == widgets.count - 1 { + UpvoteCounterView(vote: votes.myVote, score: votes.upvotes, upvote: upvote) + } else { + UpvoteCounterView(vote: votes.myVote, score: votes.upvotes, upvote: upvote) + .padding(.trailing, -AppConstants.standardSpacing) + } + case let .downvoteCounter(votes, downvote): + if siteInformation.enableDownvotes { + if offset == widgets.count - 1 { + DownvoteCounterView(vote: votes.myVote, score: votes.downvotes, downvote: downvote) + } else { + DownvoteCounterView(vote: votes.myVote, score: votes.downvotes, downvote: downvote) + .padding(.trailing, -AppConstants.standardSpacing) + } + } + case let .scoreCounter(votes, upvote, downvote): + ScoreCounterView( + vote: votes.myVote, + score: votes.total, + upvote: upvote, + downvote: downvote + ) + case let .resolve(resolved, resolve): + ResolveButtonView(resolved: resolved, resolve: resolve) + case let .remove(removed, remove): + RemoveButtonView(removed: removed, remove: remove) + case let .purge(purged, purge): + PurgeButtonView(purged: purged, purge: purge) + case let .ban(banned, instanceBan, ban): + BanButtonView(banned: banned, instanceBan: instanceBan, ban: ban) + case let .infoStack(colorizeVotes, votes, published, updated, commentCount, unreadCommentCount, saved): + InfoStackView( + votes: detailedVotes(from: votes), + published: showPublished ? published : nil, + updated: updated, + commentCount: showReplies ? commentCount : nil, + unreadCommentCount: unreadCommentCount, + saved: showSaved ? saved : nil, + alignment: infoStackAlignment(offset), + colorizeVotes: colorizeVotes + ) + .padding(AppConstants.standardSpacing) + .frame(minWidth: 0, maxWidth: .infinity) + case .spacer: + Spacer() + } + } + // swiftlint:enable cyclomatic_complexity function_body_length } diff --git a/Mlem/Views/Shared/Components/Line.swift b/Mlem/Views/Shared/Components/Line.swift new file mode 100644 index 000000000..189f3f5b9 --- /dev/null +++ b/Mlem/Views/Shared/Components/Line.swift @@ -0,0 +1,19 @@ +// +// Line.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import SwiftUI + +// https://stackoverflow.com/a/63188568/17629371 + +struct Line: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: rect.width, y: 0)) + return path + } +} diff --git a/Mlem/Views/Shared/Components/Modlog/ModlogEntryView.swift b/Mlem/Views/Shared/Components/Modlog/ModlogEntryView.swift new file mode 100644 index 000000000..490c5ba60 --- /dev/null +++ b/Mlem/Views/Shared/Components/Modlog/ModlogEntryView.swift @@ -0,0 +1,86 @@ +// +// ModlogEntryView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-11. +// + +import Foundation +import SwiftUI + +struct ModlogEntryView: View { + let modlogEntry: ModlogEntry + + var body: some View { + content + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppConstants.standardSpacing) + .background(Color(uiColor: .systemBackground)) + .contextMenu { + ForEach(modlogEntry.contextLinks) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + } + } + + var content: some View { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + HStack { + Text("\(modlogEntry.date.formatted()) (\(modlogEntry.date.getRelativeTime()))") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + EllipsisMenu(size: 20, menuFunctions: modlogEntry.contextLinks) + .opacity(modlogEntry.contextLinks.isEmpty ? 0.4 : 1) + } + + description + } + } + + @ViewBuilder + var description: some View { + HStack(alignment: .top, spacing: AppConstants.standardSpacing) { + Image(systemName: modlogEntry.icon.imageName) + .foregroundColor(modlogEntry.icon.color) + .padding(.top, 3) // line it up nicely with the text + + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + Text(modlogEntry.description) + + switch modlogEntry.reason { + case .inapplicable: + EmptyView() + case .noneGiven: + Text("No reason given") + .italic() + .foregroundColor(.secondary) + case let .reason(reason): + Text("Reason: \(reason)") + } + + switch modlogEntry.expires { + case .inapplicable: + EmptyView() + case .permanent: + Text("Permanent") + .italic() + .foregroundColor(.secondary) + case let .date(date): + let expireTerm = date > Date.now ? "Expires" : "Expired" + Text("\(expireTerm) \(date.getRelativeTime())") + .italic() + .foregroundColor(.secondary) + } + + if let additionalContext = modlogEntry.additionalContext { + Text(additionalContext) + .italic() + .foregroundStyle(.secondary) + } + } + } + } +} diff --git a/Mlem/Views/Shared/Components/Modlog/ModlogView.swift b/Mlem/Views/Shared/Components/Modlog/ModlogView.swift new file mode 100644 index 000000000..35483dcfb --- /dev/null +++ b/Mlem/Views/Shared/Components/Modlog/ModlogView.swift @@ -0,0 +1,443 @@ +// +// ModlogView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-10. +// + +import Dependencies +import Foundation +import SwiftUI + +// swiftlint:disable file_length + +// swiftlint:disable:next type_body_length +struct ModlogView: View { + @AppStorage("showModlogWarning") var showModlogWarning: Bool = true + + @Dependency(\.apiClient) var apiClient + @Dependency(\.errorHandler) var errorHandler + + // TODO: 2.0 enable searching--search needs to be submitted against the instance that the modlog is fetched from to ensure that the communityId/moderatorId is locally correct, which is annoying right now but very easy in 2.0. + + @State var selectedAction: ModlogAction = .all + + @State var currentTracker: any TrackerProtocol + + @StateObject var modlogTracker: ModlogTracker + @StateObject var postRemovalsTracker: ModlogChildTracker + @StateObject var postLocksTracker: ModlogChildTracker + @StateObject var postPinsTracker: ModlogChildTracker + @StateObject var commentRemovalsTracker: ModlogChildTracker + @StateObject var communityRemovalsTracker: ModlogChildTracker + @StateObject var communityBansTracker: ModlogChildTracker + @StateObject var instanceBansTracker: ModlogChildTracker + @StateObject var moderatorAddsTracker: ModlogChildTracker + @StateObject var communityTransfersTracker: ModlogChildTracker + @StateObject var administratorAddsTracker: ModlogChildTracker + @StateObject var personPurgesTracker: ModlogChildTracker + @StateObject var communityPurgesTracker: ModlogChildTracker + @StateObject var postPurgesTracker: ModlogChildTracker + @StateObject var commentPurgesTracker: ModlogChildTracker + @StateObject var communityHidesTracker: ModlogChildTracker + + @State var instanceContext: InstanceModel? + @State var communityContext: CommunityModel? + + @State var modlogWarningDisplayed: Bool + @State var suppressModlogWarning: Bool = false + + @State var errorDetails: ErrorDetails? + @Namespace var scrollToTop + @State private var scrollToTopAppeared = false + + // TODO: 2.0 tidy this up with @Observable + // swiftlint:disable:next function_body_length + init(modlogLink: ModlogLink) { + @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast + @AppStorage("showModlogWarning") var showModlogWarning = true + + self._modlogWarningDisplayed = .init(wrappedValue: showModlogWarning) + + switch modlogLink { + case .userInstance: + self._instanceContext = .init(wrappedValue: nil) + self._communityContext = .init(wrappedValue: nil) + case let .instance(instanceModel): + self._instanceContext = .init(wrappedValue: instanceModel) + self._communityContext = .init(wrappedValue: nil) + case let .community(communityModel): + self._instanceContext = .init(wrappedValue: nil) // TODO: home instance + self._communityContext = .init(wrappedValue: communityModel) + } + + let modlogTracker: ModlogTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + childTrackers: .init() + ) + + let postRemovalsTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .postRemoval, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(postRemovalsTracker) + + let postLocksTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .postLock, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(postLocksTracker) + + let postPinsTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .postPin, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(postPinsTracker) + + let commentRemovalsTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .commentRemoval, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(commentRemovalsTracker) + + let communityRemovalsTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .communityRemoval, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(communityRemovalsTracker) + + let communityBansTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .communityBan, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(communityBansTracker) + + let instanceBansTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .instanceBan, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(instanceBansTracker) + + let moderatorAddsTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .moderatorAdd, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(moderatorAddsTracker) + + let communityTransfersTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .communityTransfer, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(communityTransfersTracker) + + let administratorAddsTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .administratorAdd, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(administratorAddsTracker) + + let personPurgesTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .personPurge, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(personPurgesTracker) + + let communityPurgesTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .communityPurge, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(communityPurgesTracker) + + let postPurgesTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .postPurge, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(postPurgesTracker) + + let commentPurgesTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .commentPurge, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(commentPurgesTracker) + + let communityHidesTracker: ModlogChildTracker = .init( + internetSpeed: internetSpeed, + sortType: .new, + actionType: .communityHide, + modlogLink: modlogLink, + firstPageProvider: modlogTracker + ) + modlogTracker.addChildTracker(communityHidesTracker) + + self._postRemovalsTracker = .init(wrappedValue: postRemovalsTracker) + self._postLocksTracker = .init(wrappedValue: postLocksTracker) + self._postPinsTracker = .init(wrappedValue: postPinsTracker) + self._commentRemovalsTracker = .init(wrappedValue: commentRemovalsTracker) + self._communityRemovalsTracker = .init(wrappedValue: communityRemovalsTracker) + self._communityBansTracker = .init(wrappedValue: communityBansTracker) + self._instanceBansTracker = .init(wrappedValue: instanceBansTracker) + self._moderatorAddsTracker = .init(wrappedValue: moderatorAddsTracker) + self._communityTransfersTracker = .init(wrappedValue: communityTransfersTracker) + self._administratorAddsTracker = .init(wrappedValue: administratorAddsTracker) + self._personPurgesTracker = .init(wrappedValue: personPurgesTracker) + self._communityPurgesTracker = .init(wrappedValue: communityPurgesTracker) + self._postPurgesTracker = .init(wrappedValue: postPurgesTracker) + self._commentPurgesTracker = .init(wrappedValue: commentPurgesTracker) + self._communityHidesTracker = .init(wrappedValue: communityHidesTracker) + self._modlogTracker = .init(wrappedValue: modlogTracker) + self._currentTracker = .init(wrappedValue: modlogTracker) + } + + var body: some View { + if modlogWarningDisplayed { + modlogWarning + } else { + ScrollViewReader { scrollProxy in + content + .animation(.easeOut(duration: 0.2), value: currentTracker.items.isEmpty) + .task { await currentTracker.loadMoreItems() } + .refreshable { + await Task { + await modlogTracker.refresh() + }.value + } + .onChange(of: selectedAction) { newValue in + switch newValue { + case .all: + currentTracker = modlogTracker + case .postRemoval: + currentTracker = postRemovalsTracker + case .postLock: + currentTracker = postLocksTracker + case .postPin: + currentTracker = postPinsTracker + case .commentRemoval: + currentTracker = commentRemovalsTracker + case .communityRemoval: + currentTracker = communityRemovalsTracker + case .communityBan: + currentTracker = communityBansTracker + case .instanceBan: + currentTracker = instanceBansTracker + case .moderatorAdd: + currentTracker = moderatorAddsTracker + case .communityTransfer: + currentTracker = communityTransfersTracker + case .administratorAdd: + currentTracker = administratorAddsTracker + case .personPurge: + currentTracker = personPurgesTracker + case .communityPurge: + currentTracker = communityPurgesTracker + case .postPurge: + currentTracker = postPurgesTracker + case .commentPurge: + currentTracker = commentPurgesTracker + case .communityHide: + currentTracker = communityHidesTracker + } + + if currentTracker.items.isEmpty { + Task { + await currentTracker.loadMoreItems() + } + } + } + .navigationTitle("Modlog") + .hoistNavigation { + if scrollToTopAppeared { + return false + } + withAnimation { + scrollProxy.scrollTo(scrollToTop, anchor: .bottom) + } + return true + } + .fancyTabScrollCompatible() + } + } + } + + @ViewBuilder + var content: some View { + ScrollView { + LazyVStack(spacing: 0) { + ScrollToView(appeared: $scrollToTopAppeared) + .id(scrollToTop) + + Divider() + + header + .padding(AppConstants.standardSpacing) + + Divider() + + if currentTracker.items.isEmpty { + noEntriesView() + } else { + ForEach(currentTracker.items, id: \.uid) { entry in + entryView(for: entry) + } + + EndOfFeedView(loadingState: currentTracker.loadingState, viewType: .turtle, whatIsLoading: .modlog) + } + } + } + } + + @ViewBuilder + var modlogWarning: some View { + VStack(alignment: .center, spacing: AppConstants.doubleSpacing) { + WarningView( + iconName: Icons.warning, + text: "The moderation log may contain sensitive or disturbing content. Proceed with caution.", + inList: false + ) + + VStack(spacing: AppConstants.standardSpacing) { + Button { + modlogWarningDisplayed = false + if suppressModlogWarning { + showModlogWarning = false + } + } label: { + Text("View Modlog") + .padding(3) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + Toggle(isOn: $suppressModlogWarning) { + Text("Do not show this warning again") + } + .padding(5) + } + } + .padding(AppConstants.standardSpacing) + } + + @ViewBuilder + var header: some View { + HStack { + if let instanceContext { + InstanceLabelView(instance: instanceContext) + } + + if let communityContext { + CommunityLabelView(community: communityContext, serverInstanceLocation: .bottom) + } + + Spacer() + + Picker("Modlog Action", selection: $selectedAction) { + Text(ModlogAction.all.label).tag(ModlogAction.all) + + Divider() + + ForEach(ModlogAction.communityActionCases, id: \.self) { action in + Text(action.label).tag(action) + } + + Divider() + + ForEach(ModlogAction.removalCases, id: \.self) { action in + Text(action.label).tag(action) + } + + Divider() + + ForEach(ModlogAction.banCases, id: \.self) { action in + Text(action.label).tag(action) + } + + Divider() + + ForEach(ModlogAction.instanceActionCases, id: \.self) { action in + Text(action.label).tag(action) + } + + Divider() + + ForEach(ModlogAction.purgeCases, id: \.self) { action in + Text(action.label).tag(action) + } + } + } + } + + @ViewBuilder + private func entryView(for entry: ModlogEntry) -> some View { + VStack(spacing: 0) { + ModlogEntryView(modlogEntry: entry) + Divider() + } + .onAppear { currentTracker.loadIfThreshold(entry) } + } + + @ViewBuilder + private func noEntriesView() -> some View { + VStack { + if currentTracker.loadingState == .loading || + (currentTracker.items.isEmpty && currentTracker.loadingState == .idle) { + LoadingView(whatIsLoading: .modlog) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + } else if let errorDetails { + ErrorView(errorDetails) + .frame(maxWidth: .infinity) + } else if currentTracker.loadingState == .done { + Text("No items found") + .italic() + .foregroundColor(.secondary) + .padding(.top, 20) + .transition(.scale(scale: 0.9).combined(with: .opacity)) + } + } + .animation(.easeOut(duration: 0.1), value: currentTracker.loadingState) + } +} + +// swiftlint:enable file_length diff --git a/Mlem/Views/Shared/Components/Thumbnail Image View.swift b/Mlem/Views/Shared/Components/Thumbnail Image View.swift index a8fdf10e3..b1c995d11 100644 --- a/Mlem/Views/Shared/Components/Thumbnail Image View.swift +++ b/Mlem/Views/Shared/Components/Thumbnail Image View.swift @@ -11,6 +11,7 @@ import SwiftUI struct ThumbnailImageView: View { @AppStorage("shouldBlurNsfw") var shouldBlurNsfw: Bool = true + @AppStorage("showWebsiteIndicatorIcon") var showWebsiteIndicatorIcon: Bool = false @Dependency(\.errorHandler) var errorHandler @Dependency(\.postRepository) var postRepository @@ -23,38 +24,36 @@ struct ThumbnailImageView: View { let size = CGSize(width: AppConstants.thumbnailSize, height: AppConstants.thumbnailSize) var body: some View { - Group { + VStack { switch post.postType { case let .image(url): // just blur, no need for the whole filter viewModifier since this is just a thumbnail CachedImage( url: url, + hasContextMenu: true, fixedSize: size, blurRadius: showNsfwFilter ? 8 : 0, contentMode: .fill, onTapCallback: markPostAsRead ) case let .link(url): - CachedImage( - url: url, - shouldExpand: false, - fixedSize: size, - blurRadius: showNsfwFilter ? 8 : 0, - contentMode: .fill - ) - .onTapGesture { - if let url = post.post.linkUrl { - openURL(url) - markPostAsRead() - } - } - .overlay { - Group { - WebsiteIndicatorView() - .frame(width: 20, height: 20) - .padding(6) + VStack { + if let linkUrl = post.post.linkUrl { + websiteView(url: url) + .contextMenu { + Button("Open", systemImage: Icons.browser) { + openURL(linkUrl) + markPostAsRead() + } + Button("Copy", systemImage: Icons.copy) { + let pasteboard = UIPasteboard.general + pasteboard.url = linkUrl + } + ShareLink(item: linkUrl) + } preview: { WebView(url: linkUrl) } + } else { + websiteView(url: url) } - .frame(width: size.width, height: size.height, alignment: .topLeading) } case .text: Image(systemName: Icons.textPost) @@ -71,6 +70,33 @@ struct ThumbnailImageView: View { .overlay(RoundedRectangle(cornerRadius: AppConstants.smallItemCornerRadius) .stroke(Color(UIColor.secondarySystemBackground), lineWidth: 1)) } + + @ViewBuilder + func websiteView(url: URL?) -> some View { + CachedImage( + url: url, + shouldExpand: false, + fixedSize: size, + blurRadius: showNsfwFilter ? 8 : 0, + contentMode: .fill + ) + .onTapGesture { + if let url = post.post.linkUrl { + openURL(url) + markPostAsRead() + } + } + .overlay { + if showWebsiteIndicatorIcon { + Group { + WebsiteIndicatorView() + .frame(width: 20, height: 20) + .padding(6) + } + .frame(width: size.width, height: size.height, alignment: .topLeading) + } + } + } /// Synchronous void wrapper for postTracker.markRead to pass into CachedImage as dismiss callback func markPostAsRead() { diff --git a/Mlem/Views/Shared/Composer/BodyEditorView.swift b/Mlem/Views/Shared/Composer/BodyEditorView.swift new file mode 100644 index 000000000..535bfef60 --- /dev/null +++ b/Mlem/Views/Shared/Composer/BodyEditorView.swift @@ -0,0 +1,85 @@ +// +// BodyEditorView.swift +// Mlem +// +// Created by Sjmarf on 04/03/2024. +// + +import Dependencies +import Foundation +import SwiftUI +import SwiftUIIntrospect + +class BodyEditorModel: ObservableObject { + @Dependency(\.pictrsRepository) var pictrsRepository + + var uiTextView: UITextView? + var attachedFiles: [PictrsFile] = .init() + + func deleteUnusedFiles(text: String) async { + for file in attachedFiles where !text.contains(file.file) { + await deleteFile(file: file) + } + } + + func deleteAllFiles() async { + for file in attachedFiles { + await deleteFile(file: file) + } + } + + private func deleteFile(file: PictrsFile) async { + do { + try await pictrsRepository.deleteImage(file: file) + print("Deleted attachment \(file.file)") + } catch { + print("FAILED TO DELETE", error) + } + } +} + +struct BodyEditorView: View { + @Binding var text: String + let prompt: String + + @ObservedObject var bodyEditorModel: BodyEditorModel + @ObservedObject var attachmentModel: LinkAttachmentModel + + var body: some View { + TextField( + prompt, + text: $text, + axis: .vertical + ) + .disabled(attachmentModel.imageModel?.state != nil) + .opacity(attachmentModel.imageModel?.state == nil ? 1 : 0.5) + .introspect(.textField(axis: .vertical), on: .iOS(.v16, .v17)) { uiTextView in + bodyEditorModel.uiTextView = uiTextView + } + .onChange(of: attachmentModel.imageModel?.state) { newValue in + switch newValue { + case let .uploaded(file: file): + if let file { + let cursorPosition = cursorPosition + let index = text.index(text.startIndex, offsetBy: cursorPosition) + text = String(text[.. some View) -> some View { + ZStack { + Capsule() + .fill(color) + content() + } + .padding(-2) } } diff --git a/Mlem/Views/Shared/Composer/ProgressOverlayView.swift b/Mlem/Views/Shared/Composer/ProgressOverlayView.swift new file mode 100644 index 000000000..e28a401a5 --- /dev/null +++ b/Mlem/Views/Shared/Composer/ProgressOverlayView.swift @@ -0,0 +1,32 @@ +// +// ProgressOverlayView.swift +// Mlem +// +// Created by Sjmarf on 13/03/2024. +// + +import Foundation +import SwiftUI + +struct ProgressOverlayView: ViewModifier { + @Binding var isPresented: Bool + func body(content: Content) -> some View { + content.overlay { + if isPresented { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray.opacity(0.3)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Submitting") + .edgesIgnoringSafeArea(.all) + .allowsHitTesting(false) + } + } + } +} + +extension View { + func progressOverlay(isPresented: Binding) -> some View { + modifier(ProgressOverlayView(isPresented: isPresented)) + } +} diff --git a/Mlem/Views/Shared/Composer/ResponseEditorView.swift b/Mlem/Views/Shared/Composer/ResponseEditorView.swift index 1842ee8c0..5502311f2 100644 --- a/Mlem/Views/Shared/Composer/ResponseEditorView.swift +++ b/Mlem/Views/Shared/Composer/ResponseEditorView.swift @@ -23,7 +23,7 @@ struct ResponseEditorView: View { self.editorModel = concreteEditorModel.editorModel // don't need the wrapper self._editorBody = State(initialValue: concreteEditorModel.editorModel.prefillContents ?? "") } - + @Environment(\.dismiss) var dismiss @State var editorBody: String @@ -31,92 +31,127 @@ struct ResponseEditorView: View { @State var slurMatch: String? + @StateObject var bodyEditorModel: BodyEditorModel = .init() + @StateObject var attachmentModel: LinkAttachmentModel = .init(url: "") + @FocusState private var focusedField: Field? private var isReadyToReply: Bool { editorBody.trimmed.isNotEmpty } - + var body: some View { - ScrollView { - VStack(spacing: AppConstants.postAndCommentSpacing) { - // Post Text - TextField( - "What do you want to say?", - text: $editorBody, - axis: .vertical - ) - .lineLimit(AppConstants.textFieldVariableLineLimit) - .accessibilityLabel("Response Body") - .padding(AppConstants.postAndCommentSpacing) - .focused($focusedField, equals: .editorBody) - .onAppear { - focusedField = .editorBody + NavigationStack(path: .constant(.init())) { + ScrollView { + VStack(spacing: AppConstants.standardSpacing) { + // Post Text + BodyEditorView( + text: $editorBody, + prompt: "What do you want to say?", + bodyEditorModel: bodyEditorModel, + attachmentModel: attachmentModel + ) + .linkAttachmentModel(model: attachmentModel) + .lineLimit(AppConstants.textFieldVariableLineLimit) + .accessibilityLabel("Response Body") + .padding(AppConstants.standardSpacing) + .focused($focusedField, equals: .editorBody) + .onAppear { + focusedField = .editorBody + } + .onChange(of: editorBody) { newValue in + if editorModel.showSlurWarning { + slurMatch = siteInformation.instance?.firstSlurFilterMatch(newValue) + } + } + + VStack(spacing: 0) { + Divider() + infoView + } } - .onChange(of: editorBody) { newValue in - if editorModel.showSlurWarning { - slurMatch = siteInformation.instance?.firstSlurFilterMatch(newValue) + .animation(.default, value: attachmentModel.imageModel?.state) + .animation(.default, value: slurMatch) + .padding(.bottom, AppConstants.editorOverscroll) + } + .scrollDismissesKeyboard(.automatic) + .progressOverlay(isPresented: $isSubmitting) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel", role: .destructive) { + Task(priority: .background) { + await bodyEditorModel.deleteAllFiles() + } + dismiss() } + .tint(.red) } - Divider() - - if let slurMatch { - VStack { - Text("\"\(slurMatch)\" is disallowed.") - .foregroundStyle(.white) - Text("You can still post this comment, but your instance will replace \"\(slurMatch)\" with \"*removed*\".") - .multilineTextAlignment(.center) - .font(.footnote) - .foregroundStyle(.white.opacity(0.8)) + ToolbarItemGroup(placement: .navigationBarTrailing) { + LinkUploadOptionsView(model: attachmentModel) { + Label("Attach Image or Link", systemImage: Icons.attachment) } - .padding() - .frame(maxWidth: .infinity) - .background(RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius).fill(.red)) - .padding(.horizontal, 10) - } else { - editorModel.embeddedView() + // Submit Button + Button { + Task(priority: .userInitiated) { + await submit() + } + Task(priority: .background) { + await bodyEditorModel.deleteUnusedFiles(text: editorBody) + } + } label: { + Image(systemName: Icons.send) + }.disabled(isSubmitting || !isReadyToReply) } } - .animation(.default, value: slurMatch) - .padding(.bottom, AppConstants.editorOverscroll) + .navigationBarColor() + .navigationTitle(editorModel.modalName) + .navigationBarTitleDisplayMode(.inline) } - .scrollDismissesKeyboard(.automatic) - .overlay { - // Loading Indicator - if isSubmitting { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray.opacity(0.3)) - .accessibilityElement(children: .ignore) - .accessibilityLabel("Submitting Resposne") - .edgesIgnoringSafeArea(.all) - .allowsHitTesting(false) - } - } - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel", role: .destructive) { - dismiss() + .interactiveDismissDisabled(isReadyToReply) + .presentationDragIndicator(.hidden) + } + + @ViewBuilder + var infoView: some View { + switch attachmentModel.imageModel?.state { + case let .uploading(progress): + if progress == 1 { + HStack(spacing: 20) { + Text("Processing...") + ProgressView() + } + } else { + VStack { + Text("Uploading...") + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle()) + .frame(width: 80, height: 10) } - .tint(.red) } - - ToolbarItem(placement: .navigationBarTrailing) { - // Submit Button - Button { - Task(priority: .userInitiated) { - await submit() - } - } label: { - Image(systemName: Icons.send) - }.disabled(isSubmitting || !isReadyToReply) + case let .failed(string): + VStack { + Text("Failed to upload") + .foregroundStyle(.red) + } + default: + if let slurMatch { + VStack { + Text("\"\(slurMatch)\" is disallowed.") + .foregroundStyle(.white) + Text("You can still post this comment, but your instance will replace \"\(slurMatch)\" with \"*removed*\".") + .multilineTextAlignment(.center) + .font(.footnote) + .foregroundStyle(.white.opacity(0.8)) + } + .padding() + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius).fill(.red)) + .padding(.horizontal, 10) + } else { + editorModel.embeddedView() } } - .navigationBarColor() - .navigationTitle(editorModel.modalName) - .navigationBarTitleDisplayMode(.inline) - .interactiveDismissDisabled(isReadyToReply) } @MainActor diff --git a/Mlem/Views/Shared/Instance/Fediseer.swift b/Mlem/Views/Shared/Instance/Fediseer.swift new file mode 100644 index 000000000..5ef35d552 --- /dev/null +++ b/Mlem/Views/Shared/Instance/Fediseer.swift @@ -0,0 +1,151 @@ +// +// Fediseer.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import Foundation +import SwiftUI + +// https://fediseer.com/api/v1/whitelist/lemmy.world + +struct FediseerData: Hashable, Equatable { + var instance: FediseerInstance + var endorsements: [FediseerEndorsement]? + var hesitations: [FediseerHesitation]? + var censures: [FediseerCensure]? + + var topEndorsements: [FediseerEndorsement] { + if var endorsements { + endorsements = endorsements.sorted { $0.reason != nil && $1.reason == nil } + return endorsements + } + return [] + } + + func opinions(ofType type: FediseerOpinionType) -> [any FediseerOpinion] { + switch type { + case .endorsement: + endorsements ?? [] + case .hesitation: + hesitations ?? [] + case .censure: + censures ?? [] + } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(instance.domain) + } +} + +struct FediseerInstance: Codable, Equatable { + let id: Int + let domain: String + // let software: String + let claimed: Int + let approvals: Int // This is the number of endorsements given + let endorsements: Int // This is the number of endorsements received + let guarantor: String? + + // Fediseer lets instances admins self-report these values + let sysadmins: Int? + let moderators: Int? +} + +struct FediseerEndorsements: Codable { + var instances: [FediseerEndorsement] = .init() +} + +struct FediseerHesitations: Codable { + var instances: [FediseerHesitation] = .init() +} + +struct FediseerCensures: Codable { + var instances: [FediseerCensure] = .init() +} + +enum FediseerOpinionType: CaseIterable, Identifiable { + case endorsement, hesitation, censure + + var id: FediseerOpinionType { self } + + var label: String { + switch self { + case .endorsement: + "Endorsements" + case .hesitation: + "Hesitations" + case .censure: + "Censures" + } + } +} + +protocol FediseerOpinion { + var domain: String { get } + var reason: String? { get } + var evidence: String? { get } + + static var systemImage: String { get } + static var color: Color { get } +} + +extension FediseerOpinion { + var instanceModel: InstanceModel? { + do { + return try .init(domainName: domain) + } catch { + return nil + } + } + + var formattedReason: String? { + if let reason { + return "- \(reason.split(separator: ",").joined(separator: "\n- "))" + } + return nil + } +} + +struct FediseerEndorsement: Codable { + let domain: String + let endorsementReasons: [String]? +} + +extension FediseerEndorsement: FediseerOpinion, Equatable { + static var systemImage: String = Icons.fediseerEndorsement + static var color: Color = .teal + + var reason: String? { endorsementReasons?.first } + var evidence: String? { nil } +} + +struct FediseerHesitation: Codable { + let domain: String + let hesitationReasons: [String]? + let hesitationEvidence: [String]? +} + +extension FediseerHesitation: FediseerOpinion, Equatable { + static var systemImage: String = Icons.fediseerHesitation + static var color: Color = .orange + + var reason: String? { hesitationReasons?.first } + var evidence: String? { hesitationEvidence?.first } +} + +struct FediseerCensure: Codable { + let domain: String + let censureReasons: [String]? + let censureEvidence: [String]? +} + +extension FediseerCensure: FediseerOpinion, Equatable { + static var systemImage: String = Icons.fediseerCensure + static var color: Color = .red + + var reason: String? { censureReasons?.first } + var evidence: String? { censureEvidence?.first } +} diff --git a/Mlem/Views/Shared/Instance/FediseerInfoView.swift b/Mlem/Views/Shared/Instance/FediseerInfoView.swift new file mode 100644 index 000000000..ec92f1fcd --- /dev/null +++ b/Mlem/Views/Shared/Instance/FediseerInfoView.swift @@ -0,0 +1,75 @@ +// +// FediseerInfoView.swift +// Mlem +// +// Created by Sjmarf on 04/02/2024. +// + +import SwiftUI + +// swiftlint:disable line_length +struct FediseerInfoView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading) { + subHeading("The Fediseer", systemImage: Icons.fediseer, color: .indigo) + Text("The Fediseer is a service that instance administrators use to identify spam instances and express their approval or disapproval of other instances.") + .padding(.horizontal, AppConstants.postAndCommentSpacing) + subHeading("Guarantees", systemImage: Icons.fediseerGuarantee, color: .green) + Text("If an instance is \"guaranteed\", it is known as definitely not spam. Unguaranteed instances are not necessarily spam; rather, it is unknown whether a non-guaranteed instance is spam or not.\n\nAn instance can be guaranteed by any other guaranteed instance. This forms a chain of guaranteed instances known as the \"Chain of Trust\". The Chain of Trust starts at the Fediseer itself, which guarantees several of the largest instances.\n\nA guarantee can be revoked by the guarantor at any time. If an instance's guarantee is revoked, it returns to a \"not guaranteed\" state along with any instances it guarantees.\n\nOnce an instance has been guaranteed, it is able to express its approval or disapproval of other instances using endorsements, hesitations and censures.") + .padding(.horizontal, AppConstants.postAndCommentSpacing) + subHeading("Endorsements", systemImage: Icons.fediseerEndorsement, color: .teal) + Text("An endorsement signifies that an instance approves of another instance. It is completely subjective, and a reason does not have to be given.") + .padding(.horizontal, AppConstants.postAndCommentSpacing) + subHeading("Censures", systemImage: Icons.fediseerCensure, color: .red) + Text("A censure signifies that an instance disapproves of another instance. Like an endorsement, it is completely subjective and a reason does not have to be given.") + .padding(.horizontal, AppConstants.postAndCommentSpacing) + subHeading("Hesitations", systemImage: Icons.fediseerHesitation, color: .yellow) + Text("A hesitation signifies that an instance mistrusts another instance. It is a milder version of a censure.") + .padding(.horizontal, AppConstants.postAndCommentSpacing) + Divider() + .padding(.top, 20) + linkButton( + "Fediseer FAQ", + systemImage: "questionmark.circle.fill", + destination: URL(string: "https://fediseer.com/faq/eng")! + ) + .padding(.bottom, 50) + } + .frame(maxWidth: .infinity) + } + } + + @ViewBuilder + func subHeading(_ title: String, systemImage: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 5) { + HStack { + Image(systemName: systemImage) + .foregroundStyle(color) + Text(title) + .fontWeight(.semibold) + } + .font(.title2) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + Divider() + } + .padding(.top, 20) + } + + @ViewBuilder + func linkButton(_ title: String, systemImage: String, destination: URL) -> some View { + Link(destination: destination) { + Label(title, systemImage: systemImage) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: AppConstants.smallItemCornerRadius) + .fill(Color(uiColor: .secondarySystemFill)) + ) + } + .buttonStyle(.plain) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + } +} + +// swiftlint:enable line_length diff --git a/Mlem/Views/Shared/Instance/FediseerOpinionListView.swift b/Mlem/Views/Shared/Instance/FediseerOpinionListView.swift new file mode 100644 index 000000000..4f9996223 --- /dev/null +++ b/Mlem/Views/Shared/Instance/FediseerOpinionListView.swift @@ -0,0 +1,60 @@ +// +// FediseerOpinionListView.swift +// Mlem +// +// Created by Sjmarf on 04/02/2024. +// + +import SwiftUI + +struct FediseerOpinionListView: View { + let instance: InstanceModel + let opinionType: FediseerOpinionType + let fediseerData: FediseerData + + @Environment(\.navigationPathWithRoutes) private var navigationPath + @Environment(\.scrollViewProxy) private var scrollViewProxy + + @Namespace var scrollToTop + @State private var scrollToTopAppeared = false + + var body: some View { + ScrollView { + VStack(spacing: 16) { + ScrollToView(appeared: $scrollToTopAppeared) + .id(scrollToTop) + + let items = fediseerData.opinions(ofType: opinionType).sorted { + $0.reason != nil && $1.reason == nil + } + + ForEach(items, id: \.domain) { opinion in + FediseerOpinionView(opinion: opinion) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + } + } + .padding(16) + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle(opinionType.label) + .fancyTabScrollCompatible() + .hoistNavigation { + if navigationPath.isEmpty { + withAnimation { + scrollViewProxy?.scrollTo(scrollToTop) + } + return true + } else { + if scrollToTopAppeared { + return false + } else { + withAnimation { + scrollViewProxy?.scrollTo(scrollToTop) + } + return true + } + } + } + } +} diff --git a/Mlem/Views/Shared/Instance/FediseerOpinionView.swift b/Mlem/Views/Shared/Instance/FediseerOpinionView.swift new file mode 100644 index 000000000..311ed35e5 --- /dev/null +++ b/Mlem/Views/Shared/Instance/FediseerOpinionView.swift @@ -0,0 +1,63 @@ +// +// EndorsementView.swift +// Mlem +// +// Created by Sam Marfleet on 03/02/2024. +// + +import SwiftUI + +struct FediseerOpinionView: View { + let opinion: any FediseerOpinion + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + if let model = opinion.instanceModel { + NavigationLink(value: AppRoute.instance(model)) { title } + .buttonStyle(.plain) + } else { + title + } + Spacer() + } + .foregroundStyle(type(of: opinion).color) + .padding(.horizontal) + divider + if let reason = opinion.formattedReason { + MarkdownView(text: reason, isNsfw: false) + .padding(.trailing) + } else { + Text("No reason given") + .foregroundStyle(.secondary) + .italic() + .padding(.leading) + } + if let evidence = opinion.evidence { + divider + MarkdownView(text: evidence, isNsfw: false) + .padding(.horizontal) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .font(.callout) + } + + @ViewBuilder + var title: some View { + Image(systemName: type(of: opinion).systemImage) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + Text(opinion.domain) + .fontWeight(.semibold) + } + + @ViewBuilder + var divider: some View { + Line() + .stroke(style: StrokeStyle(lineWidth: 2, dash: [5])) + .frame(height: 2) + .foregroundStyle(Color(uiColor: .systemGroupedBackground)) + } +} diff --git a/Mlem/Views/Shared/Instance/InstanceDetailsView.swift b/Mlem/Views/Shared/Instance/InstanceDetailsView.swift index 6f7644953..68c8ab1f1 100644 --- a/Mlem/Views/Shared/Instance/InstanceDetailsView.swift +++ b/Mlem/Views/Shared/Instance/InstanceDetailsView.swift @@ -29,7 +29,7 @@ struct InstanceDetailsView: View { box { Text("Users") .foregroundStyle(.secondary) - Text("\(abbreviateNumber(instance.userCount ?? 0))") + Text("\((instance.userCount ?? 0).abbreviated)") .font(.title) .fontWeight(.semibold) } @@ -37,7 +37,7 @@ struct InstanceDetailsView: View { box { Text("Communities") .foregroundStyle(.secondary) - Text("\(abbreviateNumber(instance.communityCount ?? 0))") + Text("\((instance.communityCount ?? 0).abbreviated)") .font(.title) .fontWeight(.semibold) .foregroundStyle(.green) @@ -49,7 +49,7 @@ struct InstanceDetailsView: View { box { Text("Posts") .foregroundStyle(.secondary) - Text("\(abbreviateNumber(instance.postCount ?? 0))") + Text("\((instance.postCount ?? 0).abbreviated)") .font(.title) .fontWeight(.semibold) .foregroundStyle(.pink) @@ -58,7 +58,7 @@ struct InstanceDetailsView: View { box { Text("Comments") .foregroundStyle(.secondary) - Text("\(abbreviateNumber(instance.commentCount ?? 0))") + Text("\((instance.commentCount ?? 0).abbreviated)") .font(.title) .fontWeight(.semibold) .foregroundStyle(.orange) @@ -262,7 +262,7 @@ struct InstanceDetailsView: View { @ViewBuilder func activeUserBox(_ label: String, value: Int) -> some View { VStack { - Text(abbreviateNumber(value)) + Text(value.abbreviated) .font(.title3) .fontWeight(.semibold) Text(label) diff --git a/Mlem/Views/Shared/Instance/InstanceSafetyView.swift b/Mlem/Views/Shared/Instance/InstanceSafetyView.swift new file mode 100644 index 000000000..3c0531ad2 --- /dev/null +++ b/Mlem/Views/Shared/Instance/InstanceSafetyView.swift @@ -0,0 +1,153 @@ +// +// InstanceSafetyView.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import SwiftUI + +struct InstanceSafetyView: View { + @Environment(\.colorScheme) var colorScheme + + let instance: InstanceModel + let fediseerData: FediseerData + + @State var showingInfoSheet: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + section { guarantorView } + .padding(.top, 16) + + HStack { + Button("Learn more...") { showingInfoSheet = true } + .buttonStyle(.plain) + Spacer() + if let url = URL(string: "https://gui.fediseer.com/instances/detail/\(instance.name)") { + Link(destination: url) { + Text("Fediseer GUI") + Image(systemName: "arrow.up.forward") + } + } + } + .font(.footnote) + .foregroundStyle(.blue) + .padding(.horizontal, 6) + .padding(.top, 7) + .padding(.bottom, 30) + + opinionsView + if colorScheme == .light { + Divider() + .padding(.top, 16) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .background(Color(uiColor: .systemGroupedBackground)) + .sheet(isPresented: $showingInfoSheet) { + NavigationStack { + FediseerInfoView() + .toolbar { + CloseButtonView() + } + } + } + } + + @ViewBuilder + var guarantorView: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + if fediseerData.instance.guarantor != nil { + Label("Guaranteed", systemImage: Icons.fediseerGuarantee) + .foregroundStyle(.green) + } else if fediseerData.censures?.isEmpty ?? true { + Label("Not Guaranteed", systemImage: Icons.fediseerUnguarantee) + .foregroundStyle(.secondary) + } else { + Label("Censured", systemImage: Icons.fediseerCensure) + .foregroundStyle(.red) + } + Spacer() + } + .fontWeight(.semibold) + .font(.title2) + Text(summaryCaption) + .foregroundColor(.secondary) + .font(.footnote) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .padding(.horizontal) + } + + var summaryCaption: String { + if let guarantor = fediseerData.instance.guarantor { + return "\(instance.name) is guaranteed by \(guarantor)." + } else if fediseerData.censures?.isEmpty ?? true { + return "This instance is not part of the Fediseer Chain of Trust." + } else { + return "This instance is viewed very negatively by one or more trusted instances." + } + } + + @ViewBuilder + var opinionsView: some View { + VStack(spacing: 22) { + let opinionTypes = FediseerOpinionType.allCases.sorted { + fediseerData.opinions(ofType: $0).count > fediseerData.opinions(ofType: $1).count + } + ForEach(opinionTypes, id: \.self) { opinionType in + let items = fediseerData.opinions(ofType: opinionType).sorted { + $0.reason != nil && $1.reason == nil + } + + if !items.isEmpty { + VStack(alignment: .leading, spacing: 7) { + let route: AppRoute = .instanceFediseerOpinionList(instance, data: fediseerData, type: opinionType) + opinionSubheading( + title: opinionType.label, + count: items.count, + route: items.count > 5 ? route : nil + ) + ForEach(items.prefix(5), id: \.domain) { item in + section { FediseerOpinionView(opinion: item) } + .padding(.bottom, 9) + } + } + } + } + } + } + + @ViewBuilder func section(spacing: CGFloat = 5, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: spacing) { + content() + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + } + } + + @ViewBuilder + func opinionSubheading(title: String, count: Int, route: AppRoute? = nil) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + (Text(title) + Text(" (\(count))").foregroundColor(.secondary)) + .font(.title2) + .fontWeight(.semibold) + Spacer() + if let route { + NavigationLink("See All", value: route) + .foregroundStyle(.blue) + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 6) + } +} diff --git a/Mlem/Views/Shared/Instance/InstanceUptimeView.swift b/Mlem/Views/Shared/Instance/InstanceUptimeView.swift new file mode 100644 index 000000000..42c1f0bef --- /dev/null +++ b/Mlem/Views/Shared/Instance/InstanceUptimeView.swift @@ -0,0 +1,263 @@ +// +// InstanceUptimeView.swift +// Mlem +// +// Created by Sjmarf on 28/01/2024. +// + +import Charts +import Dependencies +import Foundation +import SwiftUI + +struct InstanceUptimeView: View { + @Dependency(\.errorHandler) var errorHandler + + @Environment(\.accessibilityDifferentiateWithoutColor) var diffWithoutColor: Bool + @Environment(\.colorScheme) var colorScheme + + @State var showingExactTime: Bool = false + @State var showingAllDowntimes: Bool = false + + let instance: InstanceModel + let uptimeData: UptimeData + + @ViewBuilder + var body: some View { + VStack(alignment: .leading, spacing: 0) { + section { summary } + .padding(.top, 16) + .padding(.bottom, 10) + section("Recent Checks") { + recentChecks + .padding(.horizontal) + .padding(.vertical, 15) + } + .padding(.top, 20) + footnote("Mlem will refresh this automatically every 30 seconds.") + .padding(.top, 8) + .padding(.leading, 6) + .padding(.bottom, 30) + section("Response Time") { + VStack(alignment: .leading, spacing: 4) { + responseTimeChart + .padding(.horizontal, 20) + footnote("Average: \(uptimeData.results.map(\.durationMs).reduce(0, +) / uptimeData.results.count)ms") + .padding(.leading, 20) + } + .padding(.top, 17) + .padding(.bottom, 8) + } + subHeading("Incidents") + .padding(.top, 30) + .padding(.bottom, 3) + let todayDowntimes = uptimeData.downtimes.filter { abs($0.endTime.timeIntervalSinceNow) < 60 * 60 * 24 } + + Text( + todayDowntimes.count == 0 + ? "There were no recorded incidents today." + : "There ^[were \(todayDowntimes.count)](inflect: true) recorded incidents today." + ) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.leading, 6) + .padding(.bottom, 7) + + let displayedIncidents = showingAllDowntimes ? uptimeData.downtimes : todayDowntimes + if !displayedIncidents.isEmpty { + section(spacing: 0) { + ForEach(displayedIncidents) { event in + if event.id != uptimeData.downtimes.first?.id { + Divider() + } + IncidentRow(event: event, showingExactTime: showingExactTime) + .padding(.vertical, 10) + .padding(.leading) + } + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + showingExactTime.toggle() + } + } + } + .padding(.bottom, 30) + } + Button { + withAnimation { + showingAllDowntimes.toggle() + } + } label: { + Text("\(showingAllDowntimes ? "Hide" : "Show") Older Incidents") + .foregroundStyle(.blue) + .padding(.leading, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: AppConstants.largeItemCornerRadius) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + } + .buttonStyle(EmptyButtonStyle()) + + if let url = instance.uptimeFrontendUrl { + Text("Uptime data fetched from [lemmy-status.org](\(url))") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.vertical, 8) + .padding(.leading, 6) + } + if colorScheme == .light { + Divider() + } + } + .padding(.horizontal, 16) + .background(Color(uiColor: .systemGroupedBackground)) + } + + @ViewBuilder + var summary: some View { + VStack(alignment: .leading) { + if let mostRecentOutage = uptimeData.downtimes.first { + if uptimeData.results.filter(\.success).count < 15 { + summaryHeader(statusText: "unhealthy", systemImage: Icons.uptimeOutage, color: .red) + footnote("\(instance.name) has been unresponsive recently.") + } else { + summaryHeader(statusText: "online", systemImage: Icons.uptimeOnline, color: .green) + let relTime = mostRecentOutage.relativeTimeCaption + let length = mostRecentOutage.differenceTitle(unitsStyle: .full) + footnote("The most recent outage was \(relTime), and lasted for \(length).") + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + .padding(.horizontal) + } + + @ViewBuilder + func summaryHeader(statusText: String, systemImage: String, color: Color) -> some View { + HStack(spacing: 5) { + (Text("\(instance.name) is ") + Text(statusText).foregroundColor(color)) + .font(.title2) + .fontWeight(.semibold) + Image(systemName: systemImage) + .foregroundStyle(color) + } + } + + @ViewBuilder + var recentChecks: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 3) { + ForEach(uptimeData.results) { result in + if diffWithoutColor { + Image(systemName: result.success ? Icons.uptimeOnline : Icons.uptimeOffline) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(result.success ? .green : .red) + .frame(maxWidth: 20) + .frame(maxWidth: 25) + } else { + Circle() + .fill(result.success ? .green : .red) + .frame(maxWidth: 20) + .frame(maxWidth: 25) + } + } + } + HStack { + footnote(timeOnlyFormatter.string(from: uptimeData.results.first?.timestamp ?? .now)) + Spacer() + footnote(timeOnlyFormatter.string(from: uptimeData.results.last?.timestamp ?? .now)) + } + .frame(maxWidth: CGFloat(uptimeData.results.count * 25 + (uptimeData.results.count - 1) * 3)) + .padding(.top, 4) + } + } + + @ViewBuilder + var responseTimeChart: some View { + Chart { + ForEach(uptimeData.results) { node in + let time = Int(node.durationMs) + LineMark( + x: .value("Time", node.timestamp), + y: .value("Response Time", time) + ) + } + } + .frame(height: 200) + .chartXAxis { + let marks = [uptimeData.results.first?.timestamp ?? .distantPast, uptimeData.results.last?.timestamp ?? .distantFuture] + AxisMarks(format: .dateTime.hour(.defaultDigits(amPM: .abbreviated)).minute(.twoDigits), values: marks) + } + .chartYScale(domain: [0, max(1000, (uptimeData.results.map(\.durationMs).max() ?? 0) + 100)]) + .chartYAxis { + AxisMarks(values: .automatic) { value in + AxisGridLine() + AxisValueLabel { + if let intValue = value.as(Int.self) { + Text("\(intValue)ms") + } + } + } + } + } + + @ViewBuilder func section(_ title: String? = nil, spacing: CGFloat = 5, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 7) { + if let title { + subHeading(title) + } + VStack(alignment: .leading, spacing: spacing) { + content() + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(AppConstants.largeItemCornerRadius) + } + } + + @ViewBuilder + func subHeading(_ title: String) -> some View { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .padding(.leading, 6) + } + + @ViewBuilder + func footnote(_ title: String) -> some View { + Text(title) + .font(.footnote) + .foregroundStyle(.secondary) + } + + var timeOnlyFormatter: DateFormatter { + let dateFormatter = DateFormatter() + dateFormatter.timeStyle = .short + dateFormatter.dateStyle = .none + return dateFormatter + } +} + +private struct IncidentRow: View { + let event: DowntimePeriod + let showingExactTime: Bool + + var body: some View { + VStack(alignment: .leading) { + HStack { + Image(systemName: Icons.uptimeOutage) + .foregroundStyle(event.severityColor) + .foregroundStyle(.secondary) + Text("Unhealthy for \(event.differenceTitle())") + } + Text(showingExactTime ? event.differenceCaption : event.relativeTimeCaption) + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } +} diff --git a/Mlem/Views/Shared/Instance/InstanceView+Logic.swift b/Mlem/Views/Shared/Instance/InstanceView+Logic.swift new file mode 100644 index 000000000..8c02c8ba3 --- /dev/null +++ b/Mlem/Views/Shared/Instance/InstanceView+Logic.swift @@ -0,0 +1,137 @@ +// +// InstanceView+Logic.swift +// Mlem +// +// Created by Sjmarf on 03/02/2024. +// + +import SwiftUI + +extension InstanceView { + // swiftlint:disable:next function_body_length + func attemptToLoadInstanceData() { + if instance.administrators == nil { + Task { + do { + if let url = URL(string: "https://\(instance.name)") { + let info = try await apiClient.loadSiteInformation(instanceURL: url) + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.2)) { + instance.update(with: info) + } + } + } else { + errorDetails = ErrorDetails(title: "\"\(instance.name)\" is an invalid URL.") + } + } catch let APIClientError.decoding(data, error) { + withAnimation(.easeOut(duration: 0.2)) { + if let content = String(data: data, encoding: .utf8) { + if content.contains("
") { + errorDetails = ErrorDetails( + title: "KBin Instance", + body: "KBin instances are not currently supported.", + icon: Icons.federation + ) + } else if content.contains("- Mastodon") { + errorDetails = ErrorDetails( + title: "Mastodon Instance", + body: "Mastodon instances are not currently supported.", + icon: Icons.federation + ) + } else { + errorDetails = ErrorDetails(error: APIClientError.decoding(data, error)) + } + } else { + errorDetails = ErrorDetails(error: APIClientError.decoding(data, error)) + } + } + } catch let APIClientError.networking(error) { + if let urlError = error as? URLError { + if urlError.code.rawValue == -1202 { + errorDetails = ErrorDetails( + title: "Cannot reach instance", + body: "Access to this instance may be disallowed by your network.", + error: error + ) + return + } + } + errorDetails = ErrorDetails(error: APIClientError.networking(error)) + } catch { + withAnimation(.easeOut(duration: 0.2)) { + errorDetails = ErrorDetails(error: error) + } + } + } + } + } + + func attemptToLoadUptimeData() { + print("Fetching uptime data...") + if let url = instance.uptimeDataUrl { + Task { + do { + let data = try await URLSession.shared.data(from: url).0 + let uptimeData = try JSONDecoder.defaultDecoder.decode(UptimeData.self, from: data) + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.2)) { + self.uptimeData = .success(uptimeData) + } + } + } catch { + errorHandler.handle(error) + } + } + } + } + + func attemptToLoadFediseerData() { + if fediseerData == nil { + Task { + do { + guard let instanceURL = URL(string: "https://fediseer.com/api/v1/whitelist/\(instance.name)") else { return } + async let instanceData = try await URLSession.shared.data(from: instanceURL).0 + + async let endorsementsData = try await URLSession.shared.data( + from: URL(string: "https://fediseer.com/api/v1/endorsements/\(instance.name)")! + ).0 + + async let hesitationsData = try await URLSession.shared.data( + from: URL(string: "https://fediseer.com/api/v1/hesitations/\(instance.name)")! + ).0 + + async let censuresData = try await URLSession.shared.data( + from: URL(string: "https://fediseer.com/api/v1/censures/\(instance.name)")! + ).0 + + let fediseerData = await try FediseerData( + instance: JSONDecoder.defaultDecoder.decode( + FediseerInstance.self, + from: instanceData + ), + endorsements: JSONDecoder.defaultDecoder.decode( + FediseerEndorsements.self, + from: endorsementsData + ).instances, + hesitations: JSONDecoder.defaultDecoder.decode( + FediseerHesitations.self, + from: hesitationsData + ).instances, + censures: JSONDecoder.defaultDecoder.decode( + FediseerCensures.self, + from: censuresData + ).instances + ) + + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.2)) { + self.fediseerData = fediseerData + } + } + } catch { + errorHandler.handle(error) + } + } + } + } +} diff --git a/Mlem/Views/Shared/Instance/InstanceView.swift b/Mlem/Views/Shared/Instance/InstanceView.swift index 10b59e930..79f490990 100644 --- a/Mlem/Views/Shared/Instance/InstanceView.swift +++ b/Mlem/Views/Shared/Instance/InstanceView.swift @@ -7,10 +7,11 @@ import Charts import Dependencies +import Foundation import SwiftUI enum InstanceViewTab: String, Identifiable, CaseIterable { - case about, administrators, details, uptime, safety + case about, administration, details, uptime, safety var id: Self { self } @@ -34,98 +35,103 @@ struct InstanceView: View { @Environment(\.navigationPathWithRoutes) private var navigationPath @Environment(\.scrollViewProxy) private var scrollViewProxy - @State var domainName: String - @State var instance: InstanceModel? + enum UptimeDataStatus { + case success(UptimeData) + case failure(Error) + } + + @State var instance: InstanceModel + @State var uptimeData: UptimeDataStatus? + @State var fediseerData: FediseerData? @State var errorDetails: ErrorDetails? @Namespace var scrollToTop @State private var scrollToTopAppeared = false + @State private var menuFunctionPopup: MenuFunctionPopup? + @State var selectedTab: InstanceViewTab = .about - init(domainName: String? = nil, instance: InstanceModel? = nil) { - _domainName = State(wrappedValue: domainName ?? instance?.name ?? "") + var uptimeRefreshTimer = Timer.publish(every: 30, tolerance: 0.5, on: .main, in: .common) + .autoconnect() + + init(instance: InstanceModel) { var instance = instance - if domainName == siteInformation.instance?.url.host() { + @Dependency(\.siteInformation) var siteInformation + if instance.name == siteInformation.instance?.url.host() { instance = siteInformation.instance ?? instance } _instance = State(wrappedValue: instance) } var subtitleText: String { - if let version = instance?.version { - "\(domainName) • \(String(describing: version))" + if let version = instance.version { + "\(instance.name) • \(String(describing: version))" } else { - domainName + instance.name + } + } + + var availableTabs: [InstanceViewTab] { + var tabs: [InstanceViewTab] = [.about, .administration, .details] + if instance.canFetchUptime { + tabs.append(.uptime) } + tabs.append(.safety) + return tabs } var body: some View { ScrollView { ScrollToView(appeared: $scrollToTopAppeared) .id(scrollToTop) - VStack(spacing: AppConstants.postAndCommentSpacing) { - AvatarBannerView(instance: instance) - .padding(.horizontal, AppConstants.postAndCommentSpacing) - .padding(.top, 10) - VStack(spacing: 5) { - if errorDetails == nil { - if let instance { - Text(instance.displayName) - .font(.title) - .fontWeight(.semibold) - .lineLimit(1) - .minimumScaleFactor(0.01) - - Text(subtitleText) - .font(.footnote) - .foregroundStyle(.secondary) - } + VStack(spacing: AppConstants.standardSpacing) { + headerView + .padding(.bottom, AppConstants.halfSpacing) + + VStack(spacing: 0) { + BubblePicker( + availableTabs, + selected: $selectedTab, + withDividers: [.top, .bottom], + label: \.label + ) + + if let errorDetails, [.about, .administration, .details].contains(selectedTab) { + ErrorView(errorDetails) } else { - Text(domainName) - .font(.title) - .fontWeight(.semibold) - .lineLimit(1) - .minimumScaleFactor(0.01) - .padding(.bottom, 5) - Divider() - } - } - .padding(.bottom, 5) - if let errorDetails { - ErrorView(errorDetails) - } else if let instance, instance.creationDate != nil { - VStack(spacing: 0) { - VStack(spacing: 4) { - Divider() - BubblePicker([.about, .administrators, .details], selected: $selectedTab) { tab in - Text(tab.label) - } - Divider() - } switch selectedTab { case .about: if let description = instance.description { MarkdownView(text: description, isNsfw: false) .padding(.horizontal, AppConstants.postAndCommentSpacing) .padding(.top) - } else { + } else if instance.administrators != nil { Text("No Description") .foregroundStyle(.secondary) .padding(.top) - } - case .administrators: - if let administrators = instance.administrators { - ForEach(administrators, id: \.self) { user in - UserResultView(user, complications: [.date]) - Divider() - } } else { ProgressView() - .padding(.top) + .padding(.top, 30) + } + case .administration: + VStack(spacing: 0) { + ModlogNavigationLinkView(to: instance) + + Divider() + + if let administrators = instance.administrators { + ForEach(administrators, id: \.self) { user in + UserListRow(user, complications: [.date]) + Divider() + } + } else { + ProgressView() + .padding(.top, 30) + } } case .details: - if instance.userCount != nil { + if instance.administrators != nil { VStack(spacing: 0) { InstanceDetailsView(instance: instance) .padding(.vertical, 16) @@ -136,67 +142,53 @@ struct InstanceView: View { } } else { ProgressView() - .padding(.top) + .padding(.top, 30) } - default: - EmptyView() + case .uptime: + VStack { + switch uptimeData { + case let .success(uptimeData): + InstanceUptimeView(instance: instance, uptimeData: uptimeData) + case let .failure(error): + ErrorView(.init(error: error)) + .padding(.top, 5) + default: + ProgressView() + .padding(.top, 30) + } + } + .onAppear(perform: attemptToLoadUptimeData) + .onReceive(uptimeRefreshTimer) { _ in attemptToLoadUptimeData() } + case .safety: + Group { + if let fediseerData { + InstanceSafetyView(instance: instance, fediseerData: fediseerData) + } else { + ProgressView() + .padding(.top, 30) + } + } + .onAppear(perform: attemptToLoadFediseerData) } Spacer() .frame(height: 100) } - - } else { - LoadingView(whatIsLoading: .instanceDetails) } } } .toolbar { - if let instance { - ToolbarItem(placement: .topBarTrailing) { - Link(destination: instance.url) { - Label("Open in Browser", systemImage: Icons.browser) - } - } - } - } - .task { - if instance?.administrators == nil { - do { - if let url = URL(string: "https://\(domainName)") { - let info = try await apiClient.loadSiteInformation(instanceURL: url) - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.2)) { - if var instance { - instance.update(with: info) - self.instance = instance - } else { - instance = InstanceModel(from: info) - } - } - } - } else { - errorDetails = ErrorDetails(title: "\"\(domainName)\" is an invalid URL.") - } - } catch let APIClientError.decoding(data, error) { - withAnimation(.easeOut(duration: 0.2)) { - if let content = String(data: data, encoding: .utf8), - content.contains("
") { - errorDetails = ErrorDetails( - title: "KBin Instance", - body: "We can't yet display KBin details.", - icon: Icons.federation - ) - } else { - errorDetails = ErrorDetails(error: APIClientError.decoding(data, error)) - } - } - } catch { - withAnimation(.easeOut(duration: 0.2)) { - errorDetails = ErrorDetails(error: error) + ToolbarItem(placement: .topBarTrailing) { + ToolbarEllipsisMenu { + ForEach(instance.menuFunctions { new in + instance = new + }) { item in + MenuButton(menuFunction: item, menuFunctionPopup: $menuFunctionPopup) } } } } + .destructiveConfirmation(menuFunctionPopup: $menuFunctionPopup) + .onAppear(perform: attemptToLoadInstanceData) .fancyTabScrollCompatible() .hoistNavigation { if navigationPath.isEmpty { @@ -216,7 +208,35 @@ struct InstanceView: View { } } .navigationBarColor() - .navigationTitle(instance?.displayName ?? domainName) + .navigationTitle(instance.displayName ?? instance.name) .navigationBarTitleDisplayMode(.inline) + .onChange(of: errorDetails == nil) { _ in + attemptToLoadUptimeData() + } + } + + @ViewBuilder + var headerView: some View { + AvatarBannerView(instance: instance) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.top, 10) + VStack(spacing: 5) { + Text(instance.displayName) + .font(.title) + .fontWeight(.semibold) + .lineLimit(1) + .minimumScaleFactor(0.01) + .transition(.opacity) + .id("Title" + instance.displayName) // https://stackoverflow.com/a/60136737/17629371 + Text(subtitleText) + .font(.footnote) + .foregroundStyle(.secondary) + .transition(.opacity) + .id("Subtitle" + subtitleText) + } } } + +#Preview { + InstanceView(instance: .mock()) +} diff --git a/Mlem/Views/Shared/Instance/UptimeData.swift b/Mlem/Views/Shared/Instance/UptimeData.swift new file mode 100644 index 000000000..e56c18eff --- /dev/null +++ b/Mlem/Views/Shared/Instance/UptimeData.swift @@ -0,0 +1,115 @@ +// +// UptimeData.swift +// Mlem +// +// Created by Sjmarf on 28/01/2024. +// + +import Foundation +import SwiftUI + +struct UptimeData: Codable { + let results: [UptimeResponseTime] + let events: [UptimeEvent] + + var downtimes: [DowntimePeriod] { + var ret: [DowntimePeriod] = [] + var previous: UptimeEvent? + for event in events { + if event.type == .healthy { + if let previous { + ret.append(.init(startTime: previous.timestamp, endTime: event.timestamp)) + } + } + previous = event + } + return ret.reversed() + } +} + +struct UptimeResponseTime: Codable, Identifiable { + let success: Bool + let duration: Int + let timestamp: Date + + var durationMs: Int { + duration / 1_000_000 + } + + var id: Int { Int(timestamp.timeIntervalSince1970) } +} + +struct UptimeEvent: Codable, Identifiable { + enum EventType: String, Codable { + case healthy = "HEALTHY" + case unhealthy = "UNHEALTHY" + } + + let type: EventType + let timestamp: Date + + var id: Int { Int(timestamp.timeIntervalSince1970) } +} + +struct DowntimePeriod: Codable, Identifiable { + let startTime: Date + let endTime: Date + + var id: Int { Int(startTime.timeIntervalSince1970) } + + var duration: TimeInterval { + endTime.timeIntervalSince(startTime) + } + + var severityColor: Color { + if duration < 60 * 5 { + .secondary + } else if duration < 60 * 30 { + .orange + } else { + .red + } + } + + func differenceTitle(unitsStyle: DateComponentsFormatter.UnitsStyle = .short) -> String { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = unitsStyle + formatter.maximumUnitCount = 2 + return formatter.string(from: duration) ?? "Unknown" + } + + var relativeTimeCaption: String { + endTime.getRelativeTime() + } + + private var timeOnlyFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter + } + + private var dateAndTimeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + } + + var differenceCaption: String { + if duration < 60 { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter.string(from: startTime) + } + + let onSameDay = Calendar.current.isDate(startTime, equalTo: endTime, toGranularity: .day) + + if onSameDay { + return "\(dateAndTimeFormatter.string(from: startTime)) to \(timeOnlyFormatter.string(from: endTime))" + } + + return "\(dateAndTimeFormatter.string(from: startTime)) to \(dateAndTimeFormatter.string(from: endTime))" + } +} diff --git a/Mlem/Views/Shared/Links/Community/CommunityLinkView.swift b/Mlem/Views/Shared/Links/Community/CommunityLinkView.swift index 21aa81d7a..02bda0335 100644 --- a/Mlem/Views/Shared/Links/Community/CommunityLinkView.swift +++ b/Mlem/Views/Shared/Links/Community/CommunityLinkView.swift @@ -41,7 +41,6 @@ struct CommunityLinkView: View { } var body: some View { - // NavigationLink(value: community) { NavigationLink(.community(community)) { HStack { CommunityLabelView( diff --git a/Mlem/Views/Shared/Links/InstanceLabelView.swift b/Mlem/Views/Shared/Links/InstanceLabelView.swift new file mode 100644 index 000000000..d3927f6d3 --- /dev/null +++ b/Mlem/Views/Shared/Links/InstanceLabelView.swift @@ -0,0 +1,27 @@ +// +// InstanceLabelView.swift +// Mlem +// +// Created by Sjmarf on 10/03/2024. +// + +import Foundation +import SwiftUI + +struct InstanceLabelView: View { + let instance: InstanceModel + + var body: some View { + HStack(spacing: AppConstants.largeAvatarSpacing) { + AvatarView(instance: instance, avatarSize: AppConstants.largeAvatarSize) + .accessibilityHidden(true) + + Text(instance.name) + .dynamicTypeSize(.small ... .accessibility1) + .font(.footnote) + .bold() + .foregroundColor(.secondary) + } + .accessibilityElement(children: .combine) + } +} diff --git a/Mlem/Views/Shared/Links/User/UserLabelView.swift b/Mlem/Views/Shared/Links/User/UserLabelView.swift index 478aba010..e3893c3c6 100644 --- a/Mlem/Views/Shared/Links/User/UserLabelView.swift +++ b/Mlem/Views/Shared/Links/User/UserLabelView.swift @@ -7,11 +7,24 @@ import SwiftUI +enum FlairSize { + case small, medium, large + + var width: CGFloat { + switch self { + case .small: 10 + case .medium: 12 + case .large: 14 + } + } +} + struct UserLabelView: View { @AppStorage("shouldShowUserAvatars") var shouldShowUserAvatars: Bool = true var user: UserModel let serverInstanceLocation: ServerInstanceLocation + let bannedFromCommunity: Bool let overrideShowAvatar: Bool? // if present, shows or hides avatar according to value; otherwise uses system settings // Extra context about where the link is being displayed @@ -29,6 +42,7 @@ struct UserLabelView: View { person: APIPerson, serverInstanceLocation: ServerInstanceLocation, overrideShowAvatar: Bool? = nil, + bannedFromCommunity: Bool, postContext: APIPost? = nil, commentContext: APIComment? = nil, communityContext: CommunityModel? = nil @@ -36,6 +50,7 @@ struct UserLabelView: View { self.init( user: UserModel(from: person), serverInstanceLocation: serverInstanceLocation, + bannedFromCommunity: bannedFromCommunity, overrideShowAvatar: overrideShowAvatar, postContext: postContext, commentContext: commentContext, @@ -46,6 +61,7 @@ struct UserLabelView: View { init( user: UserModel, serverInstanceLocation: ServerInstanceLocation, + bannedFromCommunity: Bool, overrideShowAvatar: Bool? = nil, postContext: APIPost? = nil, commentContext: APIComment? = nil, @@ -54,6 +70,7 @@ struct UserLabelView: View { self.user = user self.serverInstanceLocation = serverInstanceLocation self.overrideShowAvatar = overrideShowAvatar + self.bannedFromCommunity = bannedFromCommunity _postContext = State(initialValue: postContext) _commentContext = State(initialValue: commentContext) @@ -88,25 +105,23 @@ struct UserLabelView: View { let flairs = user.getFlairs( postContext: postContext, commentContext: commentContext, - communityContext: communityContext + communityContext: communityContext, + bannedFromCommunity: bannedFromCommunity ) HStack(spacing: 4) { if serverInstanceLocation == .bottom { if flairs.count == 1, let first = flairs.first { - userFlairIcon(with: first) - .imageScale(.large) + userFlairIcon(with: first, size: .large) } else if !flairs.isEmpty { HStack(spacing: 2) { - LazyHGrid(rows: [GridItem(), GridItem()], alignment: .center, spacing: 2) { + LazyHGrid(rows: [GridItem(spacing: 2), GridItem()], alignment: .center, spacing: 2) { ForEach(flairs.dropLast(flairs.count % 2), id: \.self) { flair in - userFlairIcon(with: flair) - .imageScale(.medium) + userFlairIcon(with: flair, size: .medium) } } if flairs.count % 2 != 0 { - userFlairIcon(with: flairs.last!) - .imageScale(.medium) + userFlairIcon(with: flairs.last!, size: .medium) } } .padding(.trailing, 4) @@ -114,12 +129,10 @@ struct UserLabelView: View { } else { if flairs.count == 1, let first = flairs.first { - userFlairIcon(with: first) - .imageScale(.small) + userFlairIcon(with: first, size: .small) } else if !flairs.isEmpty { ForEach(flairs, id: \.self) { flair in - userFlairIcon(with: flair) - .imageScale(.small) + userFlairIcon(with: flair, size: .small) } .padding(.trailing, 4) } @@ -143,10 +156,11 @@ struct UserLabelView: View { } @ViewBuilder - private func userFlairIcon(with flair: UserFlair) -> some View { + private func userFlairIcon(with flair: UserFlair, size: FlairSize) -> some View { Image(systemName: flair.icon) - .bold() - .font(.footnote) + .resizable() + .scaledToFit() + .frame(height: size.width, alignment: .center) .foregroundColor(flair.color) } @@ -303,17 +317,20 @@ struct UserLinkViewPreview: PreviewProvider { return UserLinkView( user: UserModel(from: previewUser), serverInstanceLocation: serverInstanceLocation, + bannedFromCommunity: false, postContext: postContext, commentContext: commentContext ) } static var previews: some View { - VStack { - ForEach(ServerInstanceLocation.allCases, id: \.rawValue) { serverInstanceLocation in - Spacer() - ForEach(PreviewUserType.allCases, id: \.rawValue) { userType in - generateUserLinkView(name: "\(userType)User", userType: userType, serverInstanceLocation: serverInstanceLocation) + NavigationStack { + VStack { + ForEach(ServerInstanceLocation.allCases, id: \.rawValue) { serverInstanceLocation in + Spacer() + ForEach(PreviewUserType.allCases, id: \.rawValue) { userType in + generateUserLinkView(name: "\(userType)User", userType: userType, serverInstanceLocation: serverInstanceLocation) + } } } } diff --git a/Mlem/Views/Shared/Links/User/UserLinkView.swift b/Mlem/Views/Shared/Links/User/UserLinkView.swift index 7a22c22c5..958fca23a 100644 --- a/Mlem/Views/Shared/Links/User/UserLinkView.swift +++ b/Mlem/Views/Shared/Links/User/UserLinkView.swift @@ -10,6 +10,7 @@ import SwiftUI struct UserLinkView: View { var user: UserModel let serverInstanceLocation: ServerInstanceLocation + let bannedFromCommunity: Bool var overrideShowAvatar: Bool? // shows or hides the avatar according to value. If not set, uses system setting. // Extra context about where the link is being displayed @@ -23,6 +24,7 @@ struct UserLinkView: View { person: APIPerson, serverInstanceLocation: ServerInstanceLocation, overrideShowAvatar: Bool? = nil, + bannedFromCommunity: Bool, postContext: APIPost? = nil, commentContext: APIComment? = nil, communityContext: CommunityModel? = nil @@ -30,6 +32,7 @@ struct UserLinkView: View { self.init( user: UserModel(from: person), serverInstanceLocation: serverInstanceLocation, + bannedFromCommunity: bannedFromCommunity, overrideShowAvatar: overrideShowAvatar, postContext: postContext, commentContext: commentContext, @@ -40,6 +43,7 @@ struct UserLinkView: View { init( user: UserModel, serverInstanceLocation: ServerInstanceLocation, + bannedFromCommunity: Bool, overrideShowAvatar: Bool? = nil, postContext: APIPost? = nil, commentContext: APIComment? = nil, @@ -52,6 +56,7 @@ struct UserLinkView: View { self.postContext = postContext self.commentContext = commentContext self.communityContext = communityContext + self.bannedFromCommunity = bannedFromCommunity } var body: some View { @@ -59,6 +64,7 @@ struct UserLinkView: View { UserLabelView( user: user, serverInstanceLocation: serverInstanceLocation, + bannedFromCommunity: bannedFromCommunity, overrideShowAvatar: overrideShowAvatar, postContext: postContext, commentContext: commentContext, diff --git a/Mlem/Views/Shared/Loading View.swift b/Mlem/Views/Shared/Loading View.swift index 090b7a727..1aa5c4446 100644 --- a/Mlem/Views/Shared/Loading View.swift +++ b/Mlem/Views/Shared/Loading View.swift @@ -10,7 +10,7 @@ import SwiftUI struct LoadingView: View { enum PossibleThingsToLoad { case posts, image, comments, inbox, replies, mentions, messages, - communityDetails, search, instances, instanceDetails, content, profile + communityDetails, search, instances, instanceDetails, content, profile, modlog, votes, blockList } let whatIsLoading: PossibleThingsToLoad @@ -48,6 +48,12 @@ struct LoadingView: View { Text("Loading instances") case .instanceDetails: Text("Loading instance details") + case .modlog: + Text("Loading modlog") + case .votes: + Text("Loading votes") + case .blockList: + Text("Loading block list") } Spacer() diff --git a/Mlem/Views/Shared/Markdown View.swift b/Mlem/Views/Shared/Markdown View.swift index ca30f0ec6..e1cd64229 100644 --- a/Mlem/Views/Shared/Markdown View.swift +++ b/Mlem/Views/Shared/Markdown View.swift @@ -101,8 +101,13 @@ struct MarkdownView: View { ) } else { return AnyView( - CachedImage(url: url, shouldExpand: shouldExpand, cornerRadius: AppConstants.largeItemCornerRadius) - .applyNsfwOverlay(isNsfw) + CachedImage( + url: url, + shouldExpand: shouldExpand, + hasContextMenu: true, + cornerRadius: AppConstants.largeItemCornerRadius + ) + .applyNsfwOverlay(isNsfw) ) } } diff --git a/Mlem/Views/Shared/Moderation/Components/BanFormButtonStyle.swift b/Mlem/Views/Shared/Moderation/Components/BanFormButtonStyle.swift new file mode 100644 index 000000000..6a9aa3694 --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Components/BanFormButtonStyle.swift @@ -0,0 +1,25 @@ +// +// BanFormButtonStyle.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27. +// + +import Foundation +import SwiftUI + +struct BanFormButton: ButtonStyle { + let selected: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.callout) + .foregroundStyle(selected ? .white : .primary) + .padding(.vertical, 4) + .frame(maxWidth: 150) + .background { + RoundedRectangle(cornerRadius: 6) + .fill(selected ? .blue : Color(uiColor: .systemGroupedBackground)) + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Components/ModlogNavigationLinkView.swift b/Mlem/Views/Shared/Moderation/Components/ModlogNavigationLinkView.swift new file mode 100644 index 000000000..f4018524a --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Components/ModlogNavigationLinkView.swift @@ -0,0 +1,42 @@ +// +// ModlogNavigationLink.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-13. +// + +import Foundation +import SwiftUI + +struct ModlogNavigationLinkView: View { + let link: ModlogLink + + init() { + self.link = .userInstance + } + + init(to instance: InstanceModel) { + self.link = .instance(instance) + } + + init(to community: CommunityModel) { + self.link = .community(community) + } + + var body: some View { + NavigationLink(value: AppRoute.modlog(link)) { + HStack(alignment: .center) { + Text("View Modlog") + + Spacer() + + Image(systemName: Icons.forward) + .imageScale(.small) + .foregroundStyle(.tertiary) + } + .padding(.horizontal) + .padding(.vertical, 8) + .foregroundColor(.secondary) + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Components/ReasonView.swift b/Mlem/Views/Shared/Moderation/Components/ReasonView.swift new file mode 100644 index 000000000..7106dcfbd --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Components/ReasonView.swift @@ -0,0 +1,68 @@ +// +// ReasonView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27. +// + +import Dependencies +import SwiftUI + +enum FocusedField { + case reason, days +} + +struct ReasonView: View { + @Dependency(\.hapticManager) var hapticManager + + @Binding var reason: String + var focusedField: FocusState.Binding + let showReason: Bool + + var body: some View { + Section("Reason") { + TextField("Optional", text: $reason, axis: .vertical) + .lineLimit(8) + .focused(focusedField, equals: .reason) + .overlay(alignment: .trailing) { + if reason.isNotEmpty, focusedField.wrappedValue != .reason { + Button("Clear", systemImage: "xmark.circle.fill") { reason = "" } + .foregroundStyle(.secondary.opacity(0.8)) + .labelStyle(.iconOnly) + } + } + if showReason { + HStack { + if reason == "Rule #" { + ForEach(1 ..< 9) { value in + Button(String(value)) { + reason = "Rule \(value)" + hapticManager.play(haptic: .gentleInfo, priority: .low) + } + .buttonStyle(BanFormButton(selected: false)) + } + } else { + Button("Rule #") { + reason = "Rule #" + hapticManager.play(haptic: .gentleInfo, priority: .low) + } + .buttonStyle(BanFormButton(selected: reason.hasPrefix("Rule"))) + reasonPresetButton("Spam") + reasonPresetButton("Troll") + reasonPresetButton("Abuse") + } + } + .padding(.horizontal, -8) + } + } + } + + @ViewBuilder + func reasonPresetButton(_ label: String) -> some View { + Button(label) { + reason = reason == label ? "" : label + hapticManager.play(haptic: .gentleInfo, priority: .low) + } + .buttonStyle(BanFormButton(selected: reason == label)) + } +} diff --git a/Mlem/Views/Shared/Moderation/ModToolSheet.swift b/Mlem/Views/Shared/Moderation/ModToolSheet.swift new file mode 100644 index 000000000..4baff64e5 --- /dev/null +++ b/Mlem/Views/Shared/Moderation/ModToolSheet.swift @@ -0,0 +1,41 @@ +// +// ModToolSheet.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-14. +// + +import Foundation +import SwiftUI + +struct ModToolSheet: View { + let tool: ModTool + + var body: some View { + switch tool { + case .editCommunity: + Text("Not yet!") + case let .purgeContent(content, userRemovalWalker): + PurgeContentView(content: content, userRemovalWalker: userRemovalWalker) + case let .banUser(user, community, bannedFromCommunity, shouldBan, userRemovalWalker, callback): + BanUserView( + user: user, + communityContext: community, + bannedFromCommunity: bannedFromCommunity ?? false, + shouldBan: shouldBan, + userRemovalWalker: userRemovalWalker, + callback: callback + ) + case let .addMod(user, community): + AddModView(community: community, user: user) + case let .removePost(post, shouldRemove, callback): + RemovePostView(post: post, shouldRemove: shouldRemove, callback: callback) + case let .removeComment(comment, shouldRemove, callback): + RemoveCommentView(comment: comment, shouldRemove: shouldRemove, callback: callback) + case let .removeCommunity(community, shouldRemove): + RemoveCommunityView(community: community, shouldRemove: shouldRemove) + case let .denyApplication(application): + DenyApplicationView(application: application) + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/AddModView.swift b/Mlem/Views/Shared/Moderation/Tools/AddModView.swift new file mode 100644 index 000000000..c2ece6f9c --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/AddModView.swift @@ -0,0 +1,183 @@ +// +// AddModView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-05. +// + +import Dependencies +import Foundation +import SwiftUI + +struct AddModView: View { + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier + @Dependency(\.siteInformation) var siteInformation + + @Environment(\.dismiss) var dismiss + + @State var community: CommunityModel? + @State var user: UserModel? + + @State var isSearchingCommunity: Bool = false + @State var isSearchingUser: Bool = false + @State var isSubmitting: Bool = false + + @StateObject var searchModel: SearchModel = .init(searchTab: .users) + + // if present, these context bindings will pre-populate the relevant field and prevent it from being modified; upon confirm, any present bindings will be updated. + var communityBinding: Binding? + var userBinding: Binding? + + let canChangeCommunity: Bool + let canChangeUser: Bool + + // TODO: 2.0 get rid of bindings and just update the models implicitly at cache layer when add mod call returns + init(community: Binding?, user: Binding?) { + self.communityBinding = community + self.userBinding = user + + if let community { + self._community = .init(wrappedValue: community.wrappedValue) + } + if let user { + self._user = .init(wrappedValue: user.wrappedValue) + } + + self.canChangeCommunity = community == nil + self.canChangeUser = user == nil + } + + var body: some View { + content + .progressOverlay(isPresented: $isSubmitting) + .navigationTitle("Add Moderator") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $isSearchingUser) { + SimpleUserSearchView( + resultsFilter: { !(community?.isModerator($0.userId) ?? true) }, + callback: { user in + self.user = user + } + ) + } + .sheet(isPresented: $isSearchingCommunity) { + SimpleCommunitySearchView( + defaultItems: siteInformation.myUser?.moderatedCommunities, + resultsFilter: { community in + // filter out communities that the user already moderates + if user?.moderatedCommunities?.contains(community) ?? false { + return false + } + + // admin can add mod to any community + if siteInformation.myUser?.isAdmin ?? false { + return true + } + + // users can only add mod to communities they moderate + return siteInformation.moderatedCommunities.contains(community.communityId) + }, + callback: { community in + self.community = community + } + ) + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel", role: .destructive) { + dismiss() + } + .tint(.red) + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + if let community, let user { + confirmAddModerator(community: community, user: user) + } else { + assertionFailure("Confirm enabled but community or user nil!") + } + } label: { + Image(systemName: Icons.send) + } + .disabled(user == nil || community == nil) + } + } + } + + var content: some View { + Form { + Section("Community") { + Button { + isSearchingCommunity = true + } label: { + HStack { + if let community { + CommunityLabelView(community: community, serverInstanceLocation: .bottom) + } else { + Text("No community selected") + .italic() + .foregroundColor(.secondary) + } + + Spacer() + + if canChangeCommunity { + Image(systemName: Icons.search) + } + } + } + .disabled(!canChangeCommunity) + .buttonStyle(.borderless) + } + + Section("User") { + Button { + isSearchingUser = true + } label: { + HStack { + if let user { + UserLabelView(user: user, serverInstanceLocation: .bottom, bannedFromCommunity: false) + .foregroundColor(.secondary) + } else { + Text("No user selected") + .italic() + .foregroundColor(.secondary) + } + + Spacer() + + if canChangeUser { + Image(systemName: Icons.search) + } + } + } + .disabled(!canChangeUser) + } + } + } + + func confirmAddModerator(community: CommunityModel, user: UserModel) { + isSubmitting = true + + Task { + let result = await community.updateModStatus(of: user.userId, to: true) { newCommunity in + communityBinding?.wrappedValue = newCommunity + userBinding?.wrappedValue.addModeratedCommunity(newCommunity) + } + + if result { + // introduce delay to give sheet time to disappear before notification pops + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + Task { + await notifier.add(.success("Modded \(user.name ?? "user")")) + } + } + dismiss() + } else { + isSubmitting = false + } + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/BanUserView+Logic.swift b/Mlem/Views/Shared/Moderation/Tools/BanUserView+Logic.swift new file mode 100644 index 000000000..5d226a113 --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/BanUserView+Logic.swift @@ -0,0 +1,106 @@ +// +// BanUserView+Logic.swift +// Mlem +// +// Created by Sjmarf on 09/03/2024. +// + +import SwiftUI + +extension BanUserView { + func confirm() { + if banFromInstance { + instanceBan() + } else if let communityContext { + communityBan(from: communityContext) + } else { + assertionFailure("banFromInstance false but communityContext nil!") + } + } + + func instanceBan() { + isWaiting = true + Task { + let reason = reason.isEmpty ? nil : reason + var user = user + await user.toggleBan( + expires: expires, + reason: reason, + removeData: removeContent + ) + DispatchQueue.main.async { + isWaiting = false + } + + await handleResult(user.banned) + } + } + + func communityBan(from community: CommunityModel) { + isWaiting = true + Task { + let updatedBannedStatus = await community.banUser( + userId: user.userId, + ban: shouldBan, + removeData: removeContent, + reason: reason.isEmpty ? nil : reason, + expires: expires + ) + DispatchQueue.main.async { + isWaiting = false + } + + await handleResult(updatedBannedStatus) + } + } + + func handleResult(_ result: Bool) async { + if result == shouldBan { + await notifier.add(.success("\(verb.capitalized)ned User")) + + if let callback { + callback() + } + + await MainActor.run { + userRemovalWalker.modify( + userId: user.userId, + postAction: { post in + if banFromInstance { + post.creator.banned = shouldBan + } else { + post.creatorBannedFromCommunity = shouldBan + } + }, + commentAction: { comment in + if banFromInstance { + comment.commentView.creator.banned = shouldBan + } else { + comment.commentView.creatorBannedFromCommunity = shouldBan + } + }, + inboxAction: { item in + if banFromInstance { + item.setCreatorBannedFromInstance(shouldBan) + } else { + item.setCreatorBannedFromCommunity(shouldBan) + } + }, + voteAction: { vote in + if banFromInstance { + vote.user.banned = shouldBan + } else { + vote.creatorBannedFromCommunity = shouldBan + } + } + ) + } + + DispatchQueue.main.async { + dismiss() + } + } else { + await notifier.add(.failure("Failed to \(verb) user")) + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/BanUserView.swift b/Mlem/Views/Shared/Moderation/Tools/BanUserView.swift new file mode 100644 index 000000000..ff436481a --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/BanUserView.swift @@ -0,0 +1,259 @@ +// +// BanUserView.swift +// Mlem +// +// Created by Sjmarf on 26/01/2024. +// + +import Dependencies +import SwiftUI + +struct BanUserView: View { + @Dependency(\.siteInformation) var siteInformation + @Dependency(\.hapticManager) var hapticManager + @Dependency(\.apiClient) var apiClient + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier + + @EnvironmentObject var unreadTracker: UnreadTracker + + @Environment(\.dismiss) var dismiss + + let user: UserModel + let communityContext: CommunityModel? + let bannedFromCommunity: Bool + let shouldBan: Bool + let userRemovalWalker: UserRemovalWalker + let callback: (() -> Void)? + + @State var banFromInstance: Bool + + @State var reason: String = "" + @State var days: Int = 1 + @State var isPermanent: Bool = true + @State var removeContent: Bool = false + @State var isWaiting: Bool = false + + @FocusState var focusedField: FocusedField? + + init( + user: UserModel, + communityContext: CommunityModel?, + bannedFromCommunity: Bool = false, + shouldBan: Bool, + userRemovalWalker: UserRemovalWalker, + callback: (() -> Void)? = nil + ) { + self.user = user + self.communityContext = communityContext + self.bannedFromCommunity = bannedFromCommunity + self.shouldBan = shouldBan + self.userRemovalWalker = userRemovalWalker + self.callback = callback + + @Dependency(\.siteInformation) var siteInformation + + // by default, ban from instance if admin and user isn't already instance banned. If admin but also moderates the community, default to community ban + var instanceBan: Bool = siteInformation.isAdmin && shouldBan != user.banned + if siteInformation.isAdmin, + let communityId = communityContext?.communityId, + siteInformation.moderatedCommunities.contains(communityId) { + instanceBan = false + } + + _banFromInstance = .init( + wrappedValue: instanceBan + ) + } + + var expires: Int? { + isPermanent ? nil : Date.getEpochDate(daysFromNow: days) + } + + var verb: String { shouldBan ? "ban" : "unban" } + + var body: some View { + form + .toolbar { + ToolbarItem(placement: .keyboard) { + HStack { + Spacer() + Button("Done") { focusedField = nil } + } + } + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .tint(.red) + .disabled(isWaiting) + } + ToolbarItem(placement: .topBarTrailing) { + if isWaiting { + ProgressView() + } else { + Button("Confirm", systemImage: Icons.send, action: confirm) + } + } + } + .navigationTitle("\(verb.capitalized) \(user.displayName)") + .navigationBarTitleDisplayMode(.inline) + .allowsHitTesting(!isWaiting) + .opacity(isWaiting ? 0.5 : 1) + .interactiveDismissDisabled(isWaiting) + } + + var form: some View { + Form { + scopeSection() + + ReasonView(reason: $reason, focusedField: $focusedField, showReason: shouldBan) + + if shouldBan { + durationSections() + } + } + } + + // MARK: Form Sections + + @ViewBuilder + func scopeSection() -> some View { + if !siteInformation.isAdmin || (bannedFromCommunity != user.banned && !banFromInstance) { + if let communityContext { + Section("\(verb.capitalized)ning From") { + CommunityLabelView(community: communityContext, serverInstanceLocation: .bottom) + .padding(.vertical, 1) + } + } + } else if let instance = siteInformation.instance { + if let communityContext, bannedFromCommunity == user.banned { + Section("\(verb.capitalized) From") { + Menu { + Picker("Test", selection: $banFromInstance) { + Button {} label: { + Text("Instance") + if let name = siteInformation.instance?.name { + Text(name) + } + }.tag(true) + Button {} label: { + Text("Community") + if let name = communityContext.fullyQualifiedName { + Text(name) + } + }.tag(false) + }.pickerStyle(.inline) + } label: { + HStack { + if banFromInstance { + InstanceLabelView(instance: instance) + } else { + CommunityLabelView(community: communityContext, serverInstanceLocation: .bottom) + } + Spacer() + Image(systemName: "chevron.down") + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.vertical, 1) + } + } else { + Section("\(verb.capitalized)ning From") { + InstanceLabelView(instance: instance) + .padding(.vertical, 1) + } + } + } + } + + @ViewBuilder + func durationSections() -> some View { + Section { + Toggle("Permanent", isOn: $isPermanent) + .tint(.red) + } + banDurationSection() + removeContentSection() + } + + @ViewBuilder + func banDurationSection() -> some View { + Section("Ban Duration") { + HStack { + Text("Days:") + .onTapGesture { + focusedField = .days + } + TextField("", value: Binding( + get: { days }, + set: { newValue in + days = newValue > 1 ? newValue : 0 + } + ), format: .number) + .keyboardType(.numberPad) + .focused($focusedField, equals: .days) + } + DatePicker( + "Expiration Date:", + selection: Binding( + get: { + .now.advanced(by: .days(Double(days))) + }, + set: { newValue in + days = Int(round(newValue.timeIntervalSince(.now) / (60 * 60 * 24))) + } + ), + in: Date.now.advanced(by: .days(1))..., + displayedComponents: [.date] + ) + HStack { + daysPresetButton("1d", value: 1) + daysPresetButton("3d", value: 3) + daysPresetButton("7d", value: 7) + daysPresetButton("30d", value: 30) + daysPresetButton("60d", value: 60) + daysPresetButton("90d", value: 90) + daysPresetButton("1y", value: 365) + } + .padding(.horizontal, -8) + } + .opacity(isPermanent ? 0.5 : 1) + .disabled(isPermanent) + } + + @ViewBuilder + func removeContentSection() -> some View { + Section { + Toggle("Remove Content", isOn: $removeContent) + .tint(.red) + } footer: { + if communityContext == nil { + let posts = user.postCount ?? 0 + let comments = user.commentCount ?? 0 + Text("Remove all \(posts) posts and \(comments) comments created by this user. They can be restored later if needed.") + } + } + } + + @ViewBuilder + func daysPresetButton(_ label: String, value: Int) -> some View { + Button(label) { + days = value + hapticManager.play(haptic: .gentleInfo, priority: .low) + } + .buttonStyle(BanFormButton(selected: days == value && !isPermanent)) + } +} + +#Preview { + BanUserView( + user: .mock(), + communityContext: .mock(), + shouldBan: true, + userRemovalWalker: .init() + ) +} diff --git a/Mlem/Views/Shared/Moderation/Tools/DenyApplicationView.swift b/Mlem/Views/Shared/Moderation/Tools/DenyApplicationView.swift new file mode 100644 index 000000000..b5e6b8371 --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/DenyApplicationView.swift @@ -0,0 +1,80 @@ +// +// DenyApplicationView.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-05. +// + +import Dependencies +import Foundation +import SwiftUI + +struct DenyApplicationView: View { + @Dependency(\.notifier) var notifier + + @EnvironmentObject var unreadTracker: UnreadTracker + + @Environment(\.dismiss) var dismiss + + let application: RegistrationApplicationModel + + @State var text: String = "" + @State var isWaiting: Bool = false + @FocusState var textFieldFocused: Bool + + var body: some View { + content + .onAppear { + textFieldFocused = true + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel", role: .destructive) { + dismiss() + } + .tint(.red) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + submit() + } label: { + Image(systemName: Icons.send) + } + .disabled(text.isEmpty) + } + } + .allowsHitTesting(!isWaiting) + .opacity(isWaiting ? 0.5 : 1) + .interactiveDismissDisabled(isWaiting) + } + + var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + TextField("Denial reason", text: $text, axis: .vertical) + .padding(.horizontal, AppConstants.standardSpacing) + .focused($textFieldFocused) + + Divider() + + InboxRegistrationApplicationBodyView(application: application, menuFunctions: .init()) + } + } + } + + func submit() { + isWaiting = true + Task { + let success = await application.deny(reason: text, unreadTracker: unreadTracker) + if success { + await MainActor.run { + dismiss() + } + await notifier.add(.success("Denied Application")) + } else { + isWaiting = false + } + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/PurgeContentView.swift b/Mlem/Views/Shared/Moderation/Tools/PurgeContentView.swift new file mode 100644 index 000000000..d61d276a9 --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/PurgeContentView.swift @@ -0,0 +1,129 @@ +// +// PurgeContentView.swift +// Mlem +// +// Created by Sjmarf on 26/03/2024. +// + +import Dependencies +import SwiftUI + +protocol Purgable: ContentIdentifiable { + mutating func purge(reason: String?) async -> Bool +} + +struct PurgeContentView: View { + @Dependency(\.apiClient) var apiClient + @Dependency(\.notifier) var notifier + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation + + @Environment(\.dismiss) var dismiss + + @State var reason: String = "" + @FocusState var reasonFocused: FocusedField? + @State var isWaiting: Bool = false + + let content: any Purgable + let userRemovalWalker: UserRemovalWalker + + var title: String { + if content is PostModel { + return "Purge Post" + } + if content is HierarchicalComment { + return "Purge Comment" + } + if content is UserModel { + return "Purge User" + } + if content is CommunityModel { + return "Purge Community" + } + return "Purge" + } + + var body: some View { + form + .onAppear { + reasonFocused = .reason + } + .toolbar { + ToolbarItem(placement: .keyboard) { + HStack { + Spacer() + Button("Done") { reasonFocused = nil } + } + } + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .tint(.red) + .disabled(isWaiting) + } + ToolbarItem(placement: .topBarTrailing) { + if isWaiting { + ProgressView() + } else { + Button("Confirm", systemImage: Icons.send, action: confirm) + } + } + } + .allowsHitTesting(!isWaiting) + .opacity(isWaiting ? 0.5 : 1) + .interactiveDismissDisabled(isWaiting) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } + + var form: some View { + Form { + ReasonView(reason: $reason, focusedField: $reasonFocused, showReason: true) + Section { + WarningView( + iconName: Icons.purge, + text: "Purged content cannot is removed permanently from the database and cannot be restored.", + inList: true + ) + } + } + } + + var userId: Int? { + if let post = content as? PostModel { + return post.creator.userId + } else if let comment = content as? HierarchicalComment { + return comment.commentView.creator.id + } else if let user = content as? UserModel { + return user.userId + } + return nil + } + + private func confirm() { + isWaiting = true + + Task { + var content = content + let outcome = await content.purge(reason: reason.isEmpty ? nil : reason) + if let userId { + userRemovalWalker.purge(userId: userId) + } + if outcome { + await notifier.add(.success("Purged")) + DispatchQueue.main.async { + dismiss() + } + } else { + DispatchQueue.main.async { + isWaiting = false + } + } + } + } +} + +#Preview { + PurgeContentView(content: CommunityModel.mock(), userRemovalWalker: .init()) +} diff --git a/Mlem/Views/Shared/Moderation/Tools/RemoveCommentView.swift b/Mlem/Views/Shared/Moderation/Tools/RemoveCommentView.swift new file mode 100644 index 000000000..791cdefce --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/RemoveCommentView.swift @@ -0,0 +1,80 @@ +// +// RemoveCommentView.swift +// Mlem +// +// Created by Sam Marfleet on 22/03/2024. +// + +import Dependencies +import SwiftUI + +struct RemoveCommentView: View { + @Dependency(\.apiClient) var apiClient + @Dependency(\.notifier) var notifier + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation + + @Environment(\.dismiss) var dismiss + + @State var reason: String = "" + @FocusState var reasonFocused: FocusedField? + @State var isWaiting: Bool = false + + @State var comment: any Removable + let shouldRemove: Bool + var callback: (() -> Void)? + + var verb: String { shouldRemove ? "Remove" : "Restore" } + + var body: some View { + Form { + ReasonView(reason: $reason, focusedField: $reasonFocused, showReason: shouldRemove) + } + .onAppear { + reasonFocused = .reason + } + .toolbar { + ToolbarItem(placement: .keyboard) { + HStack { + Spacer() + Button("Done") { reasonFocused = nil } + } + } + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .tint(.red) + .disabled(isWaiting) + } + ToolbarItem(placement: .topBarTrailing) { + if isWaiting { + ProgressView() + } else { + Button("Confirm", systemImage: Icons.send, action: confirm) + } + } + } + .allowsHitTesting(!isWaiting) + .opacity(isWaiting ? 0.5 : 1) + .interactiveDismissDisabled(isWaiting) + .navigationTitle("\(verb) Comment") + .navigationBarTitleDisplayMode(.inline) + } + + private func confirm() { + isWaiting = true + + Task { + let success = await comment.remove(reason: reason.isEmpty ? nil : reason, shouldRemove: shouldRemove) + DispatchQueue.main.async { + if success { + callback?() + dismiss() + } else { + isWaiting = false + } + } + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/RemoveCommunityView.swift b/Mlem/Views/Shared/Moderation/Tools/RemoveCommunityView.swift new file mode 100644 index 000000000..ef79221a2 --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/RemoveCommunityView.swift @@ -0,0 +1,89 @@ +// +// RemoveCommunityView.swift +// Mlem +// +// Created by Sjmarf on 16/03/2024. +// + +import Dependencies +import Foundation +import SwiftUI + +struct RemoveCommunityView: View { + @Dependency(\.apiClient) var apiClient + @Dependency(\.notifier) var notifier + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation + + @Environment(\.dismiss) var dismiss + + @State var reason: String = "" + @FocusState var reasonFocused: FocusedField? + @State var isWaiting: Bool = false + + let community: CommunityModel + let shouldRemove: Bool + + var verb: String { shouldRemove ? "Remove" : "Restore" } + + var body: some View { + Form { + ReasonView(reason: $reason, focusedField: $reasonFocused, showReason: shouldRemove) + } + .onAppear { + reasonFocused = .reason + } + .toolbar { + ToolbarItem(placement: .keyboard) { + HStack { + Spacer() + Button("Done") { reasonFocused = nil } + } + } + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .tint(.red) + .disabled(isWaiting) + } + ToolbarItem(placement: .topBarTrailing) { + if isWaiting { + ProgressView() + } else { + Button("Confirm", systemImage: Icons.send, action: confirm) + } + } + } + .allowsHitTesting(!isWaiting) + .opacity(isWaiting ? 0.5 : 1) + .interactiveDismissDisabled(isWaiting) + .navigationTitle("\(verb) Community") + .navigationBarTitleDisplayMode(.inline) + } + + private func confirm() { + isWaiting = true + + Task { + await community.toggleRemove(reason: reason.isEmpty ? nil : reason) { community in + Task { + if community.removed == shouldRemove { + await notifier.add(.success("\(verb)d community")) + DispatchQueue.main.async { + dismiss() + } + } else { + DispatchQueue.main.async { + isWaiting = false + } + } + } + } onFailure: { + DispatchQueue.main.async { + isWaiting = false + } + } + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/RemovePostView.swift b/Mlem/Views/Shared/Moderation/Tools/RemovePostView.swift new file mode 100644 index 000000000..3d1a5e36b --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/RemovePostView.swift @@ -0,0 +1,85 @@ +// +// RemovePostView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-27. +// + +import Dependencies +import SwiftUI + +struct RemovePostView: View { + @Dependency(\.apiClient) var apiClient + @Dependency(\.notifier) var notifier + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation + + @Environment(\.dismiss) var dismiss + + @State var reason: String = "" + @FocusState var reasonFocused: FocusedField? + @State var isWaiting: Bool = false + + @State var post: any Removable + let shouldRemove: Bool + var callback: (() -> Void)? + + var verb: String { shouldRemove ? "Remove" : "Restore" } + + var body: some View { + Form { + ReasonView(reason: $reason, focusedField: $reasonFocused, showReason: shouldRemove) + } + .onAppear { + reasonFocused = .reason + } + .toolbar { + ToolbarItem(placement: .keyboard) { + HStack { + Spacer() + Button("Done") { reasonFocused = nil } + } + } + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .tint(.red) + .disabled(isWaiting) + } + ToolbarItem(placement: .topBarTrailing) { + if isWaiting { + ProgressView() + } else { + Button("Confirm", systemImage: Icons.send, action: confirm) + } + } + } + .allowsHitTesting(!isWaiting) + .opacity(isWaiting ? 0.5 : 1) + .interactiveDismissDisabled(isWaiting) + .navigationTitle("\(verb) Post") + .navigationBarTitleDisplayMode(.inline) + } + + private func confirm() { + isWaiting = true + + Task { + let success = await post.remove(reason: reason.isEmpty ? nil : reason, shouldRemove: shouldRemove) + DispatchQueue.main.async { + if success { + dismiss() + callback?() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Task { + await notifier.add(.success("\(verb)d post")) + } + } + } else { + isWaiting = false + } + } + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/SimpleCommunitySearchView.swift b/Mlem/Views/Shared/Moderation/Tools/SimpleCommunitySearchView.swift new file mode 100644 index 000000000..421e0df4f --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/SimpleCommunitySearchView.swift @@ -0,0 +1,91 @@ +// +// SimpleCommunitySearchView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-07. +// + +import Dependencies +import Foundation +import SwiftUI + +/// Simple search view for finding a community. Takes in an optional filter to apply to community results and a callback, which will be activated when a community is tapped with the selected community. +struct SimpleCommunitySearchView: View { + @Dependency(\.errorHandler) var errorHandler + + @Environment(\.dismiss) var dismiss + + @State var searchText: String = "" + @State var communities: [CommunityModel] = .init() + + @StateObject var searchModel: SearchModel = .init(searchTab: .communities) + + let defaultItems: [CommunityModel] + let resultsFilter: (CommunityModel) -> Bool + let callback: (CommunityModel) -> Void + + var displayedItems: [CommunityModel] { communities.isEmpty ? defaultItems : communities } + + init( + defaultItems: [CommunityModel]? = nil, + resultsFilter: @escaping (CommunityModel) -> Bool = { _ in true }, + callback: @escaping (CommunityModel) -> Void + ) { + if let defaultItems { + self.defaultItems = defaultItems.filter(resultsFilter) + } else { + self.defaultItems = .init() + } + self.resultsFilter = resultsFilter + self.callback = callback + } + + var body: some View { + NavigationStack { // needed for .navigationTitle, .searchable to work in nested sheet + content + .searchable(text: $searchModel.searchText) // TODO: 2.0 add isPresented: $isSearching (iOS 17 exclusive) + .onReceive( + searchModel.$searchText + .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main) + ) { newValue in + if searchModel.previousSearchText != newValue, !newValue.isEmpty { + Task { + do { + let results = try await searchModel.performSearch(page: 1) + communities = results + .compactMap { $0.wrappedValue as? CommunityModel } + .filter(resultsFilter) + } catch { + errorHandler.handle(error) + } + } + } + } + .navigationTitle("Search for Community") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel", role: .destructive) { + dismiss() + } + .tint(.red) + } + } + } + } + + var content: some View { + ScrollView { + VStack(spacing: 0) { + ForEach(displayedItems, id: \.uid) { community in + CommunityListRow(community, complications: [.instance, .subscribers], navigationEnabled: false) + .onTapGesture { + callback(community) + dismiss() + } + Divider() + } + } + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/SimpleUserSearchView.swift b/Mlem/Views/Shared/Moderation/Tools/SimpleUserSearchView.swift new file mode 100644 index 000000000..1521390ec --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/SimpleUserSearchView.swift @@ -0,0 +1,82 @@ +// +// SimpleUserSearchView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-07. +// + +import Dependencies +import Foundation +import SwiftUI + +/// Simple search view for finding a user. Takes in an optional filter to apply to user results and a callback, which will be activated when a user is tapped with the selected user. +struct SimpleUserSearchView: View { + @Dependency(\.errorHandler) var errorHandler + + @Environment(\.dismiss) var dismiss + + @State var searchText: String = "" + @State var users: [UserModel] = .init() + + @StateObject var searchModel: SearchModel = .init(searchTab: .users) + + let resultsFilter: (UserModel) -> Bool + let callback: (UserModel) -> Void + + init( + resultsFilter: @escaping (UserModel) -> Bool = { _ in true }, + callback: @escaping (UserModel) -> Void + ) { + self.resultsFilter = resultsFilter + self.callback = callback + } + + var body: some View { + NavigationStack { // needed for .navigationTitle, .searchable to work in nested sheet + content + .searchable(text: $searchModel.searchText) // TODO: 2.0 add isPresented: $isSearching (iOS 17 exclusive) + .onReceive( + searchModel.$searchText + .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main) + ) { newValue in + if searchModel.previousSearchText != newValue, !newValue.isEmpty { + Task { + do { + let results = try await searchModel.performSearch(page: 1) + users = results + .compactMap { $0.wrappedValue as? UserModel } + .filter(resultsFilter) + } catch { + errorHandler.handle(error) + } + } + } + } + .navigationTitle("Search for User") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel", role: .destructive) { + dismiss() + } + .tint(.red) + } + } + } + } + + var content: some View { + ScrollView { + VStack(spacing: 0) { + ForEach(users, id: \.uid) { user in + UserListRow(user, complications: [.instance, .date, .posts, .comments], navigationEnabled: false) + .onTapGesture { + callback(user) + dismiss() + } + Divider() + } + } + } + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/VotesListView.swift b/Mlem/Views/Shared/Moderation/Tools/VotesListView.swift new file mode 100644 index 000000000..f083d7a06 --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/VotesListView.swift @@ -0,0 +1,145 @@ +// +// VotesListView.swift +// Mlem +// +// Created by Sjmarf on 22/03/2024. +// + +import Dependencies +import SwiftUI + +struct VotesListView: View { + @Dependency(\.siteInformation) var siteInformation + + @EnvironmentObject var modToolTracker: ModToolTracker + + @StateObject var votesTracker: VotesTracker + + @State private var menuFunctionPopup: MenuFunctionPopup? + let content: any ContentIdentifiable + + init(content: any ContentIdentifiable) { + self.content = content + self._votesTracker = .init(wrappedValue: .init(content: content)) + } + + var communityContext: CommunityModel? { + if let post = content as? PostModel { + return post.community + } else if let comment = content as? HierarchicalComment { + return CommunityModel(from: comment.commentView.community) + } + return nil + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + if votesTracker.votes.isEmpty { + LoadingView(whatIsLoading: .votes) + } else { + Divider() + ForEach(votesTracker.votes, id: \.id) { item in + voteRow(item: item) + Divider() + } + EndOfFeedView(loadingState: votesTracker.loadingState, viewType: .hobbit, whatIsLoading: .votes) + } + Spacer().frame(height: 50) + } + } + .fancyTabScrollCompatible() + .navigationTitle("Votes") + .onAppear { + if votesTracker.votes.isEmpty { + votesTracker.loadNextPage() + } + } + } + + @ViewBuilder + func voteRow(item: VoteModel) -> some View { + NavigationLink(.userProfile(item.user, communityContext: communityContext)) { + HStack { + UserLinkView( + user: item.user, + serverInstanceLocation: .bottom, + bannedFromCommunity: item.creatorBannedFromCommunity + ) + Spacer() + Image(systemName: item.vote.iconNameFill) + .foregroundStyle(item.vote.color ?? .primary) + .imageScale(.large) + } + .padding(.horizontal, AppConstants.standardSpacing) + .padding(.vertical, 8) + .contentShape(.rect) + } + .buttonStyle(.plain) + .contextMenu { + ForEach(menuFunctions(for: item)) { item in + MenuButton(menuFunction: item, menuFunctionPopup: $menuFunctionPopup) + } + } + .onAppear { + if item.id == votesTracker.votes.last?.id { + votesTracker.loadNextPage() + } + } + } + + func menuFunctions(for item: VoteModel) -> [MenuFunction] { + guard siteInformation.userId != item.user.userId else { return [] } + + var functions = [MenuFunction]() + + if !(siteInformation.isAdmin && item.creatorBannedFromCommunity && item.user.banned) { + functions.append(MenuFunction.toggleableMenuFunction( + toggle: item.creatorBannedFromCommunity, + trueText: "Unban User", + trueImageName: Icons.communityUnban, + falseText: "Ban User", + falseImageName: item.user.banned ? Icons.communityBan : Icons.instanceBan, + isDestructive: .whenFalse + ) { + modToolTracker.banUser( + item.user, + from: communityContext, + bannedFromCommunity: item.creatorBannedFromCommunity, + shouldBan: !item.creatorBannedFromCommunity, + userRemovalWalker: .init(votesTracker: votesTracker) + ) + }) + } + + if siteInformation.isAdmin, item.user.banned || item.creatorBannedFromCommunity { + functions.append(MenuFunction.toggleableMenuFunction( + toggle: item.user.banned, + trueText: "Unban User", + trueImageName: Icons.instanceUnban, + falseText: "Ban User", + falseImageName: Icons.instanceBan, + isDestructive: .whenFalse + ) { + modToolTracker.banUser( + item.user, + from: communityContext, + bannedFromCommunity: item.creatorBannedFromCommunity, + shouldBan: !item.user.banned, + userRemovalWalker: .init(votesTracker: votesTracker) + ) + }) + } + if siteInformation.isAdmin { + functions.append(.standardMenuFunction( + text: "Purge User", + imageName: Icons.purge, + isDestructive: true + ) { + modToolTracker.purgeContent(item.user, userRemovalWalker: .init(votesTracker: votesTracker)) + }) + } + + return functions + } +} diff --git a/Mlem/Views/Shared/Moderation/Tools/VotesTracker.swift b/Mlem/Views/Shared/Moderation/Tools/VotesTracker.swift new file mode 100644 index 000000000..21caa852d --- /dev/null +++ b/Mlem/Views/Shared/Moderation/Tools/VotesTracker.swift @@ -0,0 +1,98 @@ +// +// VotesTracker.swift +// Mlem +// +// Created by Sjmarf on 23/03/2024. +// + +import Dependencies +import SwiftUI + +struct VoteModel: Identifiable { + var user: UserModel + let vote: ScoringOperation + + // On 0.19.3 and below this is only used for state-faking, and isn't truthful for already existing bans. + var creatorBannedFromCommunity: Bool + + // If I try to access user.userId in a computed property, I get a "Publishing changes from background + // threads is not allowed" error. Doing this instead, hopefully I can fix this in 2.0 - sjmarf + let id: Int + + init(item: APIVoteView) { + self.user = .init(from: item.creator) + self.vote = item.score + self.creatorBannedFromCommunity = item.creatorBannedFromCommunity ?? false + self.id = item.creator.id + } +} + +class VotesTracker: ObservableObject { + @Dependency(\.apiClient) var apiClient + @Dependency(\.errorHandler) var errorHandler + + @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast + + @Published var votes: [VoteModel] = .init() + @Published var isLoading: Bool = false + @Published var hasReachedEnd: Bool = false + + let content: any ContentIdentifiable + + init(content: any ContentIdentifiable) { + self.content = content + } + + var loadingState: LoadingState { + if hasReachedEnd { return .done } + if isLoading { return .loading } + return .idle + } + + func loadNextPage() { + if !isLoading, !hasReachedEnd { + isLoading = true + Task { + let page = 1 + votes.count % internetSpeed.pageSize + do { + let newVotes: [APIVoteView] + if content is PostModel { + let response = try await apiClient.getPostLikes( + id: content.uid.contentId, + page: page, + limit: internetSpeed.pageSize + ) + newVotes = response.postLikes + } else if content is HierarchicalComment { + let response = try await apiClient.getCommentLikes( + id: content.uid.contentId, + page: page, + limit: internetSpeed.pageSize + ) + newVotes = response.commentLikes + } else { + newVotes = .init() + assertionFailure("Only a PostModel or HierarchicalComment can be used!") + } + DispatchQueue.main.async { [newVotes] in + self.votes.append(contentsOf: newVotes.map(VoteModel.init)) + if newVotes.count != self.internetSpeed.pageSize { + self.hasReachedEnd = true + } + } + } catch { + errorHandler.handle(error) + } + DispatchQueue.main.async { + self.isLoading = false + } + } + } + } + + func updateItem(user: UserModel) { + if let index = votes.firstIndex(where: { $0.id == user.userId }) { + votes[index].user = user + } + } +} diff --git a/Mlem/Views/Shared/Posts/Components/LockedTag.swift b/Mlem/Views/Shared/Posts/Components/LockedTag.swift new file mode 100644 index 000000000..39e457f2a --- /dev/null +++ b/Mlem/Views/Shared/Posts/Components/LockedTag.swift @@ -0,0 +1,19 @@ +// +// LockedTag.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-02. +// + +import SwiftUI + +struct LockedTag: View { + let compact: Bool + + var body: some View { + Image(systemName: Icons.locked) + .foregroundColor(.orange) + .font(compact ? .footnote : .subheadline) + .accessibilityLabel("Post locked") + } +} diff --git a/Mlem/Views/Shared/Posts/Components/PostEllipsisMenus.swift b/Mlem/Views/Shared/Posts/Components/PostEllipsisMenus.swift new file mode 100644 index 000000000..360368ddc --- /dev/null +++ b/Mlem/Views/Shared/Posts/Components/PostEllipsisMenus.swift @@ -0,0 +1,73 @@ +// +// PostEllipsisMenus.swift +// Mlem +// +// Created by Sjmarf on 26/03/2024. +// + +import Dependencies +import SwiftUI + +struct PostEllipsisMenus: View { + @Dependency(\.siteInformation) var siteInformation + + @AppStorage("moderatorActionGrouping") var moderatorActionGrouping: ModerationActionGroupingMode = .none + @AppStorage("postSize") var postSize: PostSize = .large + + @EnvironmentObject var editorTracker: EditorTracker + @EnvironmentObject var modToolTracker: ModToolTracker + + let postModel: PostModel + let postTracker: StandardPostTracker? + + var size: CGFloat = 24 + + var isMod: Bool { + siteInformation.isModOrAdmin(communityId: postModel.community.communityId) + } + + var combinedMenuFunctions: [MenuFunction] { + postModel.combinedMenuFunctions( + editorTracker: editorTracker, + showSelectText: postSize == .large, + postTracker: postTracker, + community: isMod ? postModel.community : nil, + modToolTracker: isMod ? modToolTracker : nil + ) + } + + var onlyPersonalMenuFunctions: [MenuFunction] { + postModel.personalMenuFunctions( + editorTracker: editorTracker, + showSelectText: postSize == .large, + postTracker: postTracker, + community: isMod ? postModel.community : nil, + modToolTracker: isMod ? modToolTracker : nil + ) + } + + var onlyModeratorMenuFunctions: [MenuFunction] { + postModel.modMenuFunctions( + community: postModel.community, + modToolTracker: modToolTracker, + postTracker: postTracker + ) + } + + var body: some View { + if moderatorActionGrouping == .separateMenu { + if isMod { + let functions = onlyModeratorMenuFunctions + EllipsisMenu( + size: size, + systemImage: siteInformation.isAdmin ? Icons.admin : Icons.moderation, + menuFunctions: functions + ) + .opacity(functions.isEmpty ? 0.5 : 1) + } + EllipsisMenu(size: size, menuFunctions: onlyPersonalMenuFunctions) + } else { + EllipsisMenu(size: size, menuFunctions: combinedMenuFunctions) + } + } +} diff --git a/Mlem/Views/Shared/Posts/Components/RemovedTag.swift b/Mlem/Views/Shared/Posts/Components/RemovedTag.swift new file mode 100644 index 000000000..cb5ec2eb6 --- /dev/null +++ b/Mlem/Views/Shared/Posts/Components/RemovedTag.swift @@ -0,0 +1,19 @@ +// +// RemovedTag.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-02. +// + +import SwiftUI + +struct RemovedTag: View { + let compact: Bool + + var body: some View { + Image(systemName: Icons.removed) + .foregroundColor(.red) + .font(compact ? .footnote : .subheadline) + .accessibilityLabel("Post removed by moderator") + } +} diff --git a/Mlem/Views/Shared/Posts/Components/Stickied Tag.swift b/Mlem/Views/Shared/Posts/Components/Stickied Tag.swift index 4f23f569e..a31d90cee 100644 --- a/Mlem/Views/Shared/Posts/Components/Stickied Tag.swift +++ b/Mlem/Views/Shared/Posts/Components/Stickied Tag.swift @@ -36,7 +36,7 @@ struct StickiedTag: View { case .local: return .red case .community: - return .green + return .moderation } } } diff --git a/Mlem/Views/Shared/Posts/Expanded Post.swift b/Mlem/Views/Shared/Posts/Expanded Post.swift index 423a7c622..99106ccd7 100644 --- a/Mlem/Views/Shared/Posts/Expanded Post.swift +++ b/Mlem/Views/Shared/Posts/Expanded Post.swift @@ -32,6 +32,8 @@ struct ExpandedPost: View { @Dependency(\.hapticManager) var hapticManager @Dependency(\.notifier) var notifier @Dependency(\.postRepository) var postRepository + @Dependency(\.communityRepository) var communityRepository + @Dependency(\.siteInformation) var siteInformation // appstorage @AppStorage("shouldShowUserServerInPost") var shouldShowUserServerInPost: Bool = true @@ -52,11 +54,12 @@ struct ExpandedPost: View { @EnvironmentObject var appState: AppState @EnvironmentObject var editorTracker: EditorTracker @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker + @EnvironmentObject var modToolTracker: ModToolTracker @StateObject var commentTracker: CommentTracker = .init() @EnvironmentObject var postTracker: StandardPostTracker @StateObject var post: PostModel - var community: CommunityModel? + @State var community: CommunityModel? @State var commentErrorDetails: ErrorDetails? @@ -74,17 +77,38 @@ struct ExpandedPost: View { @State private var scrollToTopAppeared = false @Namespace var scrollToTop + @State private var menuFunctionPopup: MenuFunctionPopup? + var body: some View { contentView .environmentObject(commentTracker) .navigationBarTitle(post.community.name, displayMode: .inline) .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { toolbarMenu } + ToolbarItem(placement: .topBarTrailing) { toolbarMenu } + ToolbarItemGroup(placement: .topBarTrailing) { + ToolbarEllipsisMenu { + let isMod = siteInformation.isModOrAdmin(communityId: post.community.communityId) + let menuFunctions = post.combinedMenuFunctions( + isExpanded: true, + editorTracker: editorTracker, + postTracker: postTracker, + commentTracker: commentTracker, + community: isMod ? post.community : nil, + modToolTracker: isMod ? modToolTracker : nil + ) + ForEach(menuFunctions) { child in + MenuButton(menuFunction: child, menuFunctionPopup: $menuFunctionPopup) + } + } + } } + .destructiveConfirmation(menuFunctionPopup: $menuFunctionPopup) .task { if commentTracker.comments.isEmpty { await loadComments() } + } + .task { await post.markRead(true) } .refreshable { await refreshComments() } @@ -117,6 +141,7 @@ struct ExpandedPost: View { noCommentsView() } else { commentsView + .clipped() .onAppear { if let target = scrollTarget { scrollTarget = nil @@ -219,24 +244,17 @@ struct ExpandedPost: View { private var postView: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { - HStack { - CommunityLinkView( - community: post.community, - serverInstanceLocation: communityServerInstanceLocation - ) - - Spacer() - - let functions = post.menuFunctions(editorTracker: editorTracker, postTracker: postTracker) - EllipsisMenu(size: 24, menuFunctions: functions) - } + CommunityLinkView( + community: post.community, + serverInstanceLocation: communityServerInstanceLocation + ) LargePost( post: post, layoutMode: $postLayoutMode ) .onTapGesture { - withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 1, blendDuration: 0.25)) { + withAnimation(.showHidePost) { postLayoutMode = postLayoutMode == .maximize ? .minimize : .maximize } } @@ -244,32 +262,14 @@ struct ExpandedPost: View { UserLinkView( user: post.creator, serverInstanceLocation: userServerInstanceLocation, + bannedFromCommunity: post.creatorBannedFromCommunity, communityContext: community ) } .padding(.top, AppConstants.postAndCommentSpacing) .padding(.horizontal, AppConstants.postAndCommentSpacing) - InteractionBarView( - votes: post.votes, - published: post.published, - updated: post.updated, - commentCount: post.commentCount, - unreadCommentCount: post.unreadCommentCount, - saved: post.saved, - accessibilityContext: "post", - widgets: layoutWidgetTracker.groups.post, - upvote: post.toggleUpvote, - downvote: post.toggleDownvote, - save: post.toggleSave, - reply: replyToPost, - shareURL: URL(string: post.post.apId), - shouldShowScore: shouldShowScoreInPostBar, - showDownvotesSeparately: showPostDownvotesSeparately, - shouldShowTime: shouldShowTimeInPostBar, - shouldShowSaved: shouldShowSavedInPostBar, - shouldShowReplies: shouldShowRepliesInPostBar - ) + InteractionBarView(context: .post, widgets: enrichLayoutWidgets()) } } diff --git a/Mlem/Views/Shared/Posts/ExpandedPostLogic.swift b/Mlem/Views/Shared/Posts/ExpandedPostLogic.swift index 6d29fae52..c448f173e 100644 --- a/Mlem/Views/Shared/Posts/ExpandedPostLogic.swift +++ b/Mlem/Views/Shared/Posts/ExpandedPostLogic.swift @@ -8,6 +8,41 @@ import Foundation extension ExpandedPost { + func enrichLayoutWidgets() -> [EnrichedLayoutWidget] { + layoutWidgetTracker.groups.post.compactMap { baseWidget in + switch baseWidget { + case .infoStack: + .infoStack( + colorizeVotes: false, + votes: post.votes, + published: post.published, + updated: post.updated, + commentCount: post.commentCount, + unreadCommentCount: post.unreadCommentCount, + saved: post.saved + ) + case .upvote: + .upvote(myVote: post.votes.myVote, upvote: post.toggleUpvote) + case .downvote: + .downvote(myVote: post.votes.myVote, downvote: post.toggleDownvote) + case .save: + .save(saved: post.saved, save: post.toggleSave) + case .reply: + .reply(reply: replyToPost) + case .share: + .share(shareUrl: post.post.apId) + case .upvoteCounter: + .upvoteCounter(votes: post.votes, upvote: post.toggleUpvote) + case .downvoteCounter: + .downvoteCounter(votes: post.votes, downvote: post.toggleDownvote) + case .scoreCounter: + .scoreCounter(votes: post.votes, upvote: post.toggleUpvote, downvote: post.toggleDownvote) + default: + nil + } + } + } + // MARK: Interaction callbacks func replyToPost() { @@ -25,6 +60,8 @@ extension ExpandedPost { operation: CommentOperation.replyToComment )) } + + // MARK: Helper functions @discardableResult func loadComments() async -> Bool { diff --git a/Mlem/Views/Shared/Posts/Feed Post.swift b/Mlem/Views/Shared/Posts/Feed Post.swift index 62fa64e36..e2cab1682 100644 --- a/Mlem/Views/Shared/Posts/Feed Post.swift +++ b/Mlem/Views/Shared/Posts/Feed Post.swift @@ -37,12 +37,12 @@ struct FeedPost: View { @AppStorage("shouldShowTimeInPostBar") var shouldShowTimeInPostBar: Bool = true @AppStorage("shouldShowSavedInPostBar") var shouldShowSavedInPostBar: Bool = false @AppStorage("shouldShowRepliesInPostBar") var shouldShowRepliesInPostBar: Bool = true - + @AppStorage("reakMarkStyle") var readMarkStyle: ReadMarkStyle = .bar @AppStorage("readBarThickness") var readBarThickness: Int = 3 - // @EnvironmentObject var postTracker: StandardPostTracker @EnvironmentObject var editorTracker: EditorTracker + @EnvironmentObject var modToolTracker: ModToolTracker @EnvironmentObject var appState: AppState @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker @@ -77,14 +77,20 @@ struct FeedPost: View { @State private var isShowingEnlargedImage: Bool = false @State private var isComposingReport: Bool = false - // MARK: Destructive confirmation + @State private var menuFunctionPopup: MenuFunctionPopup? - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? + var isMod: Bool { + siteInformation.isModOrAdmin(communityId: postModel.community.communityId) + } - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true + var combinedMenuFunctions: [MenuFunction] { + postModel.combinedMenuFunctions( + editorTracker: editorTracker, + showSelectText: postSize == .large, + postTracker: postTracker, + community: isMod ? postModel.community : nil, + modToolTracker: isMod ? modToolTracker : nil + ) } // MARK: Computed @@ -93,18 +99,15 @@ struct FeedPost: View { var showCheck: Bool { postModel.read && diffWithoutColor && readMarkStyle == .check } var body: some View { - // this allows post deletion to not require tracker updates - if postModel.post.deleted { + // this allows post deletion/removal to not require tracker updates + if postModel.post.deleted || (postModel.post.removed && !isMod) || postModel.purged { EmptyView() } else { VStack(spacing: 0) { postItem .border(width: barThickness, edges: [.leading], color: .secondary) .background(Color.systemBackground) - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) + .destructiveConfirmation(menuFunctionPopup: $menuFunctionPopup) .addSwipeyActions( leading: [ enableSwipeActions ? upvoteSwipeAction : nil, @@ -116,12 +119,8 @@ struct FeedPost: View { ] ) .contextMenu { - let functions = postModel.menuFunctions( - editorTracker: editorTracker, - postTracker: postTracker - ) - ForEach(functions) { item in - MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) + ForEach(combinedMenuFunctions) { item in + MenuButton(menuFunction: item, menuFunctionPopup: $menuFunctionPopup) } } } @@ -149,41 +148,42 @@ struct FeedPost: View { with: ConcreteEditorModel(post: postModel, operation: PostOperation.replyToPost) ) } + + /// Render read pinned posts in less "in-your-face" way. + private var renderPinnedAsCompact: Bool { + /// Only render pinned posts in compact size in Community feed, ignore this behaviour in other feed types (e.g. Aggregate). [2024.01] + guard case .community = postTracker?.feedType else { + return false + } + return postModel.read && (postModel.post.featuredLocal || postModel.post.featuredCommunity) + } + + @ViewBuilder + private var compactPost: some View { + CompactPost( + post: postModel, + postTracker: postTracker, + showCommunity: showCommunity + ) + } @ViewBuilder var postItem: some View { - if postSize == .compact { - let functions = postModel.menuFunctions(editorTracker: editorTracker, postTracker: postTracker) - CompactPost( - post: postModel, - showCommunity: showCommunity, - menuFunctions: functions - ) + if postSize == .compact || renderPinnedAsCompact { + compactPost } else { VStack(spacing: 0) { - VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { - // community name - // TEMPORARILY DISABLED: conditionally showing based on community - // if showCommunity { - // CommunityLinkView(community: postView.community) - // } + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { HStack { CommunityLinkView( community: postModel.community, serverInstanceLocation: communityServerInstanceLocation ) - Spacer() - if showCheck { ReadCheck() } - - let functions = postModel.menuFunctions( - editorTracker: editorTracker, - postTracker: postTracker - ) - EllipsisMenu(size: 24, menuFunctions: functions) + PostEllipsisMenus(postModel: postModel, postTracker: postTracker) } if postSize == .headline { @@ -200,33 +200,50 @@ struct FeedPost: View { UserLinkView( user: postModel.creator, serverInstanceLocation: userServerInstanceLocation, + bannedFromCommunity: postModel.creatorBannedFromCommunity, communityContext: community ) } } - .padding(.top, AppConstants.postAndCommentSpacing) - .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.top, AppConstants.standardSpacing) + .padding(.horizontal, AppConstants.standardSpacing) - InteractionBarView( + InteractionBarView(context: .post, widgets: enrichLayoutWidgets()) + } + } + } + + func enrichLayoutWidgets() -> [EnrichedLayoutWidget] { + layoutWidgetTracker.groups.post.compactMap { baseWidget in + switch baseWidget { + case .infoStack: + .infoStack( + colorizeVotes: false, votes: postModel.votes, published: postModel.published, updated: postModel.updated, commentCount: postModel.commentCount, unreadCommentCount: postModel.unreadCommentCount, - saved: postModel.saved, - accessibilityContext: "post", - widgets: layoutWidgetTracker.groups.post, - upvote: postModel.toggleUpvote, - downvote: postModel.toggleDownvote, - save: postModel.toggleSave, - reply: replyToPost, - shareURL: URL(string: postModel.post.apId), - shouldShowScore: shouldShowScoreInPostBar, - showDownvotesSeparately: showPostDownvotesSeparately, - shouldShowTime: shouldShowTimeInPostBar, - shouldShowSaved: shouldShowSavedInPostBar, - shouldShowReplies: shouldShowRepliesInPostBar + saved: postModel.saved ) + case .upvote: + .upvote(myVote: postModel.votes.myVote, upvote: postModel.toggleUpvote) + case .downvote: + .downvote(myVote: postModel.votes.myVote, downvote: postModel.toggleDownvote) + case .save: + .save(saved: postModel.saved, save: postModel.toggleSave) + case .reply: + .reply(reply: replyToPost) + case .share: + .share(shareUrl: postModel.post.apId) + case .upvoteCounter: + .upvoteCounter(votes: postModel.votes, upvote: postModel.toggleUpvote) + case .downvoteCounter: + .downvoteCounter(votes: postModel.votes, downvote: postModel.toggleDownvote) + case .scoreCounter: + .scoreCounter(votes: postModel.votes, upvote: postModel.toggleUpvote, downvote: postModel.toggleDownvote) + default: + nil } } } @@ -235,10 +252,6 @@ struct FeedPost: View { // MARK: - Swipe Actions extension FeedPost { - // TODO: if we want to mirror the behaviour in comments here we need the `dirty` operation to be visible from this - // context, which at present would require some work as it occurs down inside the post interaction bar - // this may need to wait until we complete https://github.com/mormaer/Mlem/issues/117 - var upvoteSwipeAction: SwipeAction { let (emptySymbolName, fullSymbolName) = postModel.votes.myVote == .upvote ? (Icons.resetVoteSquare, Icons.resetVoteSquareFill) : diff --git a/Mlem/Views/Shared/Posts/Lazy Load Expanded Post.swift b/Mlem/Views/Shared/Posts/Lazy Load Expanded Post.swift index 90882625e..4298b0a91 100644 --- a/Mlem/Views/Shared/Posts/Lazy Load Expanded Post.swift +++ b/Mlem/Views/Shared/Posts/Lazy Load Expanded Post.swift @@ -15,15 +15,15 @@ struct LazyLoadExpandedPost: View { @Dependency(\.errorHandler) var errorHandler @Dependency(\.postRepository) var postRepository - let post: APIPost + let postId: Int let scrollTarget: Int? @State private var loadedPostView: PostModel? @StateObject private var postTracker: StandardPostTracker - init(post: APIPost, scrollTarget: Int? = nil) { - self.post = post + init(postId: Int, scrollTarget: Int? = nil) { + self.postId = postId self.scrollTarget = scrollTarget @AppStorage("upvoteOnSave") var upvoteOnSave = false @@ -48,7 +48,7 @@ struct LazyLoadExpandedPost: View { } .task(priority: .background) { do { - loadedPostView = try await postRepository.loadPost(postId: post.id) + loadedPostView = try await postRepository.loadPost(postId: postId) } catch { // TODO: Some sort of common alert banner? // we can show a toast here by passing a `message` and `style: .toast` by using a `ContextualError` below... diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift index 3a32aee4e..4e767aad7 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Compact Post.swift @@ -31,18 +31,23 @@ struct CompactPost: View { // arguments @ObservedObject var post: PostModel + var postTracker: StandardPostTracker? 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, community: CommunityModel? = nil, showCommunity: Bool, menuFunctions: [MenuFunction]) { + init( + post: PostModel, + postTracker: StandardPostTracker?, + community: CommunityModel? = nil, + showCommunity: Bool + ) { self.post = post + self.postTracker = postTracker self.community = community self.showCommunity = showCommunity - self.menuFunctions = menuFunctions } var body: some View { @@ -55,11 +60,16 @@ struct CompactPost: View { HStack { Group { if showCommunity { - CommunityLinkView(community: post.community, serverInstanceLocation: .trailing, overrideShowAvatar: false) + CommunityLinkView( + community: post.community, + serverInstanceLocation: .trailing, + overrideShowAvatar: false + ) } else { UserLinkView( user: post.creator, serverInstanceLocation: .trailing, + bannedFromCommunity: post.creatorBannedFromCommunity, communityContext: community ) } @@ -69,15 +79,27 @@ struct CompactPost: View { if showReadCheck { ReadCheck() } - EllipsisMenu(size: 12, menuFunctions: menuFunctions) + PostEllipsisMenus(postModel: post, postTracker: postTracker, size: 12) .padding(.trailing, 6) } .padding(.bottom, -2) - Text(post.post.name) - .font(.subheadline) - .foregroundColor(post.read ? .secondary : .primary) - + VStack(alignment: .leading, spacing: 3) { + Text(post.post.name) + .font(.subheadline) + .foregroundColor(post.read ? .secondary : .primary) + + if let link = post.linkHost { + Group { + Text(Image(systemName: Icons.browser)) + + Text(" \(link)") + } + .imageScale(.small) + .font(.caption) + .foregroundStyle(.secondary) + } + } + compactInfo } @@ -92,17 +114,21 @@ struct CompactPost: View { @ViewBuilder private var compactInfo: some View { HStack(spacing: 8) { - if post.post.featuredCommunity { - if post.post.featuredLocal { - StickiedTag(tagType: .local, compact: true) - } else if post.post.featuredCommunity { - StickiedTag(tagType: .community, compact: true) - } + if post.post.featuredLocal { + StickiedTag(tagType: .local, compact: true) + } else if post.post.featuredCommunity { + StickiedTag(tagType: .community, compact: true) } if post.post.nsfw || post.community.nsfw { NSFWTag(compact: true) } + if post.post.locked { + LockedTag(compact: true) + } + if post.post.removed { + RemovedTag(compact: true) + } InfoStackView( votes: DetailedVotes( diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift index e3647f5e3..527a1acf4 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Headline Post.swift @@ -23,8 +23,8 @@ struct HeadlinePost: View { @ObservedObject var post: PostModel var body: some View { - VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { - HStack(alignment: .top, spacing: spacing) { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + HStack(alignment: .top, spacing: AppConstants.standardSpacing) { if shouldShowPostThumbnails, !thumbnailsOnRight { ThumbnailImageView(post: post) } @@ -37,15 +37,33 @@ struct HeadlinePost: View { StickiedTag(tagType: .community) } - Text(post.post.name) - .font(.headline) - .padding(.trailing) - .foregroundColor(post.read ? .secondary : .primary) + VStack(alignment: .leading, spacing: AppConstants.halfSpacing) { + Text(post.post.name) + .font(.headline) + .padding(.trailing) + .foregroundColor(post.read ? .secondary : .primary) + + if let link = post.linkHost { + Group { + Text(Image(systemName: Icons.browser)) + + Text(" \(link)") + } + .imageScale(.small) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } Spacer() if post.post.nsfw { NSFWTag(compact: true) } + if post.post.locked { + LockedTag(compact: false) + } + if post.post.removed { + RemovedTag(compact: false) + } } } diff --git a/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift b/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift index e04541cda..e6eb0d0e4 100644 --- a/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift +++ b/Mlem/Views/Shared/Posts/Post Sizes/Large Post.swift @@ -178,10 +178,16 @@ struct LargePost: View { if post.post.nsfw { NSFWTag(compact: false) } + if post.post.locked { + LockedTag(compact: false) + } + if post.post.removed { + RemovedTag(compact: false) + } } .scaleEffect(x: scaleX, y: scaleY) .onChange(of: layoutMode) { newValue in - withAnimation(.easeOut(duration: 0.25)) { + withAnimation(UIAccessibility.isReduceMotionEnabled ? nil : .easeOut(duration: 0.25)) { if newValue == .minimize { scaleX = 0.95 scaleY = 0.95 @@ -202,6 +208,7 @@ struct LargePost: View { if layoutMode != .minimize { CachedImage( url: url, + hasContextMenu: true, maxHeight: layoutMode.getMaxHeight(limitHeight), onTapCallback: markPostAsRead, cornerRadius: AppConstants.largeItemCornerRadius diff --git a/Mlem/Views/Shared/SelectTextView.swift b/Mlem/Views/Shared/SelectTextView.swift new file mode 100644 index 000000000..f94f23fa1 --- /dev/null +++ b/Mlem/Views/Shared/SelectTextView.swift @@ -0,0 +1,47 @@ +// +// SelectTextView.swift +// Mlem +// +// Created by Sjmarf on 03/03/2024. +// + +import Dependencies +import SwiftUI +import SwiftUIIntrospect + +struct SelectTextView: View { + @Dependency(\.hapticManager) var hapticManager + @Environment(\.dismiss) var dismiss + + let text: String + + var body: some View { + VStack(spacing: 10) { + HStack { + Spacer() + Button { + let pasteboard = UIPasteboard.general + pasteboard.string = text + hapticManager.play(haptic: .lightSuccess, priority: .high) + dismiss() + } label: { + Label("Copy All", systemImage: Icons.copyFill) + .font(.footnote) + .fontWeight(.semibold) + } + .foregroundStyle(.white) + .frame(height: 30) + .padding(.horizontal, 12) + .background(Capsule().fill(Color.accentColor)) + CloseButtonView() + } + .padding(.horizontal, 10) + TextEditor(text: .constant(text)) + .introspect(.textEditor, on: .iOS(.v16, .v17)) { textEditor in + textEditor.isEditable = false + textEditor.textContainerInset = .init(top: 0, left: 10, bottom: 10, right: 10) + } + } + .padding(.top, 10) + } +} diff --git a/Mlem/Views/Shared/ToolbarEllipsisMenu.swift b/Mlem/Views/Shared/ToolbarEllipsisMenu.swift new file mode 100644 index 000000000..f29369cb0 --- /dev/null +++ b/Mlem/Views/Shared/ToolbarEllipsisMenu.swift @@ -0,0 +1,26 @@ +// +// ToolbarEllipsisMenu.swift +// Mlem +// +// Created by Sjmarf on 16/03/2024. +// + +import SwiftUI + +struct ToolbarEllipsisMenu: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + Menu { + content + } label: { + Label("More", systemImage: Icons.menuCircle) + .frame(height: AppConstants.barIconHitbox) + .contentShape(Rectangle()) + } + } +} diff --git a/Mlem/Views/Shared/UserList/ModeratorListView.swift b/Mlem/Views/Shared/UserList/ModeratorListView.swift new file mode 100644 index 000000000..47dcb60b9 --- /dev/null +++ b/Mlem/Views/Shared/UserList/ModeratorListView.swift @@ -0,0 +1,108 @@ +// +// ModeratorListView.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-15. +// + +import Dependencies +import Foundation +import SwiftUI + +struct ModeratorListView: View { + @Dependency(\.siteInformation) var siteInformation + @Dependency(\.notifier) var notifier + + @EnvironmentObject var modToolTracker: ModToolTracker + + @Binding var community: CommunityModel + let navigationEnabled: Bool + + @State var isConfirming: Bool = false + @State var confirmingUser: UserModel? + + var confirmingUserName: String { + confirmingUser?.name ?? "user" + } + + var canEditModList: Bool { + siteInformation.myUser?.isAdmin ?? false || + siteInformation.moderatedCommunities.contains(community.communityId) + } + + init(community: Binding, navigationEnabled: Bool = true) { + self._community = community + self.navigationEnabled = navigationEnabled + } + + var body: some View { + content + .alert( + "Remove \(confirmingUserName) as moderator of \(community.name)?", + isPresented: $isConfirming, + presenting: confirmingUser + ) { user in + Button("Cancel", role: .cancel) { + isConfirming = false + } + + Button("Confirm") { + confirmRemoveModerator(user: user) + } + .keyboardShortcut(.defaultAction) + } + } + + var content: some View { + VStack(spacing: 0) { + ModlogNavigationLinkView(to: community) + + Divider() + + if let moderators = community.moderators { + ForEach(moderators, id: \.id) { user in + UserListRow(user, complications: [.date], communityContext: community, navigationEnabled: navigationEnabled) + .addSwipeyActions(genSwipeyActions(for: user)) + Divider() + } + } + + if canEditModList { + Button { + modToolTracker.addModerator(user: nil, to: $community) + } label: { + Label("Add Moderator", systemImage: Icons.add) + } + .accessibilityLabel("Add moderator") + .padding(AppConstants.standardSpacing) + } + } + } + + func genSwipeyActions(for user: UserModel) -> SwipeConfiguration { + // disable swipey actions if user is not admin or moderator + guard canEditModList else { + return .init() + } + + var trailingActions: [SwipeAction] = .init() + + trailingActions.append(.init( + symbol: .init(emptyName: Icons.unmod, fillName: Icons.unmodFill), color: .red + ) { + confirmingUser = user + isConfirming = true + }) + + return SwipeConfiguration(trailingActions: trailingActions) + } + + func confirmRemoveModerator(user: UserModel) { + Task { + _ = await community.updateModStatus(of: user.userId, to: false) { newCommunity in + community = newCommunity + } + await notifier.add(.success("Unmodded \(user.name ?? "user")")) + } + } +} diff --git a/Mlem/Views/Shared/UserList/UserListRow.swift b/Mlem/Views/Shared/UserList/UserListRow.swift new file mode 100644 index 000000000..e66d79abe --- /dev/null +++ b/Mlem/Views/Shared/UserList/UserListRow.swift @@ -0,0 +1,106 @@ +// +// UserListRow.swift +// Mlem +// +// Created by Sjmarf on 23/09/2023. +// + +import Dependencies +import SwiftUI + +enum UserComplication: CaseIterable { + case type, instance, date, posts, comments +} + +extension [UserComplication] { + static let withTypeLabel: [UserComplication] = [.type, .instance, .comments] + static let withoutTypeLabel: [UserComplication] = [.instance, .date, .posts, .comments] + static let instanceOnly: [UserComplication] = [.instance] +} + +struct UserListRow: View { + @Dependency(\.apiClient) private var apiClient + @Dependency(\.hapticManager) var hapticManager + + @EnvironmentObject var modToolTracker: ModToolTracker + + let user: UserModel + let communityContext: CommunityModel? + let showBlockStatus: Bool + let trackerCallback: (_ item: UserModel) -> Void + let swipeActions: SwipeConfiguration? + let complications: [UserComplication] + let navigationEnabled: Bool + + @State private var menuFunctionPopup: MenuFunctionPopup? + + init( + _ user: UserModel, + complications: [UserComplication] = .withoutTypeLabel, + showBlockStatus: Bool = true, + communityContext: CommunityModel? = nil, + swipeActions: SwipeConfiguration? = nil, + navigationEnabled: Bool = true, + trackerCallback: @escaping (_ item: UserModel) -> Void = { _ in } + ) { + self.user = user + self.complications = complications + self.showBlockStatus = showBlockStatus + self.communityContext = communityContext + self.swipeActions = swipeActions + self.navigationEnabled = navigationEnabled + self.trackerCallback = trackerCallback + } + + var body: some View { + userRow + .opacity((user.blocked && showBlockStatus) ? 0.5 : 1) + .buttonStyle(.plain) + .background(.background) + .draggable(user.profileUrl) { + HStack { + AvatarView(user: user, avatarSize: 24) + Text(user.name) + } + .padding(8) + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .destructiveConfirmation(menuFunctionPopup: $menuFunctionPopup) + .addSwipeyActions(swipeActions ?? .init()) + .contextMenu { + ForEach(user.menuFunctions(trackerCallback, modToolTracker: modToolTracker)) { item in + MenuButton(menuFunction: item, menuFunctionPopup: $menuFunctionPopup) + } + } + } + + @ViewBuilder + var userRow: some View { + if navigationEnabled { + NavigationLink(value: AppRoute.userProfile(user, communityContext: communityContext)) { + UserListRowBody( + user: user, + communityContext: communityContext, + complications: complications, + showBlockStatus: showBlockStatus, + navigationEnabled: true + ) + } + } else { + UserListRowBody( + user: user, + communityContext: communityContext, + complications: complications, + showBlockStatus: showBlockStatus, + navigationEnabled: false + ) + } + } +} + +#Preview { + UserListRow( + .init(from: .mock()) + ) +} diff --git a/Mlem/Views/Shared/UserList/UserListRowBody.swift b/Mlem/Views/Shared/UserList/UserListRowBody.swift new file mode 100644 index 000000000..eee4a0483 --- /dev/null +++ b/Mlem/Views/Shared/UserList/UserListRowBody.swift @@ -0,0 +1,128 @@ +// +// UserRow.swift +// Mlem +// +// Created by Eric Andrews on 2024-02-14. +// + +import Foundation +import SwiftUI + +struct UserListRowBody: View { + let user: UserModel + let communityContext: CommunityModel? + let complications: [UserComplication] + var showBlockStatus: Bool = true + let navigationEnabled: Bool + + var title: String { + if user.blocked, showBlockStatus { + return "\(user.displayName!) ∙ Blocked" + } else { + return user.displayName + } + } + + var caption: String { + var parts: [String] = [] + if complications.contains(.type) { + parts.append("User") + } + if complications.contains(.instance), let host = user.profileUrl.host { + parts.append("@\(host)") + } + if complications.contains(.date) { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy" + parts.append(dateFormatter.string(from: user.creationDate)) + } + return parts.joined(separator: " ∙ ") + } + + var body: some View { + content + .padding(.horizontal) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + + var content: some View { + HStack(spacing: AppConstants.standardSpacing) { + if user.blocked, showBlockStatus { + Image(systemName: Icons.hide) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .padding(9) + } else { + AvatarView(user: user, avatarSize: 48, iconResolution: .fixed(128)) + } + let flairs = user.getFlairs(communityContext: communityContext) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + ForEach(flairs, id: \.self) { flair in + Image(systemName: flair.icon) + .imageScale(.small) + .foregroundStyle(flair.color) + } + Text(title) + .lineLimit(1) + } + Text(caption) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + trailingInfo + + if navigationEnabled { + Image(systemName: Icons.forward) + .imageScale(.small) + .foregroundStyle(.tertiary) + } + } + } + + @ViewBuilder + var trailingInfo: some View { + Group { + if complications.contains(.posts), let postCount = user.postCount { + if complications.contains(.comments), let commentCount = user.commentCount { + HStack(spacing: 5) { + VStack(alignment: .trailing, spacing: 6) { + Text(postCount.abbreviated) + .font(.subheadline) + .monospacedDigit() + Text(commentCount.abbreviated) + .font(.subheadline) + .monospacedDigit() + } + .foregroundStyle(.secondary) + VStack(spacing: 10) { + Image(systemName: Icons.posts) + .imageScale(.small) + Image(systemName: Icons.replies) + .imageScale(.small) + } + } + .foregroundStyle(.secondary) + } else { + HStack(spacing: 5) { + Text(postCount.abbreviated) + .monospacedDigit() + Image(systemName: Icons.posts) + } + .foregroundStyle(.secondary) + } + } else if complications.contains(.comments), let commentCount = user.commentCount { + HStack(spacing: 5) { + Text(commentCount.abbreviated) + .monospacedDigit() + Image(systemName: Icons.replies) + } + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Mlem/Views/Shared/WarningView.swift b/Mlem/Views/Shared/WarningView.swift new file mode 100644 index 000000000..a623f8510 --- /dev/null +++ b/Mlem/Views/Shared/WarningView.swift @@ -0,0 +1,52 @@ +// +// WarningView.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-14. +// + +import Foundation +import SwiftUI + +struct WarningView: View { + let iconName: String + let text: String + let inList: Bool + + var body: some View { + VStack(alignment: .center, spacing: 12) { + Image(systemName: iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(.red) + .frame(width: 50) + Text(text) + .font(.headline) + .fontWeight(.medium) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + .padding(inList ? 0 : AppConstants.doubleSpacing) + .listRowBackground(listBackground()) + .background(background()) + } + + @ViewBuilder + func listBackground() -> some View { + if inList { backgroundRect } + } + + @ViewBuilder + func background() -> some View { + if !inList { backgroundRect } + } + + var backgroundRect: some View { + RoundedRectangle(cornerRadius: 10) + .stroke(.red, lineWidth: 3) + .background(Color.red.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/Mlem/Views/Shared/WebView.swift b/Mlem/Views/Shared/WebView.swift new file mode 100644 index 000000000..d42f5610a --- /dev/null +++ b/Mlem/Views/Shared/WebView.swift @@ -0,0 +1,23 @@ +// +// WebView.swift +// Mlem +// +// Created by Sjmarf on 05/02/2024. +// + +import SwiftUI +import WebKit + +struct WebView: UIViewRepresentable { + let url: URL + + func makeUIView(context: Context) -> WKWebView { + let wkwebView = WKWebView() + let request = URLRequest(url: url) + wkwebView.load(request) + return wkwebView + } + + func updateUIView(_ uiView: WKWebView, context: Context) { + } +} diff --git a/Mlem/Views/Shared/Website Icon Complex.swift b/Mlem/Views/Shared/Website Icon Complex.swift index b48d82d67..fabc6a70d 100644 --- a/Mlem/Views/Shared/Website Icon Complex.swift +++ b/Mlem/Views/Shared/Website Icon Complex.swift @@ -63,6 +63,24 @@ struct WebsiteIconComplex: View { var imageHeight: CGFloat { horizontalSizeClass == .regular ? 400 : screenWidth * 0.66 } var body: some View { + if let url = post.linkUrl { + content + .contextMenu { + Button("Open", systemImage: Icons.browser) { + openURL(url) + } + Button("Copy", systemImage: Icons.copy) { + let pasteboard = UIPasteboard.general + pasteboard.url = url + } + ShareLink(item: url) + } preview: { WebView(url: url) } + } else { + content + } + } + + var content: some View { LazyVStack(spacing: 0) { if shouldShowWebsitePreviews, let thumbnailURL = post.thumbnailImageUrl { CachedImage( @@ -121,14 +139,5 @@ struct WebsiteIconComplex: View { } } } - .contextMenu { - if let url = post.linkUrl { - Button("Copy", systemImage: Icons.copy) { - let pasteboard = UIPasteboard.general - pasteboard.url = url - } - ShareLink(item: url) - } - } } } diff --git a/Mlem/Views/Tabs/Feeds/Community List/CommunityListSidebarEntry.swift b/Mlem/Views/Tabs/Feeds/Community List/CommunityListSidebarEntry.swift index f245b059b..4d476e8f6 100644 --- a/Mlem/Views/Tabs/Feeds/Community List/CommunityListSidebarEntry.swift +++ b/Mlem/Views/Tabs/Feeds/Community List/CommunityListSidebarEntry.swift @@ -8,47 +8,7 @@ import Dependencies import Foundation -protocol SidebarEntry { - var sidebarLabel: String? { get } - var sidebarIcon: String? { get } - - func contains(community: APICommunity, isSubscribed: Bool) -> Bool -} - -// Filters no communities, used for top entry in sidebar -struct EmptySidebarEntry: SidebarEntry { - var sidebarLabel: String? - var sidebarIcon: String? - - func contains(community: APICommunity, isSubscribed: Bool) -> Bool { - false - } -} - -// Filters based on community name -struct RegexCommunityNameSidebarEntry: SidebarEntry { - var communityNameRegex: Regex - var sidebarLabel: String? - var sidebarIcon: String? - - func contains(community: APICommunity, isSubscribed: Bool) -> Bool { - // Ignore unsubscribed subs from main list - if !isSubscribed { - return false - } - return community.name.starts(with: communityNameRegex) - } -} - -// Filters to favorited communities -struct FavoritesSidebarEntry: SidebarEntry { - @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker - - var sidebarLabel: String? - var sidebarIcon: String? - - @MainActor - func contains(community: APICommunity, isSubscribed: Bool) -> Bool { - favoriteCommunitiesTracker.isFavorited(community) - } +struct SidebarEntry { + let sidebarLabel: String? + let sidebarIcon: String? } diff --git a/Mlem/Views/Tabs/Feeds/Components/CommunityDetailsView.swift b/Mlem/Views/Tabs/Feeds/Components/CommunityDetailsView.swift index 566aa5e2a..7a48ff568 100644 --- a/Mlem/Views/Tabs/Feeds/Components/CommunityDetailsView.swift +++ b/Mlem/Views/Tabs/Feeds/Components/CommunityDetailsView.swift @@ -32,7 +32,7 @@ struct CommunityDetailsView: View { box { Text("Posts") .foregroundStyle(.secondary) - Text("\(abbreviateNumber(community.postCount ?? 0))") + Text("\((community.postCount ?? 0).abbreviated)") .font(.title) .fontWeight(.semibold) .foregroundStyle(.pink) @@ -41,7 +41,7 @@ struct CommunityDetailsView: View { box { Text("Comments") .foregroundStyle(.secondary) - Text("\(abbreviateNumber(community.commentCount ?? 0))") + Text("\((community.commentCount ?? 0).abbreviated)") .font(.title) .fontWeight(.semibold) .foregroundStyle(.orange) @@ -78,7 +78,7 @@ struct CommunityDetailsView: View { @ViewBuilder func activeUserBox(_ label: String, value: Int) -> some View { VStack { - Text(abbreviateNumber(value)) + Text(value.abbreviated) .font(.title3) .fontWeight(.semibold) Text(label) diff --git a/Mlem/Views/Tabs/Feeds/Components/FeedHeaderView.swift b/Mlem/Views/Tabs/Feeds/Components/FeedHeaderView.swift index 13c4feeba..be079b9d8 100644 --- a/Mlem/Views/Tabs/Feeds/Components/FeedHeaderView.swift +++ b/Mlem/Views/Tabs/Feeds/Components/FeedHeaderView.swift @@ -8,35 +8,39 @@ import Foundation import SwiftUI +protocol FeedType { + var label: String { get } + var subtitle: String { get } + var color: Color? { get } + var iconNameFill: String { get } + var iconScaleFactor: CGFloat { get } +} + struct FeedHeaderView: View { @EnvironmentObject var appState: AppState - let feedType: FeedType + let feedType: any FeedType + let showDropdownIndicator: Bool + let subtitle: String + let showDropdownBadge: Bool - 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" - case .saved: - return "Your saved posts and comments" - default: - assertionFailure("We shouldn't be here...") - return "" - } + init(feedType: any FeedType, showDropdownIndicator: Bool = true, customSubtitle: String? = nil, showDropdownBadge: Bool = false) { + assert( + !showDropdownBadge || showDropdownIndicator, + "showDropdownBadge (\(showDropdownBadge)) cannot be true if showDropdownIndicator (\(showDropdownIndicator)) false!" + ) + + self.feedType = feedType + self.showDropdownIndicator = showDropdownIndicator + self.subtitle = customSubtitle ?? feedType.subtitle + self.showDropdownBadge = showDropdownBadge } var body: some View { VStack(spacing: 0) { - HStack(alignment: .center, spacing: AppConstants.postAndCommentSpacing) { - Image(systemName: feedType.iconNameCircle) - .resizable() - .frame(width: 44, height: 44) - .foregroundStyle(feedType.color ?? .primary) - .padding(.leading, AppConstants.postAndCommentSpacing) + HStack(alignment: .center, spacing: AppConstants.standardSpacing) { + FeedIconView(feedType: feedType, size: 44) + .padding(.leading, AppConstants.standardSpacing) VStack(alignment: .leading, spacing: 0) { HStack(spacing: 5) { @@ -44,8 +48,18 @@ struct FeedHeaderView: View { .lineLimit(1) .minimumScaleFactor(0.01) .fontWeight(.semibold) - Image(systemName: Icons.dropdown) - .foregroundStyle(.secondary) + + if showDropdownIndicator { + Image(systemName: Icons.dropdown) + .foregroundStyle(.secondary) + .overlay(alignment: .topTrailing) { + if showDropdownBadge { + Circle() + .frame(width: 6, height: 6) + .foregroundStyle(.red) + } + } + } } .font(.title2) @@ -57,7 +71,7 @@ struct FeedHeaderView: View { .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 5) - .padding(.bottom, 3) + .padding(.bottom, 5) Divider() } diff --git a/Mlem/Views/Tabs/Feeds/Components/FeedRowView.swift b/Mlem/Views/Tabs/Feeds/Components/FeedRowView.swift index 25c3e60c1..6038dd334 100644 --- a/Mlem/Views/Tabs/Feeds/Components/FeedRowView.swift +++ b/Mlem/Views/Tabs/Feeds/Components/FeedRowView.swift @@ -10,20 +10,33 @@ import Foundation import SwiftUI struct FeedRowView: View { - let feedType: FeedType + let feedType: PostFeedType var body: some View { HStack { - Image(systemName: feedType.iconNameCircle) - .resizable() - .frame(width: 30, height: 30) - .foregroundColor(feedType.color) - + FeedIconView(feedType: feedType, size: 30) Text(feedType.label) } } } +struct FeedIconView: View { + let feedType: any FeedType + let size: CGFloat + + var body: some View { + Circle().fill(feedType.color ?? .blue) + .frame(width: size, height: size) + .overlay { + Image(systemName: feedType.iconNameFill) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(.white) + .frame(width: size * feedType.iconScaleFactor, height: size * feedType.iconScaleFactor) + } + } +} + struct CommunityFeedRowView: View { @Dependency(\.favoriteCommunitiesTracker) var favoriteCommunitiesTracker @Dependency(\.hapticManager) var hapticManager diff --git a/Mlem/Views/Tabs/Feeds/Components/PostFeedView+MenuFunctions.swift b/Mlem/Views/Tabs/Feeds/Components/PostFeedView+MenuFunctions.swift index e8e3cdc71..e46a7d316 100644 --- a/Mlem/Views/Tabs/Feeds/Components/PostFeedView+MenuFunctions.swift +++ b/Mlem/Views/Tabs/Feeds/Components/PostFeedView+MenuFunctions.swift @@ -15,7 +15,6 @@ extension PostFeedView { return MenuFunction.standardMenuFunction( text: type.label, imageName: imageName, - destructiveActionPrompt: nil, enabled: !isSelected ) { postSortType = type @@ -29,53 +28,10 @@ extension PostFeedView { 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/Components/PostFeedView.swift b/Mlem/Views/Tabs/Feeds/Components/PostFeedView.swift index a65dd3a6a..51edfe4ef 100644 --- a/Mlem/Views/Tabs/Feeds/Components/PostFeedView.swift +++ b/Mlem/Views/Tabs/Feeds/Components/PostFeedView.swift @@ -12,6 +12,7 @@ import SwiftUI struct PostFeedView: View { @Dependency(\.errorHandler) var errorHandler @Dependency(\.siteInformation) var siteInformation + @Dependency(\.markReadBatcher) var markReadBatcher @AppStorage("shouldShowPostCreator") var shouldShowPostCreator: Bool = true @AppStorage("showReadPosts") var showReadPosts: Bool = true @@ -19,6 +20,7 @@ struct PostFeedView: View { @AppStorage("postSize") var postSize: PostSize = .large @AppStorage("defaultPostSorting") var defaultPostSorting: PostSortType = .hot @AppStorage("fallbackDefaultPostSorting") var fallbackDefaultPostSorting: PostSortType = .hot + @AppStorage("markReadOnScroll") var markReadOnScroll: Bool = false @EnvironmentObject var postTracker: StandardPostTracker @EnvironmentObject var appState: AppState @@ -68,6 +70,8 @@ struct PostFeedView: View { defer { suppressNoPostsView = false } if let versionSafePostSort { + await markReadBatcher.flush() + await postTracker.changeSortType( to: versionSafePostSort, forceRefresh: postTracker.items.isEmpty @@ -76,20 +80,12 @@ struct PostFeedView: View { } .toolbar { if versionSafePostSort != nil { - 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) - } - } + ToolbarItem(placement: .topBarTrailing) { sortMenu } + } + } + .onDisappear { + Task { + await markReadBatcher.flush() } } } @@ -99,8 +95,30 @@ struct PostFeedView: View { if postTracker.items.isEmpty || versionSafePostSort == nil || postTracker.isStale { noPostsView() } else { - ForEach(postTracker.items, id: \.uid) { feedPost(for: $0) } - EndOfFeedView(loadingState: postTracker.loadingState, viewType: .hobbit) + ForEach(Array(postTracker.items.enumerated()), id: \.element.uid) { index, element in + feedPost(for: element) + .task { + if markReadOnScroll, markReadBatcher.enabled { + // mark the post above (or several posts above) read when this post appears. This lets us get a rough "post crossed the middle of the screen" trigger without GeometryReader or timers or any of that + let indexToMark = index >= postSize.markReadThreshold ? index - postSize.markReadThreshold : index + + if let postToMark = postTracker.items[safeIndex: indexToMark] { + await markReadBatcher.stage(postToMark.postId) + if postTracker.items.count - index <= postSize.markReadThreshold { + await markReadBatcher.stage(element.postId) + } + } + } + } + .onDisappear { + if markReadOnScroll { + Task { + await markReadBatcher.add(post: element) + } + } + } + } + EndOfFeedView(loadingState: postTracker.loadingState, viewType: .hobbit, whatIsLoading: .posts) } } } @@ -108,7 +126,7 @@ struct PostFeedView: View { @ViewBuilder private func feedPost(for post: PostModel) -> some View { VStack(spacing: 0) { - NavigationLink(.postLinkWithContext(.init(post: post, community: nil, postTracker: postTracker))) { + NavigationLink(.postLinkWithContext(.init(post: post, community: communityContext, postTracker: postTracker))) { FeedPost( post: post, postTracker: postTracker, @@ -120,7 +138,9 @@ struct PostFeedView: View { Divider() } - .onAppear { postTracker.loadIfThreshold(post) } + .onAppear { + postTracker.loadIfThreshold(post) + } .buttonStyle(EmptyButtonStyle()) // Make it so that the link doesn't mess with the styling } @@ -151,12 +171,12 @@ struct PostFeedView: View { private var sortMenu: some View { Menu { ForEach(genOuterSortMenuFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) // no destructive sorts + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) // no destructive sorts } Menu { ForEach(genTopSortMenuFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) // no destructive sorts + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) // no destructive sorts } } label: { Label("Top...", systemImage: Icons.topSort) diff --git a/Mlem/Views/Tabs/Feeds/Components/UserContentFeedView+Logic.swift b/Mlem/Views/Tabs/Feeds/Components/UserContentFeedView+Logic.swift deleted file mode 100644 index 149f66c1b..000000000 --- a/Mlem/Views/Tabs/Feeds/Components/UserContentFeedView+Logic.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// UserContentFeedView+Logic.swift -// Mlem -// -// Created by Eric Andrews on 2024-01-28. -// - -import Foundation - -extension UserContentFeedView { - 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() - }) - - 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/Components/UserContentFeedView.swift b/Mlem/Views/Tabs/Feeds/Components/UserContentFeedView.swift index eae95f5a5..d93b08199 100644 --- a/Mlem/Views/Tabs/Feeds/Components/UserContentFeedView.swift +++ b/Mlem/Views/Tabs/Feeds/Components/UserContentFeedView.swift @@ -9,6 +9,12 @@ import Dependencies import Foundation import SwiftUI +enum UserContentFeedType: String, CaseIterable, Identifiable { + case all, posts, comments + + var id: String { rawValue } +} + struct UserContentFeedView: View { @Dependency(\.errorHandler) var errorHandler @Dependency(\.siteInformation) var siteInformation @@ -24,24 +30,23 @@ struct UserContentFeedView: View { @State var errorDetails: ErrorDetails? + var contentType: UserContentFeedType = .all + + var items: [UserContentModel] { + switch contentType { + case .all: + userContentTracker.items + case .posts: + userContentTracker.items.filter { $0.uid.contentType == .post } + case .comments: + userContentTracker.items.filter { $0.uid.contentType == .comment } + } + } + var body: some View { content .animation(.easeOut(duration: 0.2), value: userContentTracker.items.isEmpty) .task { await userContentTracker.loadMoreItems() } - .toolbar { - ToolbarItemGroup(placement: .secondaryAction) { - ForEach(genEllipsisMenuFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) - } - Menu { - ForEach(genPostSizeSwitchingFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) - } - } label: { - Label("Post Size", systemImage: Icons.postSizeSetting) - } - } - } } var content: some View { @@ -49,8 +54,8 @@ struct UserContentFeedView: View { if userContentTracker.items.isEmpty { noPostsView() } else { - ForEach(userContentTracker.items, id: \.uid) { feedItem(for: $0) } - EndOfFeedView(loadingState: userContentTracker.loadingState, viewType: .hobbit) + ForEach(items, id: \.uid) { feedItem(for: $0) } + EndOfFeedView(loadingState: userContentTracker.loadingState, viewType: .hobbit, whatIsLoading: .posts) } } } @@ -70,7 +75,7 @@ struct UserContentFeedView: View { private func feedPost(for postModel: PostModel) -> some View { VStack(spacing: 0) { // NavigationLink(.postLinkWithContext(.init(post: post, community: nil, postTracker: nil))) { - NavigationLink(.lazyLoadPostLinkWithContext(.init(post: postModel.post))) { + NavigationLink(.lazyLoadPostLinkWithContext(.init(postId: postModel.postId))) { FeedPost( post: postModel, postTracker: nil, // TODO: enable filtering on these posts--low priority because sort of silly to filter your saved feed @@ -89,7 +94,7 @@ struct UserContentFeedView: View { private func feedComment(for hierarchicalComment: HierarchicalComment) -> some View { VStack(spacing: 0) { NavigationLink(.lazyLoadPostLinkWithContext(.init( - post: hierarchicalComment.commentView.post, + postId: hierarchicalComment.commentView.post.id, scrollTarget: hierarchicalComment.id ))) { CommentItem( diff --git a/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView+Logic.swift b/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView+Logic.swift index 46a3a0f3a..16d201e1a 100644 --- a/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView+Logic.swift +++ b/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView+Logic.swift @@ -17,8 +17,6 @@ extension AggregateFeedView { ret.append(MenuFunction.standardMenuFunction( text: type.label, imageName: imageName, - destructiveActionPrompt: nil, - enabled: enabled, callback: { // when switching back from the saved feed, stale items are sometimes present in the post tracker; this ensures that those are not displayed if selectedFeed == .saved, type != postTracker.feedType { diff --git a/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView.swift b/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView.swift index b4dae63a3..700e16a8b 100644 --- a/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView.swift +++ b/Mlem/Views/Tabs/Feeds/Feed Types/AggregateFeedView.swift @@ -12,6 +12,8 @@ import SwiftUI /// View for post feeds aggregating multiple communities (all, local, subscribed, saved) struct AggregateFeedView: View { @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation + @Dependency(\.markReadBatcher) var markReadBatcher @Environment(\.dismiss) var dismiss @Environment(\.scrollViewProxy) var scrollProxy @@ -23,9 +25,9 @@ struct AggregateFeedView: View { @StateObject var savedContentTracker: UserContentTracker @State var postSortType: PostSortType - @State var availableFeeds: [FeedType] = [.all, .local, .subscribed] - @Binding var selectedFeed: FeedType? + @Binding var selectedFeed: PostFeedType? + @State var selectedSavedTab: UserContentFeedType = .all @Namespace var scrollToTop @State private var scrollToTopAppeared = false @@ -33,8 +35,8 @@ struct AggregateFeedView: View { postTracker.items.first?.id } - init(selectedFeed: Binding) { - var feedType: FeedType = .all + init(selectedFeed: Binding) { + var feedType: PostFeedType = .all if let selectedFeed = selectedFeed.wrappedValue { feedType = selectedFeed } else { @@ -61,18 +63,24 @@ struct AggregateFeedView: View { self._selectedFeed = selectedFeed } + var availableFeeds: [PostFeedType] { + var availableFeeds: [PostFeedType] = [.all, .local, .subscribed] + if siteInformation.moderatorFeedAvailable { + availableFeeds.append(.moderated) + } + if appState.currentActiveAccount != nil, !availableFeeds.contains(.saved) { + availableFeeds.append(.saved) + } + return availableFeeds + } + var body: some View { content .environment(\.feedType, selectedFeed) .task(id: appState.currentActiveAccount) { - // ensure that .saved isn't an available feed until user id resolved if let userId = appState.currentActiveAccount?.id { do { try await savedContentTracker.updateUserId(to: userId) - - if availableFeeds.count < 4 { - availableFeeds.append(.saved) - } } catch { errorHandler.handle(error) } @@ -81,7 +89,8 @@ struct AggregateFeedView: View { .task(id: selectedFeed) { if let selectedFeed { switch selectedFeed { - case .all, .local, .subscribed: + case .all, .local, .moderated, .subscribed: + await markReadBatcher.flush() await postTracker.changeFeedType(to: selectedFeed) postTracker.isStale = false default: @@ -93,7 +102,8 @@ struct AggregateFeedView: View { await Task { do { switch selectedFeed { - case .all, .local, .subscribed: + case .all, .local, .moderated, .subscribed: + await markReadBatcher.flush() _ = try await postTracker.refresh(clearBeforeRefresh: false) case .saved: _ = try await savedContentTracker.refresh(clearBeforeRefresh: false) @@ -115,6 +125,11 @@ struct AggregateFeedView: View { .opacity(scrollToTopAppeared ? 0 : 1) .animation(.easeOut(duration: 0.2), value: scrollToTopAppeared) } + ToolbarItem(placement: .primaryAction) { + ToolbarEllipsisMenu { + FeedToolbarContent() + } + } } .hoistNavigation { if let scrollProxy { @@ -136,15 +151,22 @@ struct AggregateFeedView: View { ScrollToView(appeared: $scrollToTopAppeared) .id(scrollToTop) headerView - .padding(.top, -1) + if selectedFeed == .saved { + BubblePicker( + UserContentFeedType.allCases, + selected: $selectedSavedTab, + withDividers: [.top, .bottom], + label: \.rawValue.capitalized + ) + } } switch selectedFeed { - case .all, .local, .subscribed: + case .all, .local, .moderated, .subscribed: PostFeedView(postSortType: $postSortType, showCommunity: true) .environmentObject(postTracker) case .saved: - UserContentFeedView() + UserContentFeedView(contentType: selectedSavedTab) .environmentObject(savedContentTracker) default: EmptyView() // shouldn't be possible @@ -157,10 +179,10 @@ struct AggregateFeedView: View { var headerView: some View { Menu { ForEach(genFeedSwitchingFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) } } label: { - if let selectedFeed, FeedType.allAggregateFeedCases.contains(selectedFeed) { + if let selectedFeed, PostFeedType.allAggregateFeedCases.contains(selectedFeed) { FeedHeaderView(feedType: selectedFeed) } else { EmptyView() // shouldn't be possible @@ -173,7 +195,7 @@ struct AggregateFeedView: View { var navBarTitle: some View { Menu { ForEach(genFeedSwitchingFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) } } label: { HStack(alignment: .center, spacing: 0) { diff --git a/Mlem/Views/Tabs/Feeds/Feed Types/CommunityFeedView.swift b/Mlem/Views/Tabs/Feeds/Feed Types/CommunityFeedView.swift index 0b5939dc8..c39c92393 100644 --- a/Mlem/Views/Tabs/Feeds/Feed Types/CommunityFeedView.swift +++ b/Mlem/Views/Tabs/Feeds/Feed Types/CommunityFeedView.swift @@ -15,7 +15,7 @@ import SwiftUI struct CommunityFeedView: View { enum Tab: String, Identifiable, CaseIterable { var id: Self { self } - case posts, about, moderators, details + case posts, about, moderation, details } @AppStorage("shouldShowCommunityHeaders") var shouldShowCommunityHeaders: Bool = true @@ -24,6 +24,7 @@ struct CommunityFeedView: View { @Dependency(\.errorHandler) var errorHandler @Dependency(\.hapticManager) var hapticManager @Dependency(\.communityRepository) var communityRepository + @Dependency(\.siteInformation) var siteInformation @Environment(\.dismiss) var dismiss @Environment(\.scrollViewProxy) var scrollProxy @@ -31,6 +32,7 @@ struct CommunityFeedView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var editorTracker: EditorTracker + @EnvironmentObject var modToolTracker: ModToolTracker @StateObject var postTracker: StandardPostTracker @@ -39,14 +41,7 @@ struct CommunityFeedView: View { @State var communityModel: CommunityModel - // destructive confirmation - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? - - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } + @State private var menuFunctionPopup: MenuFunctionPopup? // scroll to top @Namespace var scrollToTop @@ -56,7 +51,7 @@ struct CommunityFeedView: View { } var availableTabs: [Tab] { - var output: [Tab] = [.posts, .moderators, .details] + var output: [Tab] = [.posts, .moderation, .details] if communityModel.description != nil { output.insert(.about, at: 1) } @@ -96,16 +91,15 @@ struct CommunityFeedView: View { .refreshable { await Task { do { + communityModel = try await communityRepository.loadDetails(for: communityModel.communityId) + print(communityModel.moderators?.count) _ = try await postTracker.refresh(clearBeforeRefresh: false) } catch { errorHandler.handle(error) } }.value } - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) + .destructiveConfirmation(menuFunctionPopup: $menuFunctionPopup) .fancyTabScrollCompatible() .toolbar { ToolbarItem(placement: .principal) { @@ -115,19 +109,20 @@ struct CommunityFeedView: View { .animation(.easeOut(duration: 0.2), value: scrollToTopAppeared) } - ToolbarItemGroup(placement: .secondaryAction) { - ForEach( - communityModel.menuFunctions( - editorTracker: editorTracker, - postTracker: postTracker - ) { communityModel = $0 } - ) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: confirmDestructive) + ToolbarItemGroup(placement: .primaryAction) { + ToolbarEllipsisMenu { + ForEach( + communityModel.menuFunctions( + editorTracker: editorTracker, + postTracker: postTracker, + modToolTracker: modToolTracker + ) { communityModel = $0 } + ) { item in + MenuButton(menuFunction: item, menuFunctionPopup: $menuFunctionPopup) + } + Divider() + FeedToolbarContent() } - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) } } .hoistNavigation { @@ -155,7 +150,7 @@ struct CommunityFeedView: View { switch selectedTab { case .posts: posts() case .about: about() - case .moderators: moderators() + case .moderation: ModeratorListView(community: $communityModel) case .details: details() } } @@ -168,23 +163,13 @@ struct CommunityFeedView: View { } func about() -> some View { - VStack(spacing: AppConstants.postAndCommentSpacing) { + VStack(spacing: AppConstants.standardSpacing) { if shouldShowCommunityHeaders, let banner = communityModel.banner { CachedImage(url: banner, cornerRadius: AppConstants.largeItemCornerRadius) } MarkdownView(text: communityModel.description ?? "", isNsfw: false) } - .padding(AppConstants.postAndCommentSpacing) - } - - @ViewBuilder - func moderators() -> some View { - if let moderators = communityModel.moderators { - ForEach(moderators, id: \.id) { user in - UserResultView(user, communityContext: communityModel) - Divider() - } - } + .padding(AppConstants.standardSpacing) } func details() -> some View { @@ -205,8 +190,8 @@ struct CommunityFeedView: View { @ViewBuilder var headerView: some View { Group { - VStack(spacing: 5) { - HStack(alignment: .center, spacing: 10) { + VStack(spacing: AppConstants.standardSpacing) { + HStack(alignment: .center, spacing: AppConstants.standardSpacing) { if shouldShowCommunityIcons { AvatarView(community: communityModel, avatarSize: 44, iconResolution: .unrestricted) } @@ -228,16 +213,18 @@ struct CommunityFeedView: View { } .buttonStyle(.plain) Spacer() + subscribeButton } - .padding(.horizontal, AppConstants.postAndCommentSpacing) - .padding(.bottom, 3) - Divider() - BubblePicker(availableTabs, selected: $selectedTab) { - Text($0.rawValue.capitalized) - } + .padding(.horizontal, AppConstants.standardSpacing) + + BubblePicker( + availableTabs, + selected: $selectedTab, + withDividers: [.top, .bottom], + label: \.rawValue.capitalized + ) } - Divider() } } @@ -270,22 +257,12 @@ struct CommunityFeedView: View { @ViewBuilder var subscribeButton: some View { - let foregroundColor = subscribeButtonForegroundColor if let subscribed = communityModel.subscribed { - HStack(spacing: 4) { - if let subscriberCount = communityModel.subscriberCount { - Text(abbreviateNumber(subscriberCount)) - } - Image(systemName: subscribeButtonIcon) - .aspectRatio(contentMode: .fit) - } - .foregroundStyle(foregroundColor) - .padding(.vertical, 5) - .padding(.horizontal, 10) - .background( - Capsule() - .strokeBorder(foregroundColor, style: .init(lineWidth: 1)) - .background(Capsule().fill(subscribeButtonBackgroundColor)) + capsuleButton( + text: communityModel.subscriberCount?.abbreviated, + imageName: subscribeButtonIcon, + foregroundColor: subscribeButtonForegroundColor, + backgroundColor: subscribeButtonBackgroundColor ) .gesture(TapGesture().onEnded { _ in hapticManager.play(haptic: .lightSuccess, priority: .low) @@ -293,11 +270,36 @@ struct CommunityFeedView: View { Task { do { if communityModel.favorited { - print("favorited") - confirmDestructive(destructiveFunction: communityModel.favoriteMenuFunction { communityModel = $0 }) + menuFunctionPopup = .init( + prompt: "Are you sure you want to unfavorite \(communityModel.name!)?", + actions: [.init(text: "Yes", callback: { + Task { + do { + try await communityModel.toggleFavorite { item in + DispatchQueue.main.async { communityModel = item } + } + } catch { + errorHandler.handle(error) + } + } + })] + ) + } else if subscribed { - print("subscribed") - try confirmDestructive(destructiveFunction: communityModel.subscribeMenuFunction { communityModel = $0 }) + menuFunctionPopup = .init( + prompt: "Are you sure you want to unsubscribe from \(communityModel.name!)?", + actions: [.init(text: "Yes", callback: { + Task { + do { + try await communityModel.toggleSubscribe { item in + DispatchQueue.main.async { communityModel = item } + } + } catch { + errorHandler.handle(error) + } + } + })] + ) } else { print("not subscribed") try await communityModel.toggleSubscribe { item in @@ -324,6 +326,26 @@ struct CommunityFeedView: View { }) } } + + func capsuleButton(text: String?, imageName: String, foregroundColor: Color, backgroundColor: Color) -> some View { + HStack(spacing: 4) { + if let text { + Text(text) + } + + Image(systemName: imageName) + } + .foregroundStyle(foregroundColor) + .padding(.vertical, AppConstants.halfSpacing) + .padding(.horizontal, AppConstants.standardSpacing) + .background { + Capsule() + .strokeBorder(foregroundColor, style: .init(lineWidth: 1)) + .background { + Capsule().fill(backgroundColor) + } + } + } } // swiftlint:enable type_body_length diff --git a/Mlem/Views/Tabs/Feeds/Feed Types/FeedToolbarContent.swift b/Mlem/Views/Tabs/Feeds/Feed Types/FeedToolbarContent.swift new file mode 100644 index 000000000..cb5f18611 --- /dev/null +++ b/Mlem/Views/Tabs/Feeds/Feed Types/FeedToolbarContent.swift @@ -0,0 +1,85 @@ +// +// FeedToolbarContent.swift +// Mlem +// +// Created by Sjmarf on 16/03/2024. +// + +import Dependencies +import SwiftUI + +struct FeedToolbarContent: View { + @Dependency(\.siteInformation) var siteInformation + @AppStorage("showReadPosts") var showReadPosts: Bool = true + @AppStorage("shouldBlurNsfw") var shouldBlurNsfw: Bool = true + @AppStorage("postSize") var postSize: PostSize = .large + + var body: some View { + ForEach(genEllipsisMenuFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + Menu { + ForEach(genPostSizeSwitchingFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + } label: { + Label("Post Size", systemImage: Icons.postSizeSetting) + } + } + + func genEllipsisMenuFunctions() -> [MenuFunction] { + var body: some ToolbarContent { + ToolbarItemGroup(placement: .secondaryAction) { + ForEach(genEllipsisMenuFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + Menu { + ForEach(genPostSizeSwitchingFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + } label: { + Label("Post Size", systemImage: Icons.postSizeSetting) + } + } + } + + var ret: [MenuFunction] = .init() + + if siteInformation.myUserInfo?.localUserView.localUser.showNsfw ?? true { + let blurNsfwText = shouldBlurNsfw ? "Unblur NSFW" : "Blur NSFW" + ret.append(MenuFunction.standardMenuFunction( + text: blurNsfwText, + imageName: Icons.blurNsfw, + enabled: true + ) { + shouldBlurNsfw.toggle() + }) + } + + let showReadPostsText = showReadPosts ? "Hide Read" : "Show Read" + ret.append(MenuFunction.standardMenuFunction( + text: showReadPostsText, + imageName: "book", + 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, + enabled: enabled, + callback: { postSize = size } + ) + } + } +} diff --git a/Mlem/Views/Tabs/Feeds/FeedsView.swift b/Mlem/Views/Tabs/Feeds/FeedsView.swift index d82bd24b3..14a5f6f5c 100644 --- a/Mlem/Views/Tabs/Feeds/FeedsView.swift +++ b/Mlem/Views/Tabs/Feeds/FeedsView.swift @@ -5,10 +5,12 @@ // Created by Eric Andrews on 2024-01-07. // +import Dependencies import Foundation import SwiftUI struct FeedsView: View { + @Dependency(\.siteInformation) var siteInformation @AppStorage("defaultFeed") var defaultFeed: DefaultFeedType = .subscribed @Environment(\.scenePhase) var scenePhase @@ -16,7 +18,7 @@ struct FeedsView: View { @EnvironmentObject var appState: AppState - @State private var selectedFeed: FeedType? + @State private var selectedFeed: PostFeedType? @State var appeared: Bool = false // tracks whether this is the view's first appearance @StateObject private var communityListModel: CommunityListModel = .init() @@ -40,7 +42,7 @@ struct FeedsView: View { } } .onChange(of: scenePhase) { newPhase in - if newPhase == .active, let shortcutItem = FeedType.fromShortcutString(shortcut: shortcutItemToProcess?.type) { + if newPhase == .active, let shortcutItem = PostFeedType.fromShortcutString(shortcut: shortcutItemToProcess?.type) { selectedFeed = shortcutItem } } @@ -53,7 +55,7 @@ struct FeedsView: View { // Note that NavigationLinks in here update selectedFeed and are handled by the detail switch, not the general navigation handler ZStack(alignment: .trailing) { List(selection: $selectedFeed) { - ForEach([FeedType.all, FeedType.local, FeedType.subscribed, FeedType.saved]) { feedType in + ForEach(siteInformation.feeds) { feedType in NavigationLink(value: feedType) { FeedRowView(feedType: feedType) } @@ -61,28 +63,20 @@ struct FeedsView: View { .id(scrollToTop) // using this instead of ScrollToView because ScrollToView renders as an empty list item .padding(.trailing, 10) - ForEach(communityListModel.visibleSections) { section in - Section(header: communitySectionHeaderView(for: section)) { - ForEach(communityListModel.communities(for: section)) { community in - NavigationLink(value: FeedType.community(.init(from: community, subscribed: true))) { - CommunityFeedRowView( - community: community, - subscribed: communityListModel.isSubscribed(to: community), - communitySubscriptionChanged: communityListModel.updateSubscriptionStatus, - navigationContext: .sidebar - ) - } - } - } - } - .padding(.trailing, 10) + communitySections + .padding(.trailing, 10) } .scrollIndicators(.hidden) .navigationTitle("Feeds") .listStyle(PlainListStyle()) + .refreshable { + await Task { + await communityListModel.load() + }.value + } .fancyTabScrollCompatible() - SectionIndexTitles(proxy: scrollProxy, communitySections: communityListModel.allSections()) + SectionIndexTitles(proxy: scrollProxy, communitySections: communityListModel.allSections) } .onChange(of: tabReselectionHashValue) { newValue in // due to NavigationSplitView weirdness, the normal .hoistNavigation doesn't work here, so we do it manually @@ -110,10 +104,28 @@ struct FeedsView: View { } } + @ViewBuilder + var communitySections: some View { + ForEach(communityListModel.visibleSections) { section in + Section(header: communitySectionHeaderView(for: section)) { + ForEach(section.communities) { community in + NavigationLink(value: PostFeedType.community(.init(from: community, subscribed: true))) { + CommunityFeedRowView( + community: community, + subscribed: communityListModel.isSubscribed(to: community), + communitySubscriptionChanged: updateSubscriptionStatus, // communityListModel.updateSubscriptionStatus, + navigationContext: .sidebar + ) + } + } + } + } + } + @ViewBuilder private var navStackView: some View { switch selectedFeed { - case .all, .local, .subscribed, .saved: + case .all, .local, .subscribed, .moderated, .saved: AggregateFeedView(selectedFeed: $selectedFeed) case let .community(communityModel): CommunityFeedView(communityModel: communityModel) @@ -123,6 +135,12 @@ struct FeedsView: View { } } + private func updateSubscriptionStatus(_ community: APICommunity, _ status: Bool) { + Task { + await communityListModel.updateSubscriptionStatus(for: community, subscribed: status) + } + } + private func communitySectionHeaderView(for section: CommunityListSection) -> some View { HStack { Text(section.inlineHeaderLabel!) diff --git a/Mlem/Views/Tabs/Inbox/Feed/AllItemsFeedView.swift b/Mlem/Views/Tabs/Inbox/Feed/AllItemsFeedView.swift deleted file mode 100644 index 7fe50794b..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/AllItemsFeedView.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// AllItemsFeedView.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Foundation -import SwiftUI - -struct AllItemsFeedView: View { - @ObservedObject var inboxTracker: ParentTracker - - var body: some View { - Group { - if inboxTracker.items.isEmpty, inboxTracker.loadingState == .loading { - LoadingView(whatIsLoading: .inbox) - } else if inboxTracker.items.isEmpty { - noItemsView() - } else { - LazyVStack(spacing: 0) { - EmptyView().id("top") - inboxListView() - } - } - } - } - - @ViewBuilder - func noItemsView() -> some View { - VStack(alignment: .center, spacing: 5) { - Image(systemName: Icons.noPosts) - - Text("No items to be found") - } - .padding() - .foregroundColor(.secondary) - } - - // NOTE: this view is sometimes a little bit tetchy, and will refuse to compile for literally no reason. If that happens, copy it, - // delete it, recompile, paste it, and it should work. Go figure. - @ViewBuilder - func inboxListView() -> some View { - ForEach(inboxTracker.items, id: \.uid) { item in - VStack(spacing: 0) { - inboxItemView(item: item) - - Divider() - } - } - - EndOfFeedView(loadingState: inboxTracker.loadingState, viewType: .cartoon) - } - - @ViewBuilder - func inboxItemView(item: AnyInboxItem) -> some View { - Group { - switch item { - case let .message(message): - InboxMessageView(message: message) - case let .mention(mention): - InboxMentionView(mention: mention) - case let .reply(reply): - InboxReplyView(reply: reply) - } - } - .onAppear { - inboxTracker.loadIfThreshold(item) - } - } -} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox ReplyBodyView.swift b/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox ReplyBodyView.swift deleted file mode 100644 index 08e38ec73..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox ReplyBodyView.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// InboxReplyBodyView.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-25. -// - -import SwiftUI - -struct InboxReplyBodyView: View { - @ObservedObject var reply: ReplyModel - @EnvironmentObject var inboxTracker: InboxTracker - @EnvironmentObject var editorTracker: EditorTracker - @EnvironmentObject var unreadTracker: UnreadTracker - - var voteIconName: String { reply.votes.myVote == .downvote ? Icons.downvote : Icons.upvote } - var iconName: String { reply.commentReply.read ? "arrowshape.turn.up.right" : "arrowshape.turn.up.right.fill" } - - var body: some View { - NavigationLink(.lazyLoadPostLinkWithContext(.init( - post: reply.post, - scrollTarget: reply.comment.id - ))) { - content - .padding(AppConstants.postAndCommentSpacing) - .background(Color(uiColor: .systemBackground)) - .contentShape(Rectangle()) - .contextMenu { - ForEach(reply.menuFunctions( - unreadTracker: unreadTracker, - editorTracker: editorTracker - )) { item in - MenuButton(menuFunction: item, confirmDestructive: nil) - } - } - } - .buttonStyle(EmptyButtonStyle()) - } - - var content: some View { - VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { - Text(reply.post.name) - .font(.headline) - .padding(.bottom, AppConstants.postAndCommentSpacing) - - UserLinkView(user: reply.creator, serverInstanceLocation: ServerInstanceLocation.bottom, overrideShowAvatar: true) - .font(.subheadline) - - HStack(alignment: .top, spacing: AppConstants.postAndCommentSpacing) { - Image(systemName: iconName) - .foregroundColor(.accentColor) - .frame(width: AppConstants.largeAvatarSize) - - MarkdownView(text: reply.comment.content, isNsfw: false) - .font(.subheadline) - } - - CommunityLinkView(community: reply.community) - - HStack { - HStack(spacing: 4) { - Image(systemName: voteIconName) - Text(reply.votes.total.description) - } - .foregroundColor(reply.votes.myVote.color ?? .secondary) - .onTapGesture { - Task(priority: .userInitiated) { - await reply.vote(inputOp: .upvote, unreadTracker: unreadTracker) - } - } - - EllipsisMenu( - size: AppConstants.largeAvatarSize, - menuFunctions: reply.menuFunctions(unreadTracker: unreadTracker, editorTracker: editorTracker) - ) - - Spacer() - - PublishedTimestampView(date: reply.commentReply.published) - } - } - } -} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMentionBodyView.swift b/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMentionBodyView.swift deleted file mode 100644 index 0ec8539b2..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMentionBodyView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// InboxMentionBodyView.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-25. -// - -import SwiftUI - -struct InboxMentionBodyView: View { - @ObservedObject var mention: MentionModel - @EnvironmentObject var inboxTracker: InboxTracker - @EnvironmentObject var editorTracker: EditorTracker - @EnvironmentObject var unreadTracker: UnreadTracker - - var voteIconName: String { mention.votes.myVote == .downvote ? Icons.downvote : Icons.upvote } - var iconName: String { mention.personMention.read ? "quote.bubble" : "quote.bubble.fill" } - - var body: some View { - NavigationLink(.lazyLoadPostLinkWithContext(.init( - post: mention.post, - scrollTarget: mention.comment.id - ))) { - content - .padding(AppConstants.postAndCommentSpacing) - .background(Color(uiColor: .systemBackground)) - .contentShape(Rectangle()) - .contextMenu { - ForEach(mention.menuFunctions( - unreadTracker: unreadTracker, - editorTracker: editorTracker - )) { item in - MenuButton(menuFunction: item, confirmDestructive: nil) - } - } - } - .buttonStyle(EmptyButtonStyle()) - } - - var content: some View { - VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { - Text(mention.post.name) - .font(.headline) - .padding(.bottom, AppConstants.postAndCommentSpacing) - - UserLinkView( - user: mention.creator, - serverInstanceLocation: .bottom, - overrideShowAvatar: true - ) - .font(.subheadline) - - HStack(alignment: .top, spacing: AppConstants.postAndCommentSpacing) { - Image(systemName: iconName) - .foregroundColor(.accentColor) - .frame(width: AppConstants.largeAvatarSize) - - MarkdownView(text: mention.comment.content, isNsfw: false) - .font(.subheadline) - } - - CommunityLinkView(community: mention.community) - - HStack { - HStack(spacing: 4) { - Image(systemName: voteIconName) - Text(mention.votes.total.description) - } - .foregroundColor(mention.votes.myVote.color ?? .secondary) - .onTapGesture { - Task(priority: .userInitiated) { - await mention.vote(inputOp: .upvote, unreadTracker: unreadTracker) - } - } - - EllipsisMenu( - size: AppConstants.largeAvatarSize, - menuFunctions: mention.menuFunctions(unreadTracker: unreadTracker, editorTracker: editorTracker) - ) - - Spacer() - - PublishedTimestampView(date: mention.comment.published) - } - } - } -} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMentionView.swift b/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMentionView.swift deleted file mode 100644 index e377ea776..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMentionView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// InboxMentionView.swift -// Mlem -// -// Created by Bosco Ho on 2023-12-22. -// - -import SwiftUI - -struct InboxMentionView: View { - @ObservedObject var mention: MentionModel - @EnvironmentObject var editorTracker: EditorTracker - @EnvironmentObject var unreadTracker: UnreadTracker - - var body: some View { - InboxMentionBodyView(mention: mention) - .addSwipeyActions( - mention.swipeActions( - unreadTracker: unreadTracker, - editorTracker: editorTracker - ) - ) - } -} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMessageBodyView.swift b/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMessageBodyView.swift deleted file mode 100644 index b7fd740f9..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMessageBodyView.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// InboxMessageBodyView.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-25. -// - -import SwiftUI - -struct InboxMessageBodyView: View { - @ObservedObject var message: MessageModel - @EnvironmentObject var inboxTracker: InboxTracker - @EnvironmentObject var editorTracker: EditorTracker - @EnvironmentObject var unreadTracker: UnreadTracker - - var iconName: String { message.privateMessage.read ? "envelope.open" : "envelope.fill" } - - init(message: MessageModel) { - self.message = message - } - - var body: some View { - content - .padding(AppConstants.postAndCommentSpacing) - .background(Color(uiColor: .systemBackground)) - .contentShape(Rectangle()) - .contextMenu { - ForEach(message.menuFunctions( - unreadTracker: unreadTracker, - editorTracker: editorTracker - )) { item in - MenuButton(menuFunction: item, confirmDestructive: nil) - } - } - } - - var content: some View { - VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { - Text("Direct message") - .font(.headline.smallCaps()) - .padding(.bottom, AppConstants.postAndCommentSpacing) - - HStack(alignment: .top, spacing: AppConstants.postAndCommentSpacing) { - Image(systemName: iconName) - .foregroundColor(.accentColor) - .frame(width: AppConstants.largeAvatarSize, height: AppConstants.largeAvatarSize) - - MarkdownView(text: message.privateMessage.content, isNsfw: false) - .font(.subheadline) - } - - UserLinkView( - user: message.creator, - serverInstanceLocation: .bottom, - overrideShowAvatar: true - ) - .font(.subheadline) - - HStack { - EllipsisMenu( - size: AppConstants.largeAvatarSize, - menuFunctions: message.menuFunctions( - unreadTracker: unreadTracker, - editorTracker: editorTracker - ) - ) - - Spacer() - - PublishedTimestampView(date: message.privateMessage.published) - } - } - } -} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxReplyView.swift b/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxReplyView.swift deleted file mode 100644 index eaa495f1c..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxReplyView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// InboxReplyView.swift -// Mlem -// -// Created by Bosco Ho on 2023-12-21. -// - -import SwiftUI - -struct InboxReplyView: View { - @ObservedObject var reply: ReplyModel - @EnvironmentObject var editorTracker: EditorTracker - @EnvironmentObject var unreadTracker: UnreadTracker - - var body: some View { - InboxReplyBodyView(reply: reply) - .addSwipeyActions( - reply.swipeActions( - unreadTracker: unreadTracker, - editorTracker: editorTracker - ) - ) - } -} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Mentions Feed View.swift b/Mlem/Views/Tabs/Inbox/Feed/Mentions Feed View.swift deleted file mode 100644 index 3c6d4ce83..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/Mentions Feed View.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Mentions Feed View.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Foundation -import SwiftUI - -struct MentionsFeedView: View { - @ObservedObject var mentionTracker: MentionTracker - - var body: some View { - if mentionTracker.loadingState == .done, mentionTracker.items.isEmpty { - noMentionsView() - } else { - LazyVStack(spacing: 0) { - EmptyView().id("top") - mentionsListView() - } - } - } - - @ViewBuilder - func noMentionsView() -> some View { - VStack(alignment: .center, spacing: 5) { - Image(systemName: Icons.noPosts) - - Text("No mentions to be found") - } - .padding() - .foregroundColor(.secondary) - } - - @ViewBuilder - func mentionsListView() -> some View { - ForEach(mentionTracker.items, id: \.uid) { mention in - VStack(spacing: 0) { - InboxMentionView(mention: mention) - .onAppear { - mentionTracker.loadIfThreshold(mention) - } - - Divider() - } - } - - EndOfFeedView(loadingState: mentionTracker.loadingState, viewType: .cartoon) - } -} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Messages Feed View.swift b/Mlem/Views/Tabs/Inbox/Feed/Messages Feed View.swift deleted file mode 100644 index a80efb8f6..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/Messages Feed View.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// MessagesFeedView.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Foundation -import SwiftUI - -struct MessagesFeedView: View { - @ObservedObject var messageTracker: MessageTracker - - var body: some View { - if messageTracker.loadingState == .done, messageTracker.items.isEmpty { - noMessagesView() - } else { - LazyVStack(spacing: 0) { - EmptyView().id("top") - messagesListView() - } - } - } - - @ViewBuilder - func noMessagesView() -> some View { - VStack(alignment: .center, spacing: 5) { - Image(systemName: Icons.noPosts) - - Text("No messages to be found") - } - .padding() - .foregroundColor(.secondary) - } - - @ViewBuilder - func messagesListView() -> some View { - ForEach(messageTracker.items, id: \.uid) { message in - VStack(spacing: 0) { - InboxMessageView(message: message) - .onAppear { - messageTracker.loadIfThreshold(message) - } - - Divider() - } - } - - EndOfFeedView(loadingState: messageTracker.loadingState, viewType: .cartoon) - } -} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Replies Feed View.swift b/Mlem/Views/Tabs/Inbox/Feed/Replies Feed View.swift deleted file mode 100644 index b02b9f97d..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/Replies Feed View.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Replies Feed View.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Foundation -import SwiftUI - -struct RepliesFeedView: View { - @ObservedObject var replyTracker: ReplyTracker - - var body: some View { - if replyTracker.loadingState == .done, replyTracker.items.isEmpty { - noRepliesView() - } else { - LazyVStack(spacing: 0) { - EmptyView().id("top") - repliesListView() - } - } - } - - @ViewBuilder - func noRepliesView() -> some View { - VStack(alignment: .center, spacing: 5) { - Image(systemName: Icons.noPosts) - - Text("No replies to be found") - } - .padding() - .foregroundColor(.secondary) - } - - @ViewBuilder - func repliesListView() -> some View { - ForEach(replyTracker.items, id: \.uid) { reply in - VStack(spacing: 0) { - InboxReplyView(reply: reply) - .onAppear { - replyTracker.loadIfThreshold(reply) - } - - Divider() - } - } - - EndOfFeedView(loadingState: replyTracker.loadingState, viewType: .cartoon) - } -} diff --git a/Mlem/Views/Tabs/Inbox/Inbox View.swift b/Mlem/Views/Tabs/Inbox/Inbox View.swift index e83d6e335..e69de29bb 100644 --- a/Mlem/Views/Tabs/Inbox/Inbox View.swift +++ b/Mlem/Views/Tabs/Inbox/Inbox View.swift @@ -1,212 +0,0 @@ -// -// Inbox View.swift -// Mlem -// -// Created by Jake Shirley on 6/25/23. -// - -import Dependencies -import Foundation -import SwiftUI - -enum InboxTab: String, CaseIterable, Identifiable { - case all, replies, mentions, messages - - var id: Self { self } - - var label: String { - rawValue.capitalized - } -} - -struct InboxView: View { - @Dependency(\.apiClient) var apiClient - @Dependency(\.commentRepository) var commentRepository - @Dependency(\.errorHandler) var errorHandler - @Dependency(\.hapticManager) var hapticManager - @Dependency(\.notifier) var notifier - @Dependency(\.personRepository) var personRepository - - // MARK: Global - - @Namespace var scrollToTop - @State private var scrollToTopAppeared = false - - @EnvironmentObject var appState: AppState - @EnvironmentObject var editorTracker: EditorTracker - @EnvironmentObject var unreadTracker: UnreadTracker - - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast - - // MARK: Internal - - // destructive confirmation - @State var isPresentingConfirmDestructive: Bool = false - @State var confirmationMenuFunction: StandardMenuFunction? - - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } - - // error handling - @State var errorOccurred: Bool = false - @State var errorMessage: String = "" - - // loading handling - @State var isLoading: Bool = true - @AppStorage("shouldFilterRead") var shouldFilterRead: Bool = false - - // item feeds - @StateObject var inboxTracker: InboxTracker - @StateObject var replyTracker: ReplyTracker - @StateObject var mentionTracker: MentionTracker - @StateObject var messageTracker: MessageTracker - - init() { - // TODO: once the post tracker is changed we won't need this here... - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast - @AppStorage("shouldFilterRead") var unreadOnly = false - @AppStorage("upvoteOnSave") var upvoteOnSave = false - - let newReplyTracker = ReplyTracker(internetSpeed: internetSpeed, sortType: .published, unreadOnly: unreadOnly) - let newMentionTracker = MentionTracker(internetSpeed: internetSpeed, sortType: .published, unreadOnly: unreadOnly) - let newMessageTracker = MessageTracker(internetSpeed: internetSpeed, sortType: .published, unreadOnly: unreadOnly) - - let newInboxTracker = InboxTracker( - internetSpeed: internetSpeed, - sortType: .published, - childTrackers: [ - newReplyTracker, - newMentionTracker, - newMessageTracker - ] - ) - - newReplyTracker.setParentTracker(newInboxTracker) - newMentionTracker.setParentTracker(newInboxTracker) - newMessageTracker.setParentTracker(newInboxTracker) - - self._inboxTracker = StateObject(wrappedValue: newInboxTracker) - self._replyTracker = StateObject(wrappedValue: newReplyTracker) - self._mentionTracker = StateObject(wrappedValue: newMentionTracker) - self._messageTracker = StateObject(wrappedValue: newMessageTracker) - } - - // input state handling - // - current view - @State var curTab: InboxTab = .all - - // utility - @StateObject private var inboxTabNavigation: AnyNavigationPath = .init() - @StateObject private var navigation: Navigation = .init() - - var body: some View { - ScrollViewReader { scrollProxy in - // NOTE: there appears to be a SwiftUI issue with segmented pickers stacked on top of ScrollViews which causes the tab bar to appear fully transparent. The internet suggests that this may be a bug that only manifests in dev mode, so, unless this pops up in a build, don't worry about it. If it does manifest, we can either put the Picker *in* the ScrollView (bad because then you can't access it without scrolling to the top) or put a Divider() at the bottom of the VStack (bad because then the material tab bar doesn't show) - NavigationStack(path: $inboxTabNavigation.path) { - contentView(scrollProxy: scrollProxy) - .navigationTitle("Inbox") - .navigationBarTitleDisplayMode(.inline) - .navigationBarColor() - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { ellipsisMenu } - } - .listStyle(PlainListStyle()) - .tabBarNavigationEnabled(.inbox, navigation) - .handleLemmyViews() - .environmentObject(inboxTabNavigation) - .environmentObject(inboxTracker) - } - .handleLemmyLinkResolution(navigationPath: .constant(inboxTabNavigation)) - .environment(\.navigationPathWithRoutes, $inboxTabNavigation.path) - .environment(\.navigation, navigation) - .environment(\.scrollViewProxy, scrollProxy) - } - .onChange(of: shouldFilterRead) { newValue in - Task(priority: .userInitiated) { - await handleShouldFilterReadChange(newShouldFilterRead: newValue) - } - } - } - - @ViewBuilder private func contentView(scrollProxy: ScrollViewProxy) -> some View { - VStack(spacing: AppConstants.postAndCommentSpacing) { - Picker(selection: $curTab, label: Text("Inbox tab")) { - ForEach(InboxTab.allCases) { tab in - Text(tab.label).tag(tab.rawValue) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, AppConstants.postAndCommentSpacing) - .padding(.top, AppConstants.postAndCommentSpacing) - - ScrollView { - ScrollToView(appeared: $scrollToTopAppeared) - .id(scrollToTop) - - if errorOccurred { - errorView() - } else { - switch curTab { - case .all: - AllItemsFeedView(inboxTracker: inboxTracker) - case .replies: - RepliesFeedView(replyTracker: replyTracker) - case .mentions: - MentionsFeedView(mentionTracker: mentionTracker) - case .messages: - MessagesFeedView(messageTracker: messageTracker) - } - } - } - .fancyTabScrollCompatible() - .refreshable { - // wrapping in task so view redraws don't cancel - // awaiting the value makes the refreshable indicator properly wait for the call to finish - await Task { - await refresh() - }.value - } - .hoistNavigation { - withAnimation { - scrollProxy.scrollTo(scrollToTop) - } - return true - } - } - .task { - // wrapping in task so view redraws don't cancel - Task(priority: .userInitiated) { - await refresh() - } - } - } - - @ViewBuilder - func errorView() -> some View { - VStack(spacing: 10) { - Image(systemName: Icons.noPosts) - .font(.title) - - Text("Inbox loading failed!") - - Text(errorMessage) - } - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - } - - @ViewBuilder - private var ellipsisMenu: some View { - Menu { - ForEach(genMenuFunctions()) { menuFunction in - MenuButton(menuFunction: menuFunction, confirmDestructive: nil) // no destructive functions - } - } label: { - Label("More", systemImage: Icons.menuCircle) - .frame(height: AppConstants.barIconHitbox) - .contentShape(Rectangle()) - } - } -} diff --git a/Mlem/Views/Tabs/Inbox/InboxFeedView.swift b/Mlem/Views/Tabs/Inbox/InboxFeedView.swift new file mode 100644 index 000000000..c45cb3a0a --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/InboxFeedView.swift @@ -0,0 +1,70 @@ +// +// InboxFeedView.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-01. +// + +import Foundation +import SwiftUI + +struct InboxFeedView: View where T.Item: InboxItem { + @ObservedObject var tracker: T + + var body: some View { + if tracker.loadingState == .done, tracker.items.isEmpty { + noItemsView() + } else { + itemsListView() + } + } + + @ViewBuilder + func itemsListView() -> some View { + ForEach(tracker.items, id: \.uid) { item in + VStack(spacing: 0) { + inboxItemView(item: item.toAnyInboxItem()) + .onAppear { + tracker.loadIfThreshold(item) + } + + Divider() + } + } + + EndOfFeedView(loadingState: tracker.loadingState, viewType: .cartoon, whatIsLoading: .inbox) + } + + @ViewBuilder + func inboxItemView(item: AnyInboxItem) -> some View { + Group { + switch item { + case let .message(message): + InboxMessageView(message: message) + case let .mention(mention): + InboxMentionView(mention: mention) + case let .reply(reply): + InboxReplyView(reply: reply) + case let .commentReport(commentReport): + InboxCommentReportView(commentReport: commentReport) + case let .postReport(postReport): + InboxPostReportView(postReport: postReport) + case let .messageReport(messageReport): + InboxMessageReportView(messageReport: messageReport) + case let .registrationApplication(application): + InboxRegistrationApplicationView(application: application) + } + } + } + + @ViewBuilder + func noItemsView() -> some View { + VStack(alignment: .center, spacing: 5) { + Image(systemName: Icons.noPosts) + + Text("No items found") + } + .padding() + .foregroundColor(.secondary) + } +} diff --git a/Mlem/Views/Tabs/Inbox/InboxRoot.swift b/Mlem/Views/Tabs/Inbox/InboxRoot.swift new file mode 100644 index 000000000..bf97b1244 --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/InboxRoot.swift @@ -0,0 +1,29 @@ +// +// InboxRoot.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-20. +// + +import Foundation +import SwiftUI + +struct InboxRoot: View { + @StateObject private var inboxRouter: AnyNavigationPath = .init() + @StateObject private var navigation: Navigation = .init() + + var body: some View { + ScrollViewReader { scrollProxy in + NavigationStack(path: $inboxRouter.path) { + InboxView() + .handleLemmyViews() + .environmentObject(inboxRouter) + .tabBarNavigationEnabled(.inbox, navigation) + } + .environment(\.navigationPathWithRoutes, $inboxRouter.path) + .environment(\.navigation, navigation) + .environment(\.scrollViewProxy, scrollProxy) + .handleLemmyLinkResolution(navigationPath: .constant(inboxRouter)) + } + } +} diff --git a/Mlem/Views/Tabs/Inbox/InboxView+Feeds.swift b/Mlem/Views/Tabs/Inbox/InboxView+Feeds.swift new file mode 100644 index 000000000..e52f09f93 --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/InboxView+Feeds.swift @@ -0,0 +1,75 @@ +// +// InboxView+Feeds.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-01. +// + +import Foundation +import SwiftUI + +extension InboxView { + @ViewBuilder + var personalFeedView: some View { + Section { + switch selectedPersonalTab { + case .all: + InboxFeedView(tracker: personalInboxTracker) + case .replies: + InboxFeedView(tracker: replyTracker) + case .mentions: + InboxFeedView(tracker: mentionTracker) + case .messages: + InboxFeedView(tracker: messageTracker) + default: + InboxFeedView(tracker: personalInboxTracker) + .onAppear { + assertionFailure("personalFeedView rendered with non-personal tab!") + } + } + } header: { + picker(tabs: InboxTab.personalCases, selected: $selectedPersonalTab) + } + .task { + if personalInboxTracker.items.isEmpty { + // wrap in subtask to view redraws don't cancel load + Task(priority: .userInitiated) { + await refresh(tracker: personalInboxTracker) + } + } + } + } + + @ViewBuilder + var moderatorFeedView: some View { + Section { + switch selectedModTab { + case .all: + InboxFeedView(tracker: modOrAdminInboxTracker) + case .commentReports: + InboxFeedView(tracker: commentReportTracker) + case .postReports: + InboxFeedView(tracker: postReportTracker) + case .messageReports: + InboxFeedView(tracker: messageReportTracker) + case .registrationApplications: + InboxFeedView(tracker: registrationApplicationTracker) + default: + InboxFeedView(tracker: modOrAdminInboxTracker) + .onAppear { + assertionFailure("moderatorFeedView rendered with non-mod/admin tab!") + } + } + } header: { + picker(tabs: siteInformation.isAdmin ? InboxTab.adminCases : InboxTab.modCases, selected: $selectedModTab) + } + .task { + if modOrAdminInboxTracker.items.isEmpty { + // wrap in subtask to view redraws don't cancel load + Task(priority: .userInitiated) { + await refresh(tracker: modOrAdminInboxTracker) + } + } + } + } +} diff --git a/Mlem/Views/Tabs/Inbox/InboxView+Logic.swift b/Mlem/Views/Tabs/Inbox/InboxView+Logic.swift index cb3a57b38..4796a7dda 100644 --- a/Mlem/Views/Tabs/Inbox/InboxView+Logic.swift +++ b/Mlem/Views/Tabs/Inbox/InboxView+Logic.swift @@ -8,21 +8,9 @@ import Foundation extension InboxView { - func refresh() async { - do { - switch curTab { - case .all: - await inboxTracker.refresh(clearBeforeFetch: false) - case .replies: - try await replyTracker.refresh(clearBeforeRefresh: false) - case .mentions: - try await mentionTracker.refresh(clearBeforeRefresh: false) - case .messages: - try await messageTracker.refresh(clearBeforeRefresh: false) - } - } catch { - errorHandler.handle(error) - } + func refresh(tracker: InboxTracker) async { + await tracker.refresh(clearBeforeFetch: false) + await unreadTracker.update() } func toggleFilterRead() { @@ -33,16 +21,45 @@ extension InboxView { replyTracker.unreadOnly = newShouldFilterRead mentionTracker.unreadOnly = newShouldFilterRead messageTracker.unreadOnly = newShouldFilterRead + commentReportTracker.unreadOnly = newShouldFilterRead + postReportTracker.unreadOnly = newShouldFilterRead + messageReportTracker.unreadOnly = newShouldFilterRead + registrationApplicationTracker.unreadOnly = newShouldFilterRead if newShouldFilterRead { - await inboxTracker.filterRead() + await personalInboxTracker.filterRead() + + // mod items are returned sorted by old when unreadOnly true + await modOrAdminInboxTracker.changeSortType(to: .old) } else { - await inboxTracker.refresh(clearBeforeFetch: true) + await personalInboxTracker.refresh(clearBeforeFetch: true) + await modOrAdminInboxTracker.changeSortType(to: .new) } } func markAllAsRead() async { - await inboxTracker.markAllAsRead(unreadTracker: unreadTracker) + await personalInboxTracker.markAllAsRead(unreadTracker: unreadTracker) + } + + func genFeedSwitchingFunctions() -> [MenuFunction] { + var ret: [MenuFunction] = .init() + availableFeeds.forEach { type in + let (imageName, enabled) = type != selectedInbox + ? (type.iconName, true) + : (type.iconNameFill, false) + let label = type.enrichedLabel( + unread: type == .personal ? unreadTracker.personal : unreadTracker.modAndAdmin + ) + ret.append(MenuFunction.standardMenuFunction( + text: label, + imageName: imageName, + enabled: enabled + ) { + selectedInbox = type + } + ) + } + return ret } func genMenuFunctions() -> [MenuFunction] { @@ -54,18 +71,14 @@ extension InboxView { ret.append(MenuFunction.standardMenuFunction( text: filterReadText, - imageName: filterReadSymbol, - destructiveActionPrompt: nil, - enabled: true + imageName: filterReadSymbol ) { toggleFilterRead() }) ret.append(MenuFunction.standardMenuFunction( text: "Mark All as Read", - imageName: "envelope.open", - destructiveActionPrompt: nil, - enabled: true + imageName: "envelope.open" ) { Task(priority: .userInitiated) { await markAllAsRead() @@ -74,4 +87,23 @@ extension InboxView { return ret } + + func tabValue(for tab: InboxTab) -> Int { + switch tab { + case .all: + switch selectedInbox { + case .personal: + unreadTracker.personal + case .mod: + unreadTracker.modAndAdmin + } + case .replies: unreadTracker.replies.count + case .mentions: unreadTracker.mentions.count + case .messages: unreadTracker.messages.count + case .commentReports: unreadTracker.commentReports.count + case .postReports: unreadTracker.postReports.count + case .messageReports: unreadTracker.messageReports.count + case .registrationApplications: unreadTracker.registrationApplications.count + } + } } diff --git a/Mlem/Views/Tabs/Inbox/InboxView.swift b/Mlem/Views/Tabs/Inbox/InboxView.swift new file mode 100644 index 000000000..96d5d4a45 --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/InboxView.swift @@ -0,0 +1,368 @@ +// +// InboxView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-24. +// + +import Dependencies +import Foundation +import SwiftUI + +enum InboxSelection: FeedType { + case personal, mod + + var label: String { + switch self { + case .personal: "Inbox" + case .mod: "Mod Mail" + } + } + + func enrichedLabel(unread: Int) -> String { + if unread > 0 { + return "\(label) (\(unread))" + } + return label + } + + var subtitle: String { + switch self { + case .personal: "Replies, mentions, and messages" + case .mod: "Reports from communities you moderate" + } + } + + var color: Color? { + switch self { + case .personal: .purple + case .mod: .moderation + } + } + + var iconName: String { + switch self { + case .personal: Icons.inbox + case .mod: Icons.moderation + } + } + + var iconNameFill: String { + switch self { + case .personal: Icons.inboxFill + case .mod: Icons.moderationFill + } + } + + var iconScaleFactor: CGFloat { + switch self { + case .personal: 0.55 + case .mod: 0.5 + } + } +} + +enum InboxTab: String, CaseIterable, Identifiable { + case all, replies, mentions, messages, commentReports, postReports, messageReports, registrationApplications + + static var personalCases: [InboxTab] { [.all, .replies, .mentions, .messages] } + static var modCases: [InboxTab] { [.all, .commentReports, .postReports] } + static var adminCases: [InboxTab] { [.all, .registrationApplications, .messageReports, .commentReports, .postReports] } + + var id: Self { self } + + var label: String { + switch self { + case .commentReports: "Comments" + case .postReports: "Posts" + case .messageReports: "Messages" + case .registrationApplications: "Applications" + default: + rawValue.capitalized + } + } +} + +struct InboxView: View { + @AppStorage("shouldFilterRead") var shouldFilterRead: Bool = false + + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.siteInformation) var siteInformation + @Dependency(\.personRepository) var personRepository + @Dependency(\.notifier) var notifier + + @Environment(\.scrollViewProxy) var scrollViewProxy + + @EnvironmentObject var unreadTracker: UnreadTracker + + // personal tracker + children + @StateObject var personalInboxTracker: InboxTracker + @StateObject var replyTracker: ReplyTracker + @StateObject var mentionTracker: MentionTracker + @StateObject var messageTracker: MessageTracker + // mod/admin trackers + children + @StateObject var modInboxTracker: InboxTracker + @StateObject var adminInboxTracker: InboxTracker + @StateObject var commentReportTracker: CommentReportTracker + @StateObject var postReportTracker: PostReportTracker + @StateObject var messageReportTracker: MessageReportTracker + @StateObject var registrationApplicationTracker: RegistrationApplicationTracker + + @Namespace var scrollToTop + @State var scrollToTopAppeared = false + + @State var selectedInbox: InboxSelection = .personal + @State var selectedPersonalTab: InboxTab = .all + @State var selectedModTab: InboxTab = .all + + @State var errorOccurred: Bool = false + @State var errorMessage: String = "" + + // swiftlint:disable:next function_body_length + init() { + @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast + @AppStorage("shouldFilterRead") var unreadOnly = false + @AppStorage("upvoteOnSave") var upvoteOnSave = false + + let modSortType: TrackerSort.Case = unreadOnly ? .old : .new + + let newReplyTracker = ReplyTracker(internetSpeed: internetSpeed, sortType: .new, unreadOnly: unreadOnly) + let newMentionTracker = MentionTracker(internetSpeed: internetSpeed, sortType: .new, unreadOnly: unreadOnly) + let newMessageTracker = MessageTracker(internetSpeed: internetSpeed, sortType: .new, unreadOnly: unreadOnly) + let newCommentReportTracker = CommentReportTracker(internetSpeed: internetSpeed, sortType: modSortType, unreadOnly: unreadOnly) + let newPostReportTracker = PostReportTracker(internetSpeed: internetSpeed, sortType: modSortType, unreadOnly: unreadOnly) + let newMessageReportTracker = MessageReportTracker(internetSpeed: internetSpeed, sortType: modSortType, unreadOnly: unreadOnly) + let newRegistrationApplicationTracker = RegistrationApplicationTracker( + internetSpeed: internetSpeed, + sortType: modSortType, + unreadOnly: unreadOnly + ) + + let newPersonalInboxTracker = InboxTracker( + internetSpeed: internetSpeed, + sortType: .new, + childTrackers: [ + newReplyTracker, + newMentionTracker, + newMessageTracker + ] + ) + + let newModInboxTracker = InboxTracker( + internetSpeed: internetSpeed, + sortType: modSortType, + childTrackers: [ + newCommentReportTracker, + newPostReportTracker + ] + ) + + let newAdminInboxTracker = InboxTracker( + internetSpeed: internetSpeed, + sortType: modSortType, + childTrackers: [ + newCommentReportTracker, + newPostReportTracker, + newMessageReportTracker, + newRegistrationApplicationTracker + ] + ) + + self._personalInboxTracker = StateObject(wrappedValue: newPersonalInboxTracker) + self._modInboxTracker = StateObject(wrappedValue: newModInboxTracker) + self._adminInboxTracker = StateObject(wrappedValue: newAdminInboxTracker) + self._replyTracker = StateObject(wrappedValue: newReplyTracker) + self._mentionTracker = StateObject(wrappedValue: newMentionTracker) + self._messageTracker = StateObject(wrappedValue: newMessageTracker) + self._commentReportTracker = StateObject(wrappedValue: newCommentReportTracker) + self._postReportTracker = StateObject(wrappedValue: newPostReportTracker) + self._messageReportTracker = StateObject(wrappedValue: newMessageReportTracker) + self._registrationApplicationTracker = StateObject(wrappedValue: newRegistrationApplicationTracker) + } + + var showModFeed: Bool { siteInformation.isAdmin || !siteInformation.moderatedCommunities.isEmpty } + + var modOrAdminInboxTracker: InboxTracker { siteInformation.isAdmin ? adminInboxTracker : modInboxTracker } + + var customSubtitle: String? { selectedInbox == .mod && siteInformation.isAdmin ? "Registration applications and reports" : nil } + + var showDropdownBadge: Bool { + switch selectedInbox { + case .personal: unreadTracker.modAndAdmin > 0 + case .mod: unreadTracker.personal > 0 + } + } + + var availableFeeds: [InboxSelection] { + var availableFeeds: [InboxSelection] = [.personal] + if showModFeed { + availableFeeds.append(.mod) + } + return availableFeeds + } + + var body: some View { + content + .toolbar { + ToolbarItem(placement: .principal) { + navBarTitle + .opacity(scrollToTopAppeared ? 0 : 1) + .animation(.easeOut(duration: 0.2), value: scrollToTopAppeared) + } + ToolbarItem(placement: .primaryAction) { + toolbarMenu + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarColor(visibility: .automatic) + .onChange(of: shouldFilterRead) { newValue in + Task(priority: .userInitiated) { + await handleShouldFilterReadChange(newShouldFilterRead: newValue) + } + } + .environmentObject(modOrAdminInboxTracker) + .environmentObject(personalInboxTracker) + .refreshable { + // wrapping in task so view redraws don't cancel + // awaiting the value makes the refreshable indicator properly wait for the call to finish + await Task { + switch selectedInbox { + case .personal: + await refresh(tracker: personalInboxTracker) + case .mod: + await refresh(tracker: modOrAdminInboxTracker) + } + }.value + } + .hoistNavigation { + if scrollToTopAppeared, availableFeeds.count > 1 { + guard showModFeed else { + assertionFailure("Multiple inbox feeds available for non-mod/admin!") + return true + } + switch selectedInbox { + case .personal: + selectedInbox = .mod + case .mod: + selectedInbox = .personal + } + } else { + withAnimation { + scrollViewProxy?.scrollTo(scrollToTop) + } + } + return true + } + } + + var content: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ScrollToView(appeared: $scrollToTopAppeared) + .id(scrollToTop) + + headerView + + switch selectedInbox { + case .personal: + personalFeedView + case .mod: + moderatorFeedView + } + } + } + .fancyTabScrollCompatible() + } + + @ViewBuilder + var headerView: some View { + if showModFeed { + Menu { + ForEach(genFeedSwitchingFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + } label: { + FeedHeaderView(feedType: selectedInbox, customSubtitle: customSubtitle, showDropdownBadge: showDropdownBadge) + } + .buttonStyle(.plain) + } else { + FeedHeaderView(feedType: InboxSelection.personal, showDropdownIndicator: false) + } + } + + @ViewBuilder + var navBarTitle: some View { + Menu { + ForEach(genFeedSwitchingFunctions()) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + } label: { + HStack(alignment: .center, spacing: 0) { + Text(selectedInbox.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 } + } + } + + @ViewBuilder + var ellipsisMenu: some View { + ForEach(genMenuFunctions()) { item in + MenuButton(menuFunction: item, menuFunctionPopup: .constant(nil)) // no destructive functions + } + } + + @ViewBuilder + func errorView() -> some View { + VStack(spacing: 10) { + Image(systemName: Icons.noPosts) + .font(.title) + + Text("Inbox loading failed!") + + Text(errorMessage) + } + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + } + + @ViewBuilder + var toolbarMenu: some View { + if selectedInbox == .personal { + ToolbarEllipsisMenu { + ellipsisMenu + } + } else { + Button { + let status = shouldFilterRead ? "All" : "Only Unresolved" + toggleFilterRead() + Task { + await notifier.add(.success("Showing \(status)")) + } + } label: { + Label("Toggle Unread Only", systemImage: shouldFilterRead ? Icons.filterFill : Icons.filter) + } + } + } + + @ViewBuilder + func picker(tabs: [InboxTab], selected: Binding) -> some View { + BubblePicker( + tabs, + selected: selected, + withDividers: [.bottom], + label: \.label, + value: { tabValue(for: $0) } + ) + .background(Color.systemBackground.opacity(scrollToTopAppeared ? 1 : 0)) + .background(.bar) + .animation(.easeOut(duration: 0.2), value: scrollToTopAppeared) + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxCommentReportBodyView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxCommentReportBodyView.swift new file mode 100644 index 000000000..ba408385c --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxCommentReportBodyView.swift @@ -0,0 +1,64 @@ +// +// InboxCommentReportBodyView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-28. +// + +import Dependencies +import Foundation +import SwiftUI + +struct InboxCommentReportBodyView: View { + @Dependency(\.errorHandler) var errorHandler + + @EnvironmentObject var modToolTracker: ModToolTracker + @EnvironmentObject var modInboxTracker: InboxTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var commentReport: CommentReportModel + + var iconName: String { commentReport.commentReport.resolved ? Icons.commentReport : Icons.commentReportFill } + + var body: some View { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + HStack { + UserLinkView(user: commentReport.reporter, serverInstanceLocation: .bottom, bannedFromCommunity: false) + + Spacer() + + Image(systemName: iconName) + .foregroundColor(.red) + .frame(width: AppConstants.largeAvatarSize, height: AppConstants.largeAvatarSize) + + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: commentReport.genMenuFunctions( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + ) + ) + } + + Text(commentReport.commentReport.reason) + + Text("Comment reported \(commentReport.published.getRelativeTime())") + .italic() + .font(.footnote) + .foregroundStyle(.secondary) + + if let resolver = commentReport.resolver { + let verb = commentReport.commentReport.resolved ? "Resolved" : "Unresolved" + Text("\(verb) by \(resolver.fullyQualifiedUsername ?? resolver.name)") + .italic() + .font(.footnote) + .foregroundStyle(.secondary) + } + + EmbeddedCommentView(comment: commentReport.comment, post: nil, community: commentReport.community) + } + .padding(.top, AppConstants.standardSpacing) + .padding(.horizontal, AppConstants.standardSpacing) + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxCommentReportView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxCommentReportView.swift new file mode 100644 index 000000000..a9b790d5a --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxCommentReportView.swift @@ -0,0 +1,82 @@ +// +// InboxCommentReportView.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-27. +// + +import Foundation +import SwiftUI + +struct InboxCommentReportView: View { + @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker + @EnvironmentObject var modToolTracker: ModToolTracker + @EnvironmentObject var modInboxTracker: InboxTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var commentReport: CommentReportModel + + var body: some View { + VStack(spacing: 0) { + InboxCommentReportBodyView(commentReport: commentReport) + InteractionBarView(context: .comment, widgets: enrichLayoutWidgets()) + } + .contentShape(Rectangle()) + .background(Color.systemBackground) + .addSwipeyActions( + commentReport.swipeActions(modToolTracker: modToolTracker, inboxTracker: modInboxTracker, unreadTracker: unreadTracker) + ) + .contextMenu { + ForEach(commentReport.genMenuFunctions( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + )) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + } + } + + func enrichLayoutWidgets() -> [EnrichedLayoutWidget] { + layoutWidgetTracker.groups.moderator.compactMap { baseWidget in + switch baseWidget { + case .infoStack: + return .infoStack( + colorizeVotes: false, + votes: commentReport.votes, + published: commentReport.comment.published, + updated: commentReport.comment.updated, + commentCount: commentReport.numReplies, + unreadCommentCount: 0, + saved: false + ) + case .resolve: + return .resolve(resolved: commentReport.commentReport.resolved, resolve: toggleResolved) + case .remove: + return .remove(removed: commentReport.comment.removed) { + commentReport.toggleCommentRemoved(modToolTracker: modToolTracker, unreadTracker: unreadTracker) + } + case .purge: + return .purge(purged: commentReport.purged) { + commentReport.purgeComment(modToolTracker: modToolTracker) + } + case .ban: + return .ban(banned: commentReport.commentCreatorBannedFromCommunity, instanceBan: false) { + commentReport.toggleCommentCreatorBanned( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + ) + } + default: + return nil + } + } + } + + func toggleResolved() { + Task(priority: .userInitiated) { + await commentReport.toggleResolved(unreadTracker: unreadTracker) + } + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxMentionBodyView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxMentionBodyView.swift new file mode 100644 index 000000000..6981b86cd --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxMentionBodyView.swift @@ -0,0 +1,59 @@ +// +// InboxMentionBodyView.swift +// Mlem +// +// Created by Eric Andrews on 2023-06-25. +// + +import SwiftUI + +struct InboxMentionBodyView: View { + @EnvironmentObject var editorTracker: EditorTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var mention: MentionModel + + var voteIconName: String { mention.votes.myVote == .downvote ? Icons.downvote : Icons.upvote } + var iconName: String { mention.personMention.read ? "quote.bubble" : "quote.bubble.fill" } + + var body: some View { + NavigationLink(.lazyLoadPostLinkWithContext(.init( + postId: mention.post.id, + scrollTarget: mention.comment.id + ))) { + content + } + .buttonStyle(EmptyButtonStyle()) + } + + var content: some View { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + HStack(spacing: AppConstants.standardSpacing) { + UserLinkView( + user: mention.creator, + serverInstanceLocation: .bottom, + bannedFromCommunity: mention.commentCreatorBannedFromCommunity, + overrideShowAvatar: true + ) + + Spacer() + + Image(systemName: iconName) + .foregroundColor(.accentColor) + .frame(width: AppConstants.largeAvatarSize) + + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: mention.menuFunctions(unreadTracker: unreadTracker, editorTracker: editorTracker) + ) + } + + MarkdownView(text: mention.comment.content, isNsfw: false) + .font(.subheadline) + + EmbeddedPost(community: mention.community.community, post: mention.post, comment: mention.comment) + } + .padding(.top, AppConstants.standardSpacing) + .padding(.horizontal, AppConstants.standardSpacing) + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxMentionView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxMentionView.swift new file mode 100644 index 000000000..c728cac87 --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxMentionView.swift @@ -0,0 +1,94 @@ +// +// InboxMentionView.swift +// Mlem +// +// Created by Bosco Ho on 2023-12-22. +// + +import SwiftUI + +struct InboxMentionView: View { + @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker + @EnvironmentObject var editorTracker: EditorTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var mention: MentionModel + + var body: some View { + VStack(spacing: 0) { + InboxMentionBodyView(mention: mention) + InteractionBarView(context: .comment, widgets: enrichLayoutWidgets()) + } + .background(Color(uiColor: .systemBackground)) + .contentShape(Rectangle()) + .addSwipeyActions( + mention.swipeActions( + unreadTracker: unreadTracker, + editorTracker: editorTracker + ) + ) + .contextMenu { + ForEach(mention.menuFunctions( + unreadTracker: unreadTracker, + editorTracker: editorTracker + )) { item in + MenuButton(menuFunction: item, menuFunctionPopup: .constant(nil)) + } + } + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + func enrichLayoutWidgets() -> [EnrichedLayoutWidget] { + layoutWidgetTracker.groups.comment.compactMap { baseWidget in + switch baseWidget { + case .infoStack: + return .infoStack( + colorizeVotes: false, + votes: mention.votes, + published: mention.published, + updated: mention.comment.updated, + commentCount: mention.numReplies, + unreadCommentCount: 0, + saved: mention.saved + ) + case .upvote: + return .upvote(myVote: mention.votes.myVote) { + await mention.toggleUpvote(unreadTracker: unreadTracker) + } + case .downvote: + return .downvote(myVote: mention.votes.myVote) { + await mention.toggleDownvote(unreadTracker: unreadTracker) + } + case .save: + return .save(saved: mention.saved) { + await mention.toggleSave(unreadTracker: unreadTracker) + } + case .reply: + return .reply { + mention.reply(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + case .share: + if let shareUrl = URL(string: mention.comment.apId) { + return .share(shareUrl: shareUrl) + } + return nil + case .upvoteCounter: + return .upvoteCounter(votes: mention.votes) { + await mention.toggleDownvote(unreadTracker: unreadTracker) + } + case .downvoteCounter: + return .downvoteCounter(votes: mention.votes) { + await mention.toggleDownvote(unreadTracker: unreadTracker) + } + case .scoreCounter: + return .scoreCounter(votes: mention.votes) { + await mention.toggleUpvote(unreadTracker: unreadTracker) + } downvote: { + await mention.toggleDownvote(unreadTracker: unreadTracker) + } + default: + return nil + } + } + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageBodyView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageBodyView.swift new file mode 100644 index 000000000..27915688e --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageBodyView.swift @@ -0,0 +1,73 @@ +// +// InboxMessageBodyView.swift +// Mlem +// +// Created by Eric Andrews on 2023-06-25. +// + +import Dependencies +import SwiftUI + +struct InboxMessageBodyView: View { + @Dependency(\.siteInformation) var siteInformation + + @ObservedObject var message: MessageModel + @EnvironmentObject var editorTracker: EditorTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + var isOwnMessage: Bool { siteInformation.userId == message.creatorId } + + var iconName: String { + isOwnMessage ? Icons.send : + message.privateMessage.read ? Icons.message : Icons.messageFill + } + + var verb: String { isOwnMessage ? "Sent" : "Received" } + + init(message: MessageModel) { + self.message = message + } + + var body: some View { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + HStack(spacing: AppConstants.standardSpacing) { + if isOwnMessage { + UserLinkView( + user: message.recipient, + serverInstanceLocation: .bottom, + bannedFromCommunity: false, + overrideShowAvatar: true + ) + } else { + UserLinkView( + user: message.creator, + serverInstanceLocation: .bottom, + bannedFromCommunity: false, + overrideShowAvatar: true + ) + } + + Spacer() + + Image(systemName: iconName) + .foregroundColor(.accentColor) + .frame(width: AppConstants.largeAvatarSize, height: AppConstants.largeAvatarSize) + + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: message.menuFunctions( + unreadTracker: unreadTracker, + editorTracker: editorTracker + ) + ) + } + + MarkdownView(text: message.privateMessage.content, isNsfw: false) + .font(.subheadline) + + Text("\(verb) \(message.published.getRelativeTime())") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageReportBodyView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageReportBodyView.swift new file mode 100644 index 000000000..21ba0dafa --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageReportBodyView.swift @@ -0,0 +1,73 @@ +// +// InboxMessageReportBodyView.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Foundation +import SwiftUI + +struct InboxMessageReportBodyView: View { + @EnvironmentObject var modToolTracker: ModToolTracker + @EnvironmentObject var modInboxTracker: InboxTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var messageReport: MessageReportModel + + var iconName: String { messageReport.messageReport.resolved ? Icons.message : Icons.messageFill } + + var body: some View { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + HStack(spacing: AppConstants.standardSpacing) { + UserLinkView(user: messageReport.reporter, serverInstanceLocation: .bottom, bannedFromCommunity: false) + + Spacer() + + Image(systemName: iconName) + .foregroundColor(.red) + .frame(width: AppConstants.largeAvatarSize, height: AppConstants.largeAvatarSize) + + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: messageReport.genMenuFunctions( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + ) + ) + } + + Text(messageReport.messageReport.reason) + + Text("Message reported \(messageReport.published.getRelativeTime())") + .italic() + .font(.footnote) + .foregroundStyle(.secondary) + + if let resolver = messageReport.resolver { + let verb = messageReport.messageReport.resolved ? "Resolved" : "Unresolved" + Text("\(verb) by \(resolver.fullyQualifiedUsername ?? resolver.name)") + .italic() + .font(.footnote) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + Text("from \(messageReport.messageCreator.fullyQualifiedUsername ?? messageReport.messageCreator.name)") + .font(.footnote) + + MarkdownView(text: messageReport.messageReport.originalPmText, isNsfw: false, isInline: true) + } + .padding(AppConstants.standardSpacing) + .background { + Rectangle() + .foregroundColor(.secondarySystemBackground) + .cornerRadius(AppConstants.standardSpacing) + } + .foregroundStyle(.secondary) + } + .padding(.top, AppConstants.standardSpacing) + .padding(.horizontal, AppConstants.standardSpacing) + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageReportView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageReportView.swift new file mode 100644 index 000000000..039940fb1 --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageReportView.swift @@ -0,0 +1,70 @@ +// +// InboxMessageReportView.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Foundation +import SwiftUI + +struct InboxMessageReportView: View { + @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker + @EnvironmentObject var modToolTracker: ModToolTracker + @EnvironmentObject var modInboxTracker: InboxTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var messageReport: MessageReportModel + + var body: some View { + VStack(spacing: 0) { + InboxMessageReportBodyView(messageReport: messageReport) + InteractionBarView(context: .post, widgets: enrichLayoutWidgets()) + } + .background(Color(uiColor: .systemBackground)) + .contentShape(Rectangle()) + .addSwipeyActions( + messageReport.swipeActions( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + ) + ) + .contextMenu { + ForEach(messageReport.genMenuFunctions( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + )) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + } + } + + func toggleResolved() { + Task { + await messageReport.toggleResolved(unreadTracker: unreadTracker) + } + } + + func enrichLayoutWidgets() -> [EnrichedLayoutWidget] { + layoutWidgetTracker.groups.moderator.compactMap { baseWidget in + switch baseWidget { + case .resolve: + return .resolve(resolved: messageReport.messageReport.resolved, resolve: toggleResolved) + case .ban: + return .ban(banned: messageReport.messageCreator.banned, instanceBan: true) { + messageReport.toggleMessageCreatorBanned( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + ) + } + case .infoStack: + return .spacer + default: + return nil + } + } + } +} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMessageView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageView.swift similarity index 54% rename from Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMessageView.swift rename to Mlem/Views/Tabs/Inbox/Item Types/InboxMessageView.swift index b28be8860..c40f95f53 100644 --- a/Mlem/Views/Tabs/Inbox/Feed/Item Types/InboxMessageView.swift +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxMessageView.swift @@ -14,11 +14,22 @@ struct InboxMessageView: View { var body: some View { InboxMessageBodyView(message: message) + .padding(AppConstants.standardSpacing) + .background(Color(uiColor: .systemBackground)) + .contentShape(Rectangle()) .addSwipeyActions( message.swipeActions( unreadTracker: unreadTracker, editorTracker: editorTracker ) ) + .contextMenu { + ForEach(message.menuFunctions( + unreadTracker: unreadTracker, + editorTracker: editorTracker + )) { item in + MenuButton(menuFunction: item, menuFunctionPopup: .constant(nil)) + } + } } } diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxPostReportBodyView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxPostReportBodyView.swift new file mode 100644 index 000000000..a5829f4ac --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxPostReportBodyView.swift @@ -0,0 +1,61 @@ +// +// InboxPostReportBodyView.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import Foundation +import SwiftUI + +struct InboxPostReportBodyView: View { + @EnvironmentObject var modToolTracker: ModToolTracker + @EnvironmentObject var modInboxTracker: InboxTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var postReport: PostReportModel + + var iconName: String { postReport.postReport.resolved ? Icons.posts : Icons.postsFill } + + var body: some View { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + HStack { + UserLinkView(user: postReport.reporter, serverInstanceLocation: .bottom, bannedFromCommunity: false) + + Spacer() + + Image(systemName: iconName) + .foregroundColor(.red) + .frame(width: AppConstants.largeAvatarSize, height: AppConstants.largeAvatarSize) + + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: postReport.genMenuFunctions( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + ) + ) + } + + Text(postReport.postReport.reason) + + Text("Post reported \(postReport.published.getRelativeTime())") + .italic() + .font(.footnote) + .foregroundStyle(.secondary) + + if let resolver = postReport.resolver { + let verb = postReport.postReport.resolved ? "Resolved" : "Unresolved" + Text("\(verb) by \(resolver.fullyQualifiedUsername ?? resolver.name)") + .italic() + .font(.footnote) + .foregroundStyle(.secondary) + } + + EmbeddedPost(community: postReport.community.community, post: postReport.post, comment: nil) + } + .padding(.top, AppConstants.standardSpacing) + .padding(.horizontal, AppConstants.standardSpacing) + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxPostReportView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxPostReportView.swift new file mode 100644 index 000000000..020fb9ede --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxPostReportView.swift @@ -0,0 +1,85 @@ +// +// InboxPostReportView.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-04. +// + +import SwiftUI + +struct InboxPostReportView: View { + @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker + @EnvironmentObject var modToolTracker: ModToolTracker + @EnvironmentObject var modInboxTracker: InboxTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var postReport: PostReportModel + + var body: some View { + VStack(spacing: 0) { + InboxPostReportBodyView(postReport: postReport) + InteractionBarView(context: .post, widgets: enrichLayoutWidgets()) + } + .background(Color(uiColor: .systemBackground)) + .contentShape(Rectangle()) + .addSwipeyActions( + postReport.swipeActions( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + ) + ) + .contextMenu { + ForEach(postReport.genMenuFunctions( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + )) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + } + } + + func toggleResolved() { + Task(priority: .userInitiated) { + await postReport.toggleResolved(unreadTracker: unreadTracker) + } + } + + func enrichLayoutWidgets() -> [EnrichedLayoutWidget] { + layoutWidgetTracker.groups.moderator.compactMap { baseWidget in + switch baseWidget { + case .infoStack: + return .infoStack( + colorizeVotes: false, + votes: postReport.votes, + published: postReport.post.published, + updated: postReport.post.updated, + commentCount: postReport.numReplies, + unreadCommentCount: 0, + saved: false + ) + case .resolve: + return .resolve(resolved: postReport.postReport.resolved, resolve: toggleResolved) + case .remove: + return .remove(removed: postReport.post.removed) { + postReport.togglePostRemoved(modToolTracker: modToolTracker, unreadTracker: unreadTracker) + } + case .purge: + return .purge(purged: postReport.purged) { + postReport.purgePost(modToolTracker: modToolTracker) + } + case .ban: + return .ban(banned: postReport.postCreatorBannedFromCommunity, instanceBan: false) { + postReport.togglePostCreatorBanned( + modToolTracker: modToolTracker, + inboxTracker: modInboxTracker, + unreadTracker: unreadTracker + ) + } + default: + return nil + } + } + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxRegistrationApplicationBodyView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxRegistrationApplicationBodyView.swift new file mode 100644 index 000000000..4880df44e --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxRegistrationApplicationBodyView.swift @@ -0,0 +1,72 @@ +// +// InboxRegistrationApplicationBodyView.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-05. +// + +import Foundation +import SwiftUI + +struct InboxRegistrationApplicationBodyView: View { + @EnvironmentObject var modToolTracker: ModToolTracker + + @ObservedObject var application: RegistrationApplicationModel + let menuFunctions: [MenuFunction] + + var iconName: String { application.read ? Icons.registrationApplication : Icons.registrationApplicationFill } + + var body: some View { + content + .padding(AppConstants.standardSpacing) + .background(Color(uiColor: .systemBackground)) + .contentShape(Rectangle()) + } + + var content: some View { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + HStack(spacing: AppConstants.standardSpacing) { + UserLinkView(user: application.creator, serverInstanceLocation: .bottom, bannedFromCommunity: false) + + Spacer() + + Image(systemName: iconName) + .foregroundColor(.purple) + .frame(width: AppConstants.largeAvatarSize, height: AppConstants.largeAvatarSize) + + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: menuFunctions + ) + } + + Text("Applied \(application.published.getRelativeTime())") + .italic() + .font(.footnote) + .foregroundStyle(.secondary) + + if let resolver = application.resolver, let approved = application.approved { + Text(resolutionText(approved: approved, resolver: resolver)) + .italic() + .font(.footnote) + .foregroundStyle(.secondary) + } + + MarkdownView(text: application.application.answer, isNsfw: false) + } + } + + func resolutionText(approved: Bool, resolver: UserModel) -> String { + let resolverName: String = resolver.fullyQualifiedUsername ?? resolver.name + + if approved { + return "Approved by \(resolverName)" + } else { + var denyReason = "" + if let reason = application.application.denyReason { + denyReason = " (\(reason))" + } + return "Denied by \(resolverName)\(denyReason)" + } + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxRegistrationApplicationView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxRegistrationApplicationView.swift new file mode 100644 index 000000000..177395efa --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxRegistrationApplicationView.swift @@ -0,0 +1,73 @@ +// +// InboxRegistrationApplicationView.swift +// Mlem +// +// Created by Eric Andrews on 2024-04-05. +// + +import Dependencies +import Foundation +import SwiftUI + +struct InboxRegistrationApplicationView: View { + @EnvironmentObject var modToolTracker: ModToolTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var application: RegistrationApplicationModel + + var body: some View { + VStack(spacing: 0) { + InboxRegistrationApplicationBodyView( + application: application, + menuFunctions: application.genMenuFunctions(modToolTracker: modToolTracker, unreadTracker: unreadTracker) + ) + + if !application.read { + interactions + } + } + .background(Color.systemBackground) + .addSwipeyActions( + application.swipeActions(modToolTracker: modToolTracker, unreadTracker: unreadTracker) + ) + .contextMenu { + ForEach(application.genMenuFunctions(modToolTracker: modToolTracker, unreadTracker: unreadTracker)) { menuFunction in + MenuButton(menuFunction: menuFunction, menuFunctionPopup: .constant(nil)) + } + } + } + + var interactions: some View { + HStack(spacing: AppConstants.standardSpacing) { + Button { + modToolTracker.denyApplication(application) + } label: { + Image(systemName: Icons.deny) + .font(.subheadline) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, AppConstants.standardSpacing) + } + .background(Color.secondarySystemBackground) + .foregroundStyle(.red) + .clipShape(Capsule()) + + Button { + Task { + await application.approve(unreadTracker: unreadTracker) + } + } label: { + Image(systemName: Icons.approve) + .font(.subheadline) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, AppConstants.standardSpacing) + } + .background(Color.secondarySystemBackground) + .foregroundStyle(.blue) + .clipShape(Capsule()) + } + .padding(.horizontal, AppConstants.standardSpacing) + .padding(.bottom, AppConstants.standardSpacing) + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxReplyBodyView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxReplyBodyView.swift new file mode 100644 index 000000000..2a033f347 --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxReplyBodyView.swift @@ -0,0 +1,58 @@ +// +// InboxReplyBodyView.swift +// Mlem +// +// Created by Eric Andrews on 2023-06-25. +// + +import SwiftUI + +struct InboxReplyBodyView: View { + @ObservedObject var reply: ReplyModel + @EnvironmentObject var editorTracker: EditorTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + var voteIconName: String { reply.votes.myVote == .downvote ? Icons.downvote : Icons.upvote } + var iconName: String { reply.commentReply.read ? "arrowshape.turn.up.right" : "arrowshape.turn.up.right.fill" } + + var body: some View { + NavigationLink(.lazyLoadPostLinkWithContext(.init( + postId: reply.post.id, + scrollTarget: reply.comment.id + ))) { + content + } + .buttonStyle(EmptyButtonStyle()) + } + + var content: some View { + VStack(alignment: .leading, spacing: AppConstants.standardSpacing) { + HStack(spacing: AppConstants.standardSpacing) { + UserLinkView( + user: reply.creator, + serverInstanceLocation: .bottom, + bannedFromCommunity: reply.commentCreatorBannedFromCommunity, + overrideShowAvatar: true + ) + + Spacer() + + Image(systemName: iconName) + .foregroundColor(.accentColor) + .frame(width: AppConstants.largeAvatarSize) + + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: reply.menuFunctions(unreadTracker: unreadTracker, editorTracker: editorTracker) + ) + } + + MarkdownView(text: reply.comment.content, isNsfw: false) + .font(.subheadline) + + EmbeddedPost(community: reply.community.community, post: reply.post, comment: reply.comment) + } + .padding(.top, AppConstants.standardSpacing) + .padding(.horizontal, AppConstants.standardSpacing) + } +} diff --git a/Mlem/Views/Tabs/Inbox/Item Types/InboxReplyView.swift b/Mlem/Views/Tabs/Inbox/Item Types/InboxReplyView.swift new file mode 100644 index 000000000..baa74dc12 --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/Item Types/InboxReplyView.swift @@ -0,0 +1,94 @@ +// +// InboxReplyView.swift +// Mlem +// +// Created by Bosco Ho on 2023-12-21. +// + +import SwiftUI + +struct InboxReplyView: View { + @EnvironmentObject var layoutWidgetTracker: LayoutWidgetTracker + @EnvironmentObject var editorTracker: EditorTracker + @EnvironmentObject var unreadTracker: UnreadTracker + + @ObservedObject var reply: ReplyModel + + var body: some View { + VStack(spacing: 0) { + InboxReplyBodyView(reply: reply) + InteractionBarView(context: .comment, widgets: enrichLayoutWidgets()) + } + .background(Color(uiColor: .systemBackground)) + .contentShape(Rectangle()) + .addSwipeyActions( + reply.swipeActions( + unreadTracker: unreadTracker, + editorTracker: editorTracker + ) + ) + .contextMenu { + ForEach(reply.menuFunctions( + unreadTracker: unreadTracker, + editorTracker: editorTracker + )) { item in + MenuButton(menuFunction: item, menuFunctionPopup: .constant(nil)) + } + } + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + func enrichLayoutWidgets() -> [EnrichedLayoutWidget] { + layoutWidgetTracker.groups.comment.compactMap { baseWidget in + switch baseWidget { + case .infoStack: + return .infoStack( + colorizeVotes: false, + votes: reply.votes, + published: reply.published, + updated: reply.comment.updated, + commentCount: reply.numReplies, + unreadCommentCount: 0, + saved: reply.saved + ) + case .upvote: + return .upvote(myVote: reply.votes.myVote) { + await reply.toggleUpvote(unreadTracker: unreadTracker) + } + case .downvote: + return .downvote(myVote: reply.votes.myVote) { + await reply.toggleDownvote(unreadTracker: unreadTracker) + } + case .save: + return .save(saved: reply.saved) { + await reply.toggleSave(unreadTracker: unreadTracker) + } + case .reply: + return .reply { + reply.reply(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + case .share: + if let shareUrl = URL(string: reply.comment.apId) { + return .share(shareUrl: shareUrl) + } + return nil + case .upvoteCounter: + return .upvoteCounter(votes: reply.votes) { + await reply.toggleUpvote(unreadTracker: unreadTracker) + } + case .downvoteCounter: + return .downvoteCounter(votes: reply.votes) { + await reply.toggleDownvote(unreadTracker: unreadTracker) + } + case .scoreCounter: + return .scoreCounter(votes: reply.votes) { + await reply.toggleUpvote(unreadTracker: unreadTracker) + } downvote: { + await reply.toggleDownvote(unreadTracker: unreadTracker) + } + default: + return nil + } + } + } +} diff --git a/Mlem/Views/Tabs/Profile/Profile View.swift b/Mlem/Views/Tabs/Profile/Profile View.swift index aaef7916a..f9d66d7f4 100644 --- a/Mlem/Views/Tabs/Profile/Profile View.swift +++ b/Mlem/Views/Tabs/Profile/Profile View.swift @@ -26,7 +26,7 @@ struct ProfileView: View { ScrollViewReader { proxy in NavigationStack(path: $profileTabNavigation.path) { if let person = siteInformation.myUserInfo?.localUserView.person { - UserView(user: UserModel(from: person)) + UserView(user: UserModel(from: person), isPresentingProfileEditor: $isPresentingProfileEditor) .handleLemmyViews() .environmentObject(profileTabNavigation) .tabBarNavigationEnabled(.profile, navigation) @@ -36,14 +36,6 @@ struct ProfileView: View { isPresentingAccountSwitcher = true } } - // TODO: 0.17 deprecation - if (siteInformation.version ?? .infinity) >= .init("0.18.0") { - ToolbarItem(placement: .secondaryAction) { - Button("Edit", systemImage: Icons.edit) { - isPresentingProfileEditor = true - } - } - } } .sheet(isPresented: $isPresentingAccountSwitcher) { Form { diff --git a/Mlem/Views/Tabs/Profile/UserFeedView.swift b/Mlem/Views/Tabs/Profile/UserFeedView.swift index 192f4025a..c504cea86 100644 --- a/Mlem/Views/Tabs/Profile/UserFeedView.swift +++ b/Mlem/Views/Tabs/Profile/UserFeedView.swift @@ -51,7 +51,7 @@ struct UserFeedView: View { .padding(.vertical, 4) Divider() ForEach(communityTracker.items, id: \.uid) { community in - CommunityResultView(community, complications: .instanceOnly, trackerCallback: { + CommunityListRow(community, complications: .instanceOnly, trackerCallback: { communityTracker.update(with: $0) }) diff --git a/Mlem/Views/Tabs/Profile/UserView+Logic.swift b/Mlem/Views/Tabs/Profile/UserView+Logic.swift index e27e35e01..68ed28abe 100644 --- a/Mlem/Views/Tabs/Profile/UserView+Logic.swift +++ b/Mlem/Views/Tabs/Profile/UserView+Logic.swift @@ -9,6 +9,26 @@ import SwiftUI extension UserView { var isOwnProfile: Bool { user.userId == siteInformation.myUserInfo?.localUserView.person.id } + var canAppointAsMod: Bool { + siteInformation.myUser?.isAdmin ?? false || + (!isOwnProfile && !siteInformation.moderatedCommunities.isEmpty) + } + + var menuFunctions: [MenuFunction] { + var ret = user.menuFunctions({ user = $0 }, modToolTracker: modToolTracker) + + // add moderator needs to be defined as a menu function out here instead of within the user model so that it can take the $user binding + // TODO: 2.0 move add moderator menu function into Person + if canAppointAsMod { + ret.append(.standardMenuFunction( + text: "Appoint as Moderator", + imageName: Icons.moderation + ) { + modToolTracker.addModerator(user: $user, to: nil) + }) + } + return ret + } var tabs: [UserViewTab] { var tabs: [UserViewTab] = [.overview, .posts, .comments] @@ -41,9 +61,8 @@ extension UserView { authoredContent = try await personRepository.loadUserDetails(for: user.userId, limit: internetSpeed.pageSize) } - var newUser = UserModel(from: authoredContent) - newUser.isAdmin = user.isAdmin - user = newUser + user.update(with: authoredContent) + user.isAdmin = user.isAdmin communityTracker.replaceAll(with: user.moderatedCommunities ?? []) @@ -57,7 +76,9 @@ extension UserView { privateCommentTracker.comments = newComments await privatePostTracker.reset(with: newPosts) - isLoadingContent = false + withAnimation(.easeOut(duration: 0.2)) { + isLoadingContent = false + } } catch { errorHandler.handle( diff --git a/Mlem/Views/Tabs/Profile/UserView.swift b/Mlem/Views/Tabs/Profile/UserView.swift index ff8a5f6fd..2d9cbff75 100644 --- a/Mlem/Views/Tabs/Profile/UserView.swift +++ b/Mlem/Views/Tabs/Profile/UserView.swift @@ -18,10 +18,13 @@ struct UserView: View { @Environment(\.navigationPathWithRoutes) private var navigationPath @Environment(\.scrollViewProxy) private var scrollViewProxy + @EnvironmentObject var modToolTracker: ModToolTracker + let internetSpeed: InternetSpeed let communityContext: CommunityModel? @State var user: UserModel + @State var selectedTab: UserViewTab = .overview @State var isLoadingContent: Bool = true @@ -32,23 +35,24 @@ struct UserView: View { @StateObject var communityTracker: ContentTracker = .init() - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? + @State private var menuFunctionPopup: MenuFunctionPopup? + + let isProfileView: Bool + @Binding var isPresentingProfileEditor: Bool @Namespace var scrollToTop @State private var scrollToTopAppeared = false - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } - - init(user: UserModel, communityContext: CommunityModel? = nil) { + init( + user: UserModel, + communityContext: CommunityModel? = nil, + isPresentingProfileEditor: Binding? = nil + ) { @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast @AppStorage("upvoteOnSave") var upvoteOnSave = false self.internetSpeed = internetSpeed - + self._privatePostTracker = .init(wrappedValue: .init( internetSpeed: internetSpeed, sortType: .new, @@ -57,151 +61,114 @@ struct UserView: View { )) self._user = State(wrappedValue: user) + self.isProfileView = isPresentingProfileEditor != nil + self._isPresentingProfileEditor = isPresentingProfileEditor ?? .constant(false) 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) - .fontWeight(.semibold) - .lineLimit(1) - .minimumScaleFactor(0.01) - Text(user.fullyQualifiedUsername ?? user.name) - .font(.caption) - .foregroundStyle(.secondary) + content + .environmentObject(privatePostTracker) + .environmentObject(privateCommentTracker) + .task(priority: .userInitiated) { + if isLoadingContent { + Task { + await tryReloadUser() } } - .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(user.creationDate.dateString, 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) - } + } + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + ToolbarEllipsisMenu { + // TODO: 0.17 deprecation + if isProfileView, (siteInformation.version ?? .infinity) >= .init("0.18.0") { + Button("Edit", systemImage: Icons.edit) { + isPresentingProfileEditor = true } - .padding(.vertical, 4) - Divider() - UserFeedView( - user: user, - privatePostTracker: privatePostTracker, - privateCommentTracker: privateCommentTracker, - communityTracker: communityTracker, - selectedTab: $selectedTab - ) } - .transition(.opacity) + ForEach(menuFunctions) { item in + MenuButton(menuFunction: item, menuFunctionPopup: $menuFunctionPopup) + } } } - .animation(.easeOut(duration: 0.2), value: isLoadingContent) } - } - .environmentObject(privatePostTracker) - .environmentObject(privateCommentTracker) - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) - .toolbar { - ToolbarItemGroup(placement: .secondaryAction) { - let functions = user.menuFunctions { user = $0 } - ForEach(functions) { item in - MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) - } - } - } - .task(priority: .userInitiated) { - if isLoadingContent { + .destructiveConfirmation(menuFunctionPopup: $menuFunctionPopup) + .refreshable { Task { await tryReloadUser() } } - } - .onChange(of: user.userId) { _ in - Task { - await tryReloadUser() - } - } - .refreshable { - Task { - await tryReloadUser() - } - } - .onChange(of: siteInformation.myUserInfo?.localUserView.person) { newValue in - if isOwnProfile { - if let newValue { - user.update(with: newValue) + .onChange(of: siteInformation.myUserInfo?.localUserView.person) { newValue in + if isOwnProfile { + if let newValue { + user.update(with: newValue) + } } } - } - .hoistNavigation { - if navigationPath.isEmpty { - withAnimation { - scrollViewProxy?.scrollTo(scrollToTop) - } - return true - } else { - if scrollToTopAppeared { - return false - } else { + .onChange(of: user) { newValue in + // ugly little hack to propagate user moderation status changes + communityTracker.items = newValue.moderatedCommunities ?? .init() + } + .hoistNavigation { + if navigationPath.isEmpty { withAnimation { scrollViewProxy?.scrollTo(scrollToTop) } return true + } else { + if scrollToTopAppeared { + return false + } else { + withAnimation { + scrollViewProxy?.scrollTo(scrollToTop) + } + return true + } } } + .fancyTabScrollCompatible() + .navigationBarColor() + .navigationTitle(user.displayName) + .navigationBarTitleDisplayMode(.inline) + } + + var content: some View { + ScrollView { + ScrollToView(appeared: $scrollToTopAppeared) + .id(scrollToTop) + + VStack(spacing: AppConstants.standardSpacing) { + header + + flairs + + bio + .padding(.bottom, AppConstants.halfSpacing) + + userContent + } + } + } + + @ViewBuilder + var header: some View { + AvatarBannerView(user: user) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.top, 10) + Button(action: user.copyFullyQualifiedUsername) { + VStack(spacing: 5) { + Text(user.displayName) + .font(.title) + .fontWeight(.semibold) + .lineLimit(1) + .minimumScaleFactor(0.01) + Text(user.fullyQualifiedUsername ?? user.name) + .font(.caption) + .foregroundStyle(.secondary) + } } - .fancyTabScrollCompatible() - .navigationBarColor() - .navigationTitle(user.displayName) - .navigationBarTitleDisplayMode(.inline) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .buttonStyle(.plain) } var flairs: some View { @@ -213,15 +180,15 @@ struct UserView: View { flairBackground(color: flair.color) { HStack { switch flair { - case .banned: - Image(systemName: Icons.bannedFlair) + case .bannedFromInstance: + Image(systemName: Icons.instanceBannedFlair) if let expirationDate = user.banExpirationDate { Text("Banned Until \(expirationDate.dateString)") } else { Text("Permanently Banned") } case .admin: - Image(systemName: Icons.adminFlair) + Image(systemName: Icons.adminFill) let host = user.profileUrl.host() Text("\(host ?? "Instance") Administrator") case .moderator: @@ -240,6 +207,69 @@ struct UserView: View { } } + @ViewBuilder + var bio: some View { + let bioAlignment = bioAlignment + if let userBio = user.bio { + Divider() + .padding(.bottom, AppConstants.postAndCommentSpacing) + MarkdownView(text: userBio, isNsfw: false, alignment: bioAlignment).padding(AppConstants.postAndCommentSpacing) + } + HStack { + Label(user.creationDate.dateString, 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) + } + + @ViewBuilder + var userContent: some View { + if isLoadingContent { + VStack(spacing: 0) { + LoadingView(whatIsLoading: .content) + } + .transition(.opacity) + } else { + VStack(spacing: 0) { + BubblePicker( + tabs, + selected: $selectedTab, + withDividers: [.top, .bottom], + label: \.label, + value: { tab in + switch tab { + case .posts: + user.postCount ?? 0 + case .comments: + user.commentCount ?? 0 + case .communities: + user.moderatedCommunities?.count ?? 0 + default: + nil + } + } + ) + + UserFeedView( + user: user, + privatePostTracker: privatePostTracker, + privateCommentTracker: privateCommentTracker, + communityTracker: communityTracker, + selectedTab: $selectedTab + ) + .id(user.hashValue) + } + .transition(.opacity) + } + } + @ViewBuilder func flairBackground(color: Color, @ViewBuilder content: () -> some View) -> some View { content() diff --git a/Mlem/Views/Tabs/Search/BubblePicker.swift b/Mlem/Views/Tabs/Search/BubblePicker.swift deleted file mode 100644 index cc89e95c2..000000000 --- a/Mlem/Views/Tabs/Search/BubblePicker.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// 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 { - ScrollViewReader { proxy in - ScrollView(.horizontal) { - // Use negative spacing as well as padding the HStack's children so that scrollTo leaves extra space around each tab - HStack(spacing: -2*AppConstants.postAndCommentSpacing) { - ForEach(Array(zip(tabs.indices, tabs)), id: \.0) { index, tab in - Button { - selected = tab - hapticManager.play(haptic: .gentleInfo, priority: .low) - withAnimation { - proxy.scrollTo(index) - } - } label: { - AnyView(labelBuilder(tab)) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .foregroundStyle(selected == tab ? .white : .primary) - .font(.subheadline) - .fontWeight(.semibold) - .background( - Group { - if selected == tab { - 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) - .id(index) - } - } - } - .scrollIndicators(.hidden) - } - } -} - -#Preview { - BubblePicker( - InstanceViewTab.allCases, - selected: .constant(.about) - ) { - Text($0.label) - } -} diff --git a/Mlem/Views/Tabs/Search/BubblePicker/BubblePicker.swift b/Mlem/Views/Tabs/Search/BubblePicker/BubblePicker.swift new file mode 100644 index 000000000..5d2043f0a --- /dev/null +++ b/Mlem/Views/Tabs/Search/BubblePicker/BubblePicker.swift @@ -0,0 +1,220 @@ +// +// SearchTabPicker.swift +// Mlem +// +// Created by Sjmarf on 18/09/2023. +// + +import Dependencies +import SwiftUI + +enum DividerPlacement { + case top, bottom +} + +struct BubblePickerItemFrame: Equatable { + let width: CGFloat + let offset: CGFloat +} + +struct BubblePicker: View { + @Dependency(\.hapticManager) var hapticManager + + @Binding var selected: Value + let tabs: [Value] + let dividers: Set + let label: (Value) -> String + let value: (Value) -> Int? + + // currentTabIndex is used to drive the capsule animation; it is tracked separately from selected so that the capsule animations can be triggered independently of any animation (or lack thereof) that is desired on selected + @State var currentTabIndex: Int + @State var sizes: [BubblePickerItemFrame] + let spaceName: String = UUID().uuidString + + init( + _ tabs: [Value], + selected: Binding, + withDividers: Set = .init(), + label: @escaping (Value) -> String, + value: @escaping (Value) -> Int? = { _ in nil } + ) { + let initialIndex = tabs.firstIndex(of: selected.wrappedValue) + + assert(initialIndex != nil, "Selected tab \(selected.wrappedValue) not in tabs \(tabs)!") + + self._selected = selected + self._currentTabIndex = .init(wrappedValue: initialIndex ?? 0) + self.tabs = tabs + self.dividers = withDividers + self.label = label + self.value = value + self._sizes = .init(wrappedValue: .init(repeating: .init(width: .zero, offset: .zero), count: tabs.indices.count)) + } + + var body: some View { + VStack(spacing: 0) { + if dividers.contains(.top) { + Divider() + } + + ScrollViewReader { scrollProxy in + ScrollView(.horizontal) { + buttonStack(scrollProxy: scrollProxy, isSelected: false) + .overlay { + buttonStack(isSelected: true) + .background(.blue) + .allowsHitTesting(false) + .mask(alignment: .leading) { + Capsule() + .offset(x: sizes[currentTabIndex].offset + AppConstants.standardSpacing) + .frame(width: max(sizes[currentTabIndex].width - AppConstants.doubleSpacing, 0), height: 30) + } + } + .coordinateSpace(name: spaceName) + } + .scrollIndicators(.hidden) + .onChange(of: selected) { newValue in + let newIndex = tabs.firstIndex(of: newValue) ?? 0 + withAnimation(.interactiveSpring(response: 0.2, dampingFraction: 0.8)) { + currentTabIndex = newIndex + scrollProxy.scrollTo(newIndex) + } + } + } + + if dividers.contains(.bottom) { + Divider() + } + } + } + + /// Builds the HStack containing the actual buttons + /// - Parameter scrollProxy: scrollProxy to handle scrolling horizontally to the selected view. If present, the stack will create buttons and apply a ChildSizeReader to them to populate the size information for the masking; otherwise the stack will use inert labels. + @ViewBuilder + func buttonStack( + scrollProxy: ScrollViewProxy? = nil, + isSelected: Bool + ) -> some View { + // Use negative spacing as well as padding the HStack's children so that scrollTo leaves extra space around each tab + HStack(spacing: -AppConstants.doubleSpacing) { + ForEach(Array(zip(tabs.indices, tabs)), id: \.0) { index, tab in + if let scrollProxy { + ChildSizeReader(sizes: $sizes, index: index, spaceName: spaceName) { + bubbleButton( + index: index, + tab: tab, + scrollProxy: scrollProxy, + isSelected: isSelected + ) + } + } else { + bubbleButtonLabel(tab: tab, isSelected: isSelected) + } + } + } + } + + @ViewBuilder + func bubbleButton( + index: Int, + tab: Value, + scrollProxy: ScrollViewProxy, + isSelected: Bool + ) -> some View { + Button { + selected = tab + hapticManager.play(haptic: .gentleInfo, priority: .low) + } label: { + bubbleButtonLabel(tab: tab, isSelected: isSelected) + } + .buttonStyle(EmptyButtonStyle()) + .id(index) + } + + @ViewBuilder + func bubbleButtonLabel( + tab: Value, + isSelected: Bool + ) -> some View { + AnyView(HStack(spacing: 8) { + let value = value(tab) + Text(label(tab)) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(isSelected ? .white : .primary) + if let value { + Text(value.abbreviated) + .monospacedDigit() + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(isSelected ? .white.opacity(0.8) : .secondary) + } + }) + .padding(.horizontal, 22) + .frame(minHeight: 50) + .contentShape(Rectangle()) + } + + @ViewBuilder + func bubbleButtonLabel2( + tab: Value, + isSelected: Bool + ) -> some View { + AnyView(HStack { + let value = value(tab) + Text(label(tab)) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(isSelected ? .white : .primary) + // .padding(value == nil ? .horizontal : .leading, 22 + if let value { + Text(value.abbreviated) + .monospacedDigit() + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(isSelected ? .white.opacity(0.8) : .secondary) + .padding(.horizontal, 5) + .frame(minWidth: 22) + .frame(height: 22) + .background( + Group { + if value < 10 { + Circle() + } else { + Capsule() + } + } + .foregroundStyle( + isSelected ? Color(uiColor: .systemBackground).opacity(0.3) : Color(uiColor: .secondarySystemBackground) + ) + ) + } + }) + .padding(.horizontal, 22) + .frame(minHeight: 50) + .contentShape(Rectangle()) + } +} + +#Preview { + @State var selected: InstanceViewTab = .administration + return BubblePicker( + InstanceViewTab.allCases, + selected: $selected, + label: { $0.label }, + value: { item in + switch item { + case .about: + 0 + case .administration: + 5 + case .details: + 9_950_000 + case .uptime: + 10_000_000 + default: + nil + } + } + ) +} diff --git a/Mlem/Views/Tabs/Search/BubblePicker/ChildSizeReader.swift b/Mlem/Views/Tabs/Search/BubblePicker/ChildSizeReader.swift new file mode 100644 index 000000000..8b9aa6a20 --- /dev/null +++ b/Mlem/Views/Tabs/Search/BubblePicker/ChildSizeReader.swift @@ -0,0 +1,43 @@ +// +// ChildSizeReader.swift +// Mlem +// +// Created by Eric Andrews on 2024-03-22. +// +// adapted from https://stackoverflow.com/questions/56573373/swiftui-get-size-of-child + +import Foundation +import SwiftUI + +struct ChildSizeReader: View { + @Binding var sizes: [BubblePickerItemFrame] + let index: Int + let spaceName: String + let content: () -> Content + var body: some View { + ZStack { + content() + .background( + GeometryReader { proxy in + Color.clear + .preference(key: SizePreferenceKey.self, value: .init( + width: proxy.size.width, + offset: proxy.frame(in: .named(spaceName)).minX + )) + } + ) + } + .onPreferenceChange(SizePreferenceKey.self) { preferences in + sizes[index] = preferences + } + } +} + +struct SizePreferenceKey: PreferenceKey { + typealias Value = BubblePickerItemFrame + static var defaultValue: Value = .init(width: .zero, offset: .zero) + + static func reduce(value _: inout Value, nextValue: () -> Value) { + _ = nextValue() + } +} diff --git a/Mlem/Views/Tabs/Search/RecentSearchesView.swift b/Mlem/Views/Tabs/Search/RecentSearchesView.swift index 87bae074b..13fe0a5d0 100644 --- a/Mlem/Views/Tabs/Search/RecentSearchesView.swift +++ b/Mlem/Views/Tabs/Search/RecentSearchesView.swift @@ -72,7 +72,7 @@ struct RecentSearchesView: View { ForEach(contentTracker.items, id: \.uid) { contentModel in Group { if let community = contentModel.wrappedValue as? CommunityModel { - CommunityResultView( + CommunityListRow( community, complications: .withTypeLabel, swipeActions: .init(trailingActions: [deleteSwipeAction(contentModel)]), @@ -81,7 +81,7 @@ struct RecentSearchesView: View { } ) } else if let user = contentModel.wrappedValue as? UserModel { - UserResultView( + UserListRow( user, complications: [.type, .instance, .comments], swipeActions: .init(trailingActions: [deleteSwipeAction(contentModel)]), diff --git a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift b/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift deleted file mode 100644 index 2bb6385fc..000000000 --- a/Mlem/Views/Tabs/Search/Results/CommunityResultView.swift +++ /dev/null @@ -1,167 +0,0 @@ -// -// CommunityResultView.swift -// Mlem -// -// Created by Sjmarf on 18/09/2023. -// - -import Dependencies -import SwiftUI - -enum CommunityComplication: CaseIterable { - case type, instance, subscribers -} - -extension [CommunityComplication] { - static let withTypeLabel: [CommunityComplication] = [.type, .instance, .subscribers] - static let withoutTypeLabel: [CommunityComplication] = [.instance, .subscribers] - static let instanceOnly: [CommunityComplication] = [.instance] -} - -struct CommunityResultView: View { - @Dependency(\.apiClient) private var apiClient - @Dependency(\.hapticManager) var hapticManager - - let community: CommunityModel - let trackerCallback: (_ item: CommunityModel) -> Void - let swipeActions: SwipeConfiguration? - let complications: [CommunityComplication] - - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? - - @EnvironmentObject var editorTracker: EditorTracker - - init( - _ community: CommunityModel, - complications: [CommunityComplication] = .withoutTypeLabel, - swipeActions: SwipeConfiguration? = nil, - trackerCallback: @escaping (_ item: CommunityModel) -> Void = { _ in } - ) { - self.community = community - self.complications = complications - self.swipeActions = swipeActions - self.trackerCallback = trackerCallback - } - - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } - - var title: String { - var suffix = "" - if community.blocked ?? false { - suffix.append(" ∙ Blocked") - } - if community.nsfw { - suffix.append("∙ NSFW") - } - return community.name + suffix - } - - var caption: String { - var parts: [String] = [] - if complications.contains(.type) { - parts.append("Community") - } - if complications.contains(.instance), let host = community.communityUrl.host { - parts.append("@\(host)") - } - return parts.joined(separator: " ∙ ") - } - - 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) { - if community.blocked ?? false { - Image(systemName: Icons.hide) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 30, height: 30) - .padding(9) - } else { - AvatarView(community: community, avatarSize: 48, iconResolution: .fixed(128)) - } - - VStack(alignment: .leading, spacing: 4) { - Text(title) - .lineLimit(1) - .foregroundStyle(community.nsfw ? .red : .primary) - Text(caption) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - if complications.contains(.subscribers), let subscriberCount = community.subscriberCount { - HStack(spacing: 5) { - Text(abbreviateNumber(subscriberCount)) - .monospacedDigit() - Image(systemName: subscriberCountIcon) - } - .foregroundStyle(subscriberCountColor) - } - Image(systemName: Icons.forward) - .imageScale(.small) - .foregroundStyle(.tertiary) - } - .padding(.horizontal) - .contentShape(Rectangle()) - } - .opacity((community.blocked ?? false) ? 0.5 : 1) - .buttonStyle(.plain) - .padding(.vertical, 8) - .background(.background) - .draggable(community.communityUrl) { - HStack { - AvatarView(community: community, avatarSize: 24) - Text(community.name) - } - .padding(8) - .background(.background) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) - .addSwipeyActions(swipeActions ?? community.swipeActions(trackerCallback, confirmDestructive: confirmDestructive)) - .contextMenu { - ForEach( - community.menuFunctions( - editorTracker: editorTracker, - trackerCallback - ) - ) { item in - MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) - } - } - } -} - -#Preview { - CommunityResultView( - .init(from: .mock()) - ) -} diff --git a/Mlem/Views/Tabs/Search/Results/InstanceResultView.swift b/Mlem/Views/Tabs/Search/Results/InstanceResultView.swift index 4861b44e7..868d24f0c 100644 --- a/Mlem/Views/Tabs/Search/Results/InstanceResultView.swift +++ b/Mlem/Views/Tabs/Search/Results/InstanceResultView.swift @@ -47,7 +47,7 @@ struct InstanceResultView: View { } var body: some View { - NavigationLink(value: AppRoute.instance(instance.url.host(), instance)) { + NavigationLink(value: AppRoute.instance(instance)) { HStack(spacing: 10) { AvatarView(instance: instance, avatarSize: 48, iconResolution: .fixed(128)) @@ -62,7 +62,7 @@ struct InstanceResultView: View { Spacer() if complications.contains(.users), let userCount = instance.userCount { HStack(spacing: 5) { - Text(abbreviateNumber(userCount)) + Text(userCount.abbreviated) .monospacedDigit() Image(systemName: Icons.personFill) } @@ -90,7 +90,7 @@ struct InstanceResultView: View { .addSwipeyActions(swipeActions ?? .init()) .contextMenu { ForEach(instance.menuFunctions()) { item in - MenuButton(menuFunction: item, confirmDestructive: nil) + MenuButton(menuFunction: item, menuFunctionPopup: .constant(nil)) } } } diff --git a/Mlem/Views/Tabs/Search/Results/UserResultView.swift b/Mlem/Views/Tabs/Search/Results/UserResultView.swift index 6d8bffc1e..e69de29bb 100644 --- a/Mlem/Views/Tabs/Search/Results/UserResultView.swift +++ b/Mlem/Views/Tabs/Search/Results/UserResultView.swift @@ -1,186 +0,0 @@ -// -// UserResultView.swift -// Mlem -// -// Created by Sjmarf on 23/09/2023. -// - -import Dependencies -import SwiftUI - -enum UserComplication: CaseIterable { - case type, instance, date, posts, comments -} - -extension [UserComplication] { - static let withTypeLabel: [UserComplication] = [.type, .instance, .comments] - static let withoutTypeLabel: [UserComplication] = [.instance, .date, .posts, .comments] - static let instanceOnly: [UserComplication] = [.instance] -} - -struct UserResultView: View { - @Dependency(\.apiClient) private var apiClient - @Dependency(\.hapticManager) var hapticManager - - let user: UserModel - let communityContext: CommunityModel? - let trackerCallback: (_ item: UserModel) -> Void - let swipeActions: SwipeConfiguration? - let complications: [UserComplication] - - @State private var isPresentingConfirmDestructive: Bool = false - @State private var confirmationMenuFunction: StandardMenuFunction? - - init( - _ user: UserModel, - complications: [UserComplication] = .withoutTypeLabel, - communityContext: CommunityModel? = nil, - swipeActions: SwipeConfiguration? = nil, - trackerCallback: @escaping (_ item: UserModel) -> Void = { _ in } - ) { - self.user = user - self.complications = complications - self.communityContext = communityContext - self.swipeActions = swipeActions - self.trackerCallback = trackerCallback - } - - func confirmDestructive(destructiveFunction: StandardMenuFunction) { - confirmationMenuFunction = destructiveFunction - isPresentingConfirmDestructive = true - } - - var title: String { - if user.blocked { - return "\(user.displayName!) ∙ Blocked" - } else { - return user.displayName - } - } - - var caption: String { - var parts: [String] = [] - if complications.contains(.type) { - parts.append("User") - } - if complications.contains(.instance), let host = user.profileUrl.host { - parts.append("@\(host)") - } - if complications.contains(.date) { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy" - parts.append(dateFormatter.string(from: user.creationDate)) - } - return parts.joined(separator: " ∙ ") - } - - var body: some View { - NavigationLink(value: AppRoute.userProfile(user, communityContext: communityContext)) { - HStack(spacing: 10) { - if user.blocked { - Image(systemName: Icons.hide) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 30, height: 30) - .padding(9) - } else { - AvatarView(user: user, avatarSize: 48, iconResolution: .fixed(128)) - } - let flairs = user.getFlairs(communityContext: communityContext) - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - ForEach(flairs, id: \.self) { flair in - Image(systemName: flair.icon) - .imageScale(.small) - .foregroundStyle(flair.color) - } - Text(title) - .lineLimit(1) - } - Text(caption) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - trailingInfo - Image(systemName: Icons.forward) - .imageScale(.small) - .foregroundStyle(.tertiary) - } - .padding(.horizontal) - .contentShape(Rectangle()) - } - .opacity(user.blocked ? 0.5 : 1) - .buttonStyle(.plain) - .padding(.vertical, 8) - .background(.background) - .draggable(user.profileUrl) { - HStack { - AvatarView(user: user, avatarSize: 24) - Text(user.name) - } - .padding(8) - .background(.background) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) - .addSwipeyActions(swipeActions ?? .init()) - .contextMenu { - ForEach(user.menuFunctions(trackerCallback)) { item in - MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) - } - } - } - - @ViewBuilder - var trailingInfo: some View { - Group { - if complications.contains(.posts), let postCount = user.postCount { - if complications.contains(.comments), let commentCount = user.commentCount { - HStack(spacing: 5) { - VStack(alignment: .trailing, spacing: 6) { - Text(abbreviateNumber(postCount)) - .font(.subheadline) - .monospacedDigit() - Text(abbreviateNumber(commentCount)) - .font(.subheadline) - .monospacedDigit() - } - .foregroundStyle(.secondary) - VStack(spacing: 10) { - Image(systemName: Icons.posts) - .imageScale(.small) - Image(systemName: Icons.replies) - .imageScale(.small) - } - } - .foregroundStyle(.secondary) - } else { - HStack(spacing: 5) { - Text(abbreviateNumber(postCount)) - .monospacedDigit() - Image(systemName: Icons.posts) - } - .foregroundStyle(.secondary) - } - } else if complications.contains(.comments), let commentCount = user.commentCount { - HStack(spacing: 5) { - Text(abbreviateNumber(commentCount)) - .monospacedDigit() - Image(systemName: Icons.replies) - } - .foregroundStyle(.secondary) - } - } - } -} - -#Preview { - UserResultView( - .init(from: .mock()) - ) -} diff --git a/Mlem/Views/Tabs/Search/SearchHomeView.swift b/Mlem/Views/Tabs/Search/SearchHomeView.swift index df450a299..781413314 100644 --- a/Mlem/Views/Tabs/Search/SearchHomeView.swift +++ b/Mlem/Views/Tabs/Search/SearchHomeView.swift @@ -20,11 +20,15 @@ struct SearchHomeView: View { .fontWeight(.semibold) .padding(.horizontal, 18) .padding(.top, 12) - BubblePicker(SearchTab.homePageCases, selected: $searchModel.searchTab) { - Text($0.label) - } - .padding(.bottom, 10) - Divider() + .padding(.bottom, -AppConstants.halfSpacing) + + BubblePicker( + SearchTab.homePageCases, + selected: $searchModel.searchTab, + withDividers: [.bottom], + label: \.label + ) + SearchResultListView(showTypeLabel: false) } .frame(maxWidth: .infinity) @@ -46,7 +50,6 @@ struct SearchHomeView: View { } struct SearchHomeViewPreview: View { - @StateObject var homeSearchModel: SearchModel = .init() @StateObject var homeContentTracker: ContentTracker = .init() diff --git a/Mlem/Views/Tabs/Search/SearchResultListView.swift b/Mlem/Views/Tabs/Search/SearchResultListView.swift index 6673329ee..8ee690960 100644 --- a/Mlem/Views/Tabs/Search/SearchResultListView.swift +++ b/Mlem/Views/Tabs/Search/SearchResultListView.swift @@ -21,7 +21,7 @@ struct SearchResultListView: View { ForEach(contentTracker.items, id: \.uid) { contentModel in Group { if let community = contentModel.wrappedValue as? CommunityModel { - CommunityResultView( + CommunityListRow( community, complications: showTypeLabel ? .withTypeLabel : .withoutTypeLabel, trackerCallback: { @@ -29,7 +29,7 @@ struct SearchResultListView: View { } ) } else if let user = contentModel.wrappedValue as? UserModel { - UserResultView( + UserListRow( user, complications: showTypeLabel ? .withTypeLabel : .withoutTypeLabel, trackerCallback: { diff --git a/Mlem/Views/Tabs/Search/SearchResultsView.swift b/Mlem/Views/Tabs/Search/SearchResultsView.swift index 14ab16cd8..0d0a356c5 100644 --- a/Mlem/Views/Tabs/Search/SearchResultsView.swift +++ b/Mlem/Views/Tabs/Search/SearchResultsView.swift @@ -16,9 +16,14 @@ struct SearchResultsView: View { var body: some View { VStack(spacing: 0) { - tabs + BubblePicker( + SearchTab.allCases, + selected: $searchModel.searchTab, + label: \.label + ) + .padding(.top, -2.5) + .padding(.bottom, AppConstants.halfSpacing) Divider() - .padding(.top, 8) SearchResultListView(showTypeLabel: searchModel.searchTab == .topResults) } .onReceive( @@ -36,15 +41,6 @@ struct SearchResultsView: View { } .environmentObject(contentTracker) } - - @ViewBuilder - private var tabs: some View { - HStack { - BubblePicker(SearchTab.allCases, selected: $searchModel.searchTab) { - Text($0.label) - } - } - } } #Preview { diff --git a/Mlem/Views/Tabs/Settings/Components/AccountListView.swift b/Mlem/Views/Tabs/Settings/Components/AccountListView.swift index 4f122878b..143cb6e21 100644 --- a/Mlem/Views/Tabs/Settings/Components/AccountListView.swift +++ b/Mlem/Views/Tabs/Settings/Components/AccountListView.swift @@ -84,7 +84,7 @@ struct AccountListView: View { Button { isShowingInstanceAdditionSheet = true } label: { - Label("Add Account", systemImage: "plus") + Label("Add Account", systemImage: Icons.add) } .accessibilityLabel("Add a new account.") } diff --git a/Mlem/Views/Tabs/Settings/Components/Settings Item.swift b/Mlem/Views/Tabs/Settings/Components/Settings Item.swift index bc9cf3823..a6067039f 100644 --- a/Mlem/Views/Tabs/Settings/Components/Settings Item.swift +++ b/Mlem/Views/Tabs/Settings/Components/Settings Item.swift @@ -135,7 +135,7 @@ struct Checkbox: View { VStack { if isOn { Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.white, .blue) + .foregroundStyle(.white, .tint) .imageScale(.large) } else { Image(systemName: "circle") diff --git a/Mlem/Views/Tabs/Settings/Components/SettingsView.swift b/Mlem/Views/Tabs/Settings/Components/SettingsView.swift index 95f52260f..6faf1b4fd 100644 --- a/Mlem/Views/Tabs/Settings/Components/SettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/SettingsView.swift @@ -76,7 +76,7 @@ struct SettingsView: View { Button { isShowingInstanceAdditionSheet = true } label: { - Label("Add Another Account", systemImage: "plus") + Label("Add Another Account", systemImage: Icons.add) } .sheet(isPresented: $isShowingInstanceAdditionSheet) { AddSavedInstanceView(onboarding: false) @@ -87,7 +87,14 @@ struct SettingsView: View { NavigationLink(.settings(.general)) { Label("General", systemImage: "gear").labelStyle(SquircleLabelStyle(color: .gray)) } - + if siteInformation.isAdmin || !siteInformation.moderatedCommunities.isEmpty { + NavigationLink(.settings(.moderation)) { + Label("Moderation", systemImage: Icons.moderationFill).labelStyle(SquircleLabelStyle(color: .moderation)) + } + } + NavigationLink(.settings(.links)) { + Label("Links", systemImage: "link").labelStyle(SquircleLabelStyle(color: .teal)) + } NavigationLink(.settings(.sorting)) { Label("Sorting", systemImage: "arrow.up.and.down.text.horizontal") .labelStyle(SquircleLabelStyle(color: .indigo)) diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Accessibility/AccessibilitySettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Accessibility/AccessibilitySettingsView.swift index 657de52f0..b81467417 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Accessibility/AccessibilitySettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Accessibility/AccessibilitySettingsView.swift @@ -13,6 +13,7 @@ struct AccessibilitySettingsView: View { @AppStorage("readBarThickness") var readBarThickness: Int = 3 @AppStorage("hasTranslucentInsets") var hasTranslucentInsets: Bool = true @AppStorage("showSettingsIcons") var showSettingsIcons: Bool = true + @AppStorage("showWebsiteIndicatorIcon") var showWebsiteIndicatorIcon: Bool = false @State private var readBarThicknessSlider: CGFloat = 3.0 @@ -85,6 +86,12 @@ struct AccessibilitySettingsView: View { settingName: "Show Settings Icons", isTicked: $showSettingsIcons ) + + SwitchableSettingsItem( + settingPictureSystemName: Icons.browser, + settingName: "Thumbnail Website Indicator", + isTicked: $showWebsiteIndicatorIcon + ) } header: { Text("Icons") } diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Account/AccountSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Account/AccountSettingsView.swift index 0029e4438..99da1d065 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Account/AccountSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Account/AccountSettingsView.swift @@ -85,6 +85,12 @@ struct AccountSettingsView: View { } .disabled(settingsDisabled) + Section { + NavigationLink(.settings(.blockList)) { + Label("Block List", systemImage: Icons.hide).labelStyle(SquircleLabelStyle(color: .red, fontSize: 16)) + } + } + Section { NavigationLink(.settings(.accountLocal)) { Label("Local Options", systemImage: "iphone.gen3") @@ -93,16 +99,7 @@ struct AccountSettingsView: View { } footer: { Text("These options are stored locally in Mlem and not on your Lemmy account.") } - -// Section { -// NavigationLink { EmptyView() } label: { -// Label("Blocked Commuities", systemImage: "house.fill").labelStyle(SquircleLabelStyle(color: .gray)) -// } -// NavigationLink { EmptyView() } label: { -// Label("Blocked Users", systemImage: "person.fill").labelStyle(SquircleLabelStyle(color: .gray)) -// } -// } - + Section { Button("Sign Out", role: .destructive) { showingSignOutConfirmation = true diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Account/BlockListView+Logic.swift b/Mlem/Views/Tabs/Settings/Components/Views/Account/BlockListView+Logic.swift new file mode 100644 index 000000000..63925f7b8 --- /dev/null +++ b/Mlem/Views/Tabs/Settings/Components/Views/Account/BlockListView+Logic.swift @@ -0,0 +1,61 @@ +// +// BlockListView+Logic.swift +// Mlem +// +// Created by Sjmarf on 19/04/2024. +// + +import Foundation + +extension BlockListView { + func loadItems() async { + isLoading = true + errorDetails = nil + do { + let info = try await apiClient.loadSiteInformation() + if let myUser = info.myUser { + DispatchQueue.main.async { + self.communities = myUser.communityBlocks.map { .init(from: $0.community, blocked: true) } + self.users = myUser.personBlocks.map { .init(from: $0.target, blocked: true) } + self.instances = myUser.instanceBlocks?.map(\.instance) ?? .init() + self.isLoading = false + } + } + } catch { + isLoading = false + errorDetails = .init(error: error) + } + } + + func unblockInstance(id: Int) { + Task { + do { + try await apiClient.blockSite(id: id, shouldBlock: false) + await notifier.add(.success("Unblocked instance")) + if let index = instances.firstIndex( + where: { $0.id == id } + ) { + instances.remove(at: index) + } + } catch { + await notifier.add(.failure("Failed to unblock instance")) + } + } + } + + func removeUser(_ user: UserModel) { + if !user.blocked, let index = users.firstIndex( + where: { $0.userId == user.userId } + ) { + users.remove(at: index) + } + } + + func removeCommunity(_ community: CommunityModel) { + if !(community.blocked ?? true), let index = communities.firstIndex( + where: { $0.communityId == community.communityId } + ) { + communities.remove(at: index) + } + } +} diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Account/BlockListView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Account/BlockListView.swift new file mode 100644 index 000000000..496c9d247 --- /dev/null +++ b/Mlem/Views/Tabs/Settings/Components/Views/Account/BlockListView.swift @@ -0,0 +1,177 @@ +// +// BlockListView.swift +// Mlem +// +// Created by Sjmarf on 19/04/2024. +// + +import Dependencies +import SwiftUI + +enum BlockListTab: String, Identifiable, CaseIterable { + var id: Self { self } + + case communities, users, instances +} + +struct BlockListView: View { + @Dependency(\.apiClient) var apiClient + @Dependency(\.notifier) var notifier + @Dependency(\.siteInformation) var siteInformation + + @State var selected: BlockListTab = .users + + @Namespace var scrollToTop + @State var scrollToTopAppeared = true + + @State var communities: [CommunityModel] = .init() + @State var users: [UserModel] = .init() + @State var instances: [APIInstance] = .init() + + @State var hasDoneInitialLoad: Bool = false + @State var isLoading: Bool = true + @State var errorDetails: ErrorDetails? + + var availableTabs: [BlockListTab] { + // TODO: 0.18 deprecation + if (siteInformation.version ?? .infinity) >= .init("0.19.0") { + return BlockListTab.allCases + } + return [.communities, .users] + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ScrollToView(appeared: $scrollToTopAppeared) + .id(scrollToTop) + Section { + if let errorDetails { + ErrorView(errorDetails) + } else if isLoading { + LoadingView(whatIsLoading: .blockList) + } else { + resultsView() + } + } header: { + HStack { + BubblePicker( + availableTabs, + selected: $selected, + withDividers: [.bottom], + label: \.rawValue.capitalized + ) + } + .background(Color.systemBackground.opacity(scrollToTopAppeared ? 1 : 0)) + .background(.bar) + .animation(.easeOut(duration: 0.2), value: scrollToTopAppeared) + } + } + } + .fancyTabScrollCompatible() + .navigationTitle("Block List") + .navigationBarTitleDisplayMode(.inline) + .task { + if !hasDoneInitialLoad { + DispatchQueue.main.async { + hasDoneInitialLoad = true + } + await loadItems() + } + } + .refreshable { + await Task { + if !isLoading { + await loadItems() + } + }.value + } + } + + @ViewBuilder + func resultsView() -> some View { + switch selected { + case .users: + usersView() + case .communities: + communitiesView() + case .instances: + instancesView() + } + } + + @ViewBuilder + func usersView() -> some View { + if users.isEmpty { + noItemsView() + } else { + ForEach(users) { user in + VStack(spacing: 0) { + UserListRow( + user, + showBlockStatus: false, + swipeActions: swipeActions { user.blockCallback(removeUser) }, + trackerCallback: removeUser + ) + Divider() + } + } + } + } + + @ViewBuilder + func communitiesView() -> some View { + if communities.isEmpty { + noItemsView() + } else { + ForEach(communities) { community in + VStack(spacing: 0) { + CommunityListRow( + community, + showBlockStatus: false, + swipeActions: swipeActions { community.blockCallback(removeCommunity) }, + trackerCallback: removeCommunity + ) + Divider() + } + } + } + } + + @ViewBuilder + func instancesView() -> some View { + if instances.isEmpty { + noItemsView() + } else { + ForEach(instances) { instance in + VStack(alignment: .leading, spacing: 0) { + Text(instance.domain) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 16) + .background(.background) + .addSwipeyActions(swipeActions { unblockInstance(id: instance.id) }) + .contextMenu { + Button("Unblock", systemImage: Icons.show) { + unblockInstance(id: instance.id) + } + } + Divider() + } + } + } + } + + @ViewBuilder + func noItemsView() -> some View { + Text("Nothing to see here.") + .foregroundStyle(.secondary) + .padding(.top, 20) + } + + func swipeActions(_ callback: @escaping () -> Void) -> SwipeConfiguration { + .init(trailingActions: [ + .init(symbol: .init(emptyName: "eye", fillName: "eye.fill"), color: .gray, action: callback) + ]) + } +} diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Account/ProfileSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Account/ProfileSettingsView.swift index 0d439e3ef..d9a09c5ee 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Account/ProfileSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Account/ProfileSettingsView.swift @@ -80,32 +80,31 @@ struct ProfileSettingsView: View { Text("You can use markdown here.") } Section { - LinkAttachmentView(model: avatarAttachmentModel) { - HStack { - AvatarView(url: URL(string: avatarAttachmentModel.url), type: .user, avatarSize: 48, iconResolution: .unrestricted) - switch avatarAttachmentModel.imageModel?.state { - case nil, .uploaded: - Text("Avatar") + HStack { + AvatarView(url: URL(string: avatarAttachmentModel.url), type: .user, avatarSize: 48, iconResolution: .unrestricted) + switch avatarAttachmentModel.imageModel?.state { + case nil, .uploaded: + Text("Avatar") + .padding(.leading, 3) + default: + if let imageModel = avatarAttachmentModel.imageModel { + UploadProgressView(imageModel: imageModel) .padding(.leading, 3) - default: - if let imageModel = avatarAttachmentModel.imageModel { - UploadProgressView(imageModel: imageModel) - .padding(.leading, 3) - } } - Spacer() - if avatarAttachmentModel.imageModel != nil || avatarAttachmentModel.url.isNotEmpty { - Button(action: avatarAttachmentModel.removeLinkAction) { - circleImage(systemName: "xmark") - } - .buttonStyle(.plain) - } else { - LinkUploadOptionsView(model: avatarAttachmentModel) { - circleImage(systemName: "plus") - } + } + Spacer() + if avatarAttachmentModel.imageModel != nil || avatarAttachmentModel.url.isNotEmpty { + Button(action: avatarAttachmentModel.removeLinkAction) { + circleImage(systemName: "xmark") + } + .buttonStyle(.plain) + } else { + LinkUploadOptionsView(model: avatarAttachmentModel) { + circleImage(systemName: Icons.add) } } } + .linkAttachmentModel(model: avatarAttachmentModel) .padding(10) .listRowInsets(EdgeInsets()) .onChange(of: avatarAttachmentModel.url) { newValue in @@ -115,43 +114,42 @@ struct ProfileSettingsView: View { } } Section { - LinkAttachmentView(model: bannerAttachmentModel) { - VStack(spacing: 0) { - Group { - if bannerAttachmentModel.url.isNotEmpty { - CachedImage(url: URL(string: bannerAttachmentModel.url), shouldExpand: false) - } else { - Color(uiColor: .systemGray5) - } + VStack(spacing: 0) { + Group { + if bannerAttachmentModel.url.isNotEmpty { + CachedImage(url: URL(string: bannerAttachmentModel.url), shouldExpand: false) + } else { + Color(uiColor: .systemGray5) } - .frame(height: 100) - .clipped() - HStack { - switch bannerAttachmentModel.imageModel?.state { - case nil, .uploaded: - Text("Banner") + } + .frame(height: 100) + .clipped() + HStack { + switch bannerAttachmentModel.imageModel?.state { + case nil, .uploaded: + Text("Banner") + .padding(.leading, 3) + default: + if let imageModel = bannerAttachmentModel.imageModel { + UploadProgressView(imageModel: imageModel) .padding(.leading, 3) - default: - if let imageModel = bannerAttachmentModel.imageModel { - UploadProgressView(imageModel: imageModel) - .padding(.leading, 3) - } } - Spacer() - if bannerAttachmentModel.imageModel != nil || bannerAttachmentModel.url.isNotEmpty { - Button(action: bannerAttachmentModel.removeLinkAction) { - circleImage(systemName: "xmark") - } - .buttonStyle(.plain) - } else { - LinkUploadOptionsView(model: bannerAttachmentModel) { - circleImage(systemName: "plus") - } + } + Spacer() + if bannerAttachmentModel.imageModel != nil || bannerAttachmentModel.url.isNotEmpty { + Button(action: bannerAttachmentModel.removeLinkAction) { + circleImage(systemName: "xmark") + } + .buttonStyle(.plain) + } else { + LinkUploadOptionsView(model: bannerAttachmentModel) { + circleImage(systemName: Icons.add) } } - .padding(10) } + .padding(10) } + .linkAttachmentModel(model: bannerAttachmentModel) .listRowInsets(EdgeInsets()) .onChange(of: bannerAttachmentModel.url) { newValue in if newValue != siteInformation.myUserInfo?.localUserView.person.banner ?? "" { @@ -190,10 +188,6 @@ struct ProfileSettingsView: View { bannerAttachmentModel.url = user.person.banner ?? "" } } - } else if showCloseButton { - Button("Close", systemImage: Icons.close) { - dismiss() - } } } ToolbarItem(placement: .topBarTrailing) { @@ -227,6 +221,8 @@ struct ProfileSettingsView: View { } } else if hasEdited == .updating { ProgressView() + } else if showCloseButton { + CloseButtonView() } } } diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Comment/CommentSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Comment/CommentSettingsView.swift index df0674e66..13cf69992 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Comment/CommentSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Comment/CommentSettingsView.swift @@ -94,7 +94,7 @@ struct CommentSettingsView: View { isTicked: $shouldShowScoreInCommentBar ) SwitchableSettingsItem( - settingPictureSystemName: Icons.votes, + settingPictureSystemName: Icons.votesSquare, settingName: "Show Downvotes Separately", isTicked: $showCommentDownvotesSeparately ) diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Icons/IconSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Icons/IconSettingsView.swift index 7b0496762..71c4c1ba8 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Icons/IconSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Icons/IconSettingsView.swift @@ -20,7 +20,8 @@ struct IconSettingsView: View { .init(id: "icon.sjmarf.orange", name: "Orange"), .init(id: "icon.sjmarf.green", name: "Green"), .init(id: "icon.sjmarf.alien", name: "Alien"), - .init(id: "icon.sjmarf.silver", name: "Silver") + .init(id: "icon.sjmarf.silver", name: "Silver"), + .init(id: "icon.sjmarf.ocean", name: "Ocean") ]), .init(authorName: "Eric Andrews", collapsed: false, icons: [ diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Post/PostSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Post/PostSettingsView.swift index f3a690e88..787d235f7 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Post/PostSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Post/PostSettingsView.swift @@ -115,7 +115,7 @@ struct PostSettingsView: View { isTicked: $shouldShowScoreInPostBar ) SwitchableSettingsItem( - settingPictureSystemName: Icons.votes, + settingPictureSystemName: Icons.votesSquare, settingName: "Show Downvotes Separately", isTicked: $showDownvotesSeparately ) @@ -157,7 +157,7 @@ struct PostSettingsView: View { featuredCommunity: false, featuredLocal: false, languageId: 0, - apId: "https://lemmy.ml/post/1011068", + apId: URL(string: "https://lemmy.ml/post/1011068")!, local: true, locked: false, nsfw: false, diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Shared/LayoutWidgetEditView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Shared/LayoutWidgetEditView.swift index 72874797d..87fc8d1ea 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Shared/LayoutWidgetEditView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Shared/LayoutWidgetEditView.swift @@ -8,9 +8,14 @@ import Dependencies import SwiftUI +enum LayoutWidgetMode { + case user, moderator +} + struct LayoutWidgetEditView: View { @Environment(\.isPresented) var isPresented + let mode: LayoutWidgetMode var onSave: (_ widgets: [LayoutWidgetType]) -> Void @Namespace var animation @@ -20,7 +25,17 @@ struct LayoutWidgetEditView: View { @StateObject private var widgetModel: LayoutWidgetModel + var defaultLayout: [LayoutWidgetType] { + switch mode { + case .user: + [.scoreCounter, .infoStack, .save, .reply] + case .moderator: + [.resolve, .remove, .infoStack, .ban, .purge] + } + } + init( + mode: LayoutWidgetMode, widgets: [LayoutWidgetType], onSave: @escaping (_ widgets: [LayoutWidgetType]) -> Void ) { @@ -30,7 +45,10 @@ struct LayoutWidgetEditView: View { let bar = OrderedWidgetCollection(barWidgets, costLimit: 7) - let tray = InfiniteWidgetCollection( + let trayWidgets: [LayoutWidgetType] = switch mode { + case .moderator: + [.resolve, .remove, .ban, .purge] + case .user: [ .upvote, .downvote, @@ -40,12 +58,17 @@ struct LayoutWidgetEditView: View { .upvoteCounter, .downvoteCounter, .scoreCounter - ].map { LayoutWidget($0) } + ] + } + + let tray = InfiniteWidgetCollection( + trayWidgets.map { LayoutWidget($0) } ) _barCollection = StateObject(wrappedValue: bar) _trayCollection = StateObject(wrappedValue: tray) _widgetModel = StateObject(wrappedValue: LayoutWidgetModel(collections: [bar, tray])) + self.mode = mode } var body: some View { @@ -69,14 +92,13 @@ struct LayoutWidgetEditView: View { .zIndex(1) Spacer() Button("Reset") { - barCollection.replaceItems(with: [.scoreCounter, .infoStack, .save, .reply]) + barCollection.replaceItems(with: defaultLayout) Task { onSave(barCollection.items.map(\.type)) } - } - .foregroundStyle(.tertiary) - .padding(.bottom, 20) + .foregroundStyle(.tertiary) + .padding(.bottom, 20) } .fancyTabScrollCompatible() } @@ -173,18 +195,29 @@ struct LayoutWidgetEditView: View { func tray(_ outerFrame: CGRect) -> some View { let widgets = trayCollection.getItemDictionary() return VStack(spacing: 20) { - HStack(spacing: 20) { - trayWidgetView(.scoreCounter, widgets: widgets, outerFrame: outerFrame) - trayWidgetView(.upvoteCounter, widgets: widgets, outerFrame: outerFrame) - trayWidgetView(.downvoteCounter, widgets: widgets, outerFrame: outerFrame) - } - HStack(spacing: 20) { - trayWidgetView(.upvote, widgets: widgets, outerFrame: outerFrame) - trayWidgetView(.downvote, widgets: widgets, outerFrame: outerFrame) - trayWidgetView(.save, widgets: widgets, outerFrame: outerFrame) - trayWidgetView(.share, widgets: widgets, outerFrame: outerFrame) - trayWidgetView(.reply, widgets: widgets, outerFrame: outerFrame) + switch mode { + case .user: + HStack(spacing: 20) { + trayWidgetView(.scoreCounter, widgets: widgets, outerFrame: outerFrame) + trayWidgetView(.upvoteCounter, widgets: widgets, outerFrame: outerFrame) + trayWidgetView(.downvoteCounter, widgets: widgets, outerFrame: outerFrame) + } + HStack(spacing: 20) { + trayWidgetView(.upvote, widgets: widgets, outerFrame: outerFrame) + trayWidgetView(.downvote, widgets: widgets, outerFrame: outerFrame) + trayWidgetView(.save, widgets: widgets, outerFrame: outerFrame) + trayWidgetView(.share, widgets: widgets, outerFrame: outerFrame) + trayWidgetView(.reply, widgets: widgets, outerFrame: outerFrame) + } + case .moderator: + HStack(spacing: 20) { + trayWidgetView(.resolve, widgets: widgets, outerFrame: outerFrame) + trayWidgetView(.remove, widgets: widgets, outerFrame: outerFrame) + trayWidgetView(.ban, widgets: widgets, outerFrame: outerFrame) + trayWidgetView(.purge, widgets: widgets, outerFrame: outerFrame) + } } + Spacer() } .frame(maxWidth: .infinity) @@ -195,7 +228,6 @@ struct LayoutWidgetEditView: View { Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { - var rect = geo.frame(in: .global) .offsetBy(dx: -outerFrame.origin.x, dy: -outerFrame.origin.y) // Extend the rect into the infoText area a little diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Shared/LayoutWidgetView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Shared/LayoutWidgetView.swift index 274502f10..e564039fc 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Shared/LayoutWidgetView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/Shared/LayoutWidgetView.swift @@ -23,6 +23,8 @@ struct LayoutWidgetView: View { var body: some View { HStack(spacing: 12) { switch widget.type { + case .infoStack: + EmptyView() case .upvote: icon(Icons.upvote) case .downvote: @@ -43,8 +45,14 @@ struct LayoutWidgetView: View { icon(Icons.upvote) Text("7") icon(Icons.downvote) - default: - EmptyView() + case .resolve: + icon(Icons.resolve) + case .remove: + icon(Icons.remove) + case .purge: + icon(Icons.purge) + case .ban: + icon(Icons.communityBan) } } .frame(maxWidth: .infinity, maxHeight: 40) diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/TabBar/TabBarSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/TabBar/TabBarSettingsView.swift index 8874a093f..a1239232f 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Appearance/TabBar/TabBarSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Appearance/TabBar/TabBarSettingsView.swift @@ -11,7 +11,10 @@ import SwiftUI struct TabBarSettingsView: View { @AppStorage("profileTabLabel") var profileTabLabel: ProfileTabLabel = .nickname @AppStorage("showTabNames") var showTabNames: Bool = true - @AppStorage("showInboxUnreadBadge") var showInboxUnreadBadge: Bool = true + @AppStorage("showUnreadPersonal") var showUnreadPersonal: Bool = true + @AppStorage("showUnreadModerator") var showUnreadModerator: Bool = true + @AppStorage("showUnreadMessageReports") var showUnreadMessageReports: Bool = true + @AppStorage("showUnreadApplications") var showUnreadApplications: Bool = true @AppStorage("showUserAvatarOnProfileTab") var showUserAvatar: Bool = true @AppStorage("homeButtonExists") var homeButtonExists: Bool = false @@ -25,12 +28,34 @@ struct TabBarSettingsView: View { settingName: "Tab Labels", isTicked: $showTabNames ) - + } + + Section { SwitchableSettingsItem( settingPictureSystemName: Icons.unreadBadge, - settingName: "Inbox Unread Count", - isTicked: $showInboxUnreadBadge + settingName: "Personal Notifications", + isTicked: $showUnreadPersonal + ) + + SwitchableSettingsItem( + settingPictureSystemName: Icons.moderation, + settingName: "Post and Comment Reports", + isTicked: $showUnreadModerator ) + + SwitchableSettingsItem( + settingPictureSystemName: Icons.messageReportSetting, + settingName: "Message Reports", + isTicked: $showUnreadMessageReports + ) + + SwitchableSettingsItem( + settingPictureSystemName: Icons.registrationApplication, + settingName: "Registration Applications", + isTicked: $showUnreadApplications + ) + } header: { + Text("Inbox Unread Badge") } // TODO: options like this will need to be updated to only show when there is an active account diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Filters/FiltersSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Filters/FiltersSettingsView.swift index e59559928..b7b953b24 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/Filters/FiltersSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/Filters/FiltersSettingsView.swift @@ -44,7 +44,7 @@ struct FiltersSettingsView: View { } header: { Text("Filtered Keywords") } footer: { - Text("Posts containing these keywords in their title will not be shown.") + Text("Posts containing these keywords in their title will not be shown. Posts are not hidden for communities you moderate.") } Section { diff --git a/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift index e3ef76556..b443075c4 100644 --- a/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift +++ b/Mlem/Views/Tabs/Settings/Components/Views/General/GeneralSettingsView.swift @@ -16,14 +16,12 @@ struct GeneralSettingsView: View { @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast @AppStorage("appLock") var appLock: AppLock = .disabled @AppStorage("tapCommentToCollapse") var tapCommentToCollapse: Bool = true - @AppStorage("easyTapLinkDisplayMode") var easyTapLinkDisplayMode: EasyTapLinkDisplayMode = .contextual + @AppStorage("markReadOnScroll") var markReadOnScroll: Bool = false @AppStorage("defaultFeed") var defaultFeed: DefaultFeedType = .subscribed @AppStorage("hapticLevel") var hapticLevel: HapticPriority = .low @AppStorage("upvoteOnSave") var upvoteOnSave: Bool = false - - @AppStorage("openLinksInBrowser") var openLinksInBrowser: Bool = false @EnvironmentObject var appState: AppState @@ -32,11 +30,6 @@ struct GeneralSettingsView: View { var body: some View { List { Section { - SwitchableSettingsItem( - settingPictureSystemName: Icons.browser, - settingName: "Open Links in Browser", - isTicked: $openLinksInBrowser - ) SelectableSettingsItem( settingIconSystemName: Icons.haptics, settingName: "Haptic Level", @@ -53,12 +46,16 @@ struct GeneralSettingsView: View { settingName: "Upvote on Save", isTicked: $upvoteOnSave ) - SelectableSettingsItem( - settingIconSystemName: Icons.websiteAddress, - settingName: "Tappable Links", - currentValue: $easyTapLinkDisplayMode, - options: EasyTapLinkDisplayMode.allCases + SwitchableSettingsItem( + settingPictureSystemName: Icons.read, + settingName: "Mark Read on Scroll", + isTicked: $markReadOnScroll ) + .disabled(siteInformation.version ?? .infinity <= .init("0.19.0")) + } footer: { + if siteInformation.version ?? .infinity <= .init("0.19.0") { + Text("Mark read on scroll is only available on instances running v0.19.0 or greater.") + } } Section { diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Links/LinksSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Links/LinksSettingsView.swift new file mode 100644 index 000000000..f4fa847f7 --- /dev/null +++ b/Mlem/Views/Tabs/Settings/Components/Views/Links/LinksSettingsView.swift @@ -0,0 +1,57 @@ +// +// LinksSettingsView.swift +// Mlem +// +// Created by Sjmarf on 17/03/2024. +// + +import SwiftUI + +struct LinksSettingsView: View { + @AppStorage("openLinksInBrowser") var openLinksInBrowser: Bool = false + @AppStorage("openLinksInReaderMode") var openLinksInReaderMode: Bool = false + @AppStorage("easyTapLinkDisplayMode") var easyTapLinkDisplayMode: EasyTapLinkDisplayMode = .contextual + + var body: some View { + Form { + Section("Open External Links") { + Picker("Open External Links", selection: $openLinksInBrowser) { + Text("In-App").tag(false) + Text("In Default Browser").tag(true) + } + .pickerStyle(.inline) + .labelsHidden() + } + + Section { + SwitchableSettingsItem( + settingPictureSystemName: "doc.plaintext", + settingName: "Open in Reader", + isTicked: Binding( + get: { + !openLinksInBrowser && openLinksInReaderMode + }, + set: { newValue in + openLinksInReaderMode = newValue + } + ) + ) + .disabled(openLinksInBrowser) + } footer: { + Text("Automatically enable Reader for supported webpages. You can only enable this when using the in-app browser.") + } + Section { + SelectableSettingsItem( + settingIconSystemName: Icons.websiteAddress, + settingName: "Tappable Links", + currentValue: $easyTapLinkDisplayMode, + options: EasyTapLinkDisplayMode.allCases + ) + } + } + .fancyTabScrollCompatible() + .navigationTitle("Links") + .navigationBarColor() + .hoistNavigation() + } +} diff --git a/Mlem/Views/Tabs/Settings/Components/Views/Moderation/ModerationSettingsView.swift b/Mlem/Views/Tabs/Settings/Components/Views/Moderation/ModerationSettingsView.swift new file mode 100644 index 000000000..963825e2c --- /dev/null +++ b/Mlem/Views/Tabs/Settings/Components/Views/Moderation/ModerationSettingsView.swift @@ -0,0 +1,70 @@ +// +// ModerationSettingsView.swift +// Mlem +// +// Created by Sjmarf on 25/03/2024. +// + +import Dependencies +import SwiftUI + +enum ModerationActionGroupingMode: String { + case none, disclosureGroup, separateMenu +} + +struct ModerationSettingsView: View { + @Dependency(\.siteInformation) var siteInformation + + @AppStorage("showAllModeratorActions") var showAllModeratorActions: Bool = false + @AppStorage("moderatorActionGrouping") var moderatorActionGrouping: ModerationActionGroupingMode = .none + @AppStorage("showSettingsIcons") var showSettingsIcons: Bool = true + + var body: some View { + Form { + Section { + SwitchableSettingsItem( + settingPictureSystemName: Icons.moderation, + settingName: "Show All Actions in Feed", + isTicked: $showAllModeratorActions + ) + } footer: { + Text( + // swiftlint:disable:next line_length + "When disabled, some moderator actions will be hidden from the feed and will only be visible from when viewing a post page." + ) + } + + Section { + NavigationLink(.moderationSettings(.customizeWidgets)) { + Label { + Text("Customize Widgets") + } icon: { + if showSettingsIcons { + Image(systemName: Icons.widgetWizard) + .foregroundColor(.pink) + } + } + } + } footer: { + Text("Customize the widgets shown on Mod Mail reports.") + } + + Section("Separate moderator actions using...") { + Picker("Group actions using", selection: $moderatorActionGrouping) { + Text("Divider") + .tag(ModerationActionGroupingMode.none) + Text("Disclosure Group") + .tag(ModerationActionGroupingMode.disclosureGroup) + Text("Separate Menu") + .tag(ModerationActionGroupingMode.separateMenu) + } + .pickerStyle(.inline) + .labelsHidden() + } + } + .fancyTabScrollCompatible() + .navigationTitle("Moderation") + .navigationBarColor() + .hoistNavigation() + } +} diff --git a/Mlem/Window.swift b/Mlem/Window.swift index 386be2133..1914ced41 100644 --- a/Mlem/Window.swift +++ b/Mlem/Window.swift @@ -15,6 +15,7 @@ struct Window: View { @Dependency(\.notifier) var notifier @Dependency(\.hapticManager) var hapticManager @Dependency(\.siteInformation) var siteInformation + @Dependency(\.markReadBatcher) var markReadBatcher @StateObject var easterFlagsTracker: EasterFlagsTracker = .init() @StateObject var filtersTracker: FiltersTracker = .init() @@ -91,6 +92,12 @@ struct Window: View { private func setFlow(_ flow: AppFlow) { transition(flow) + DispatchQueue.main.async { + Task { + await markReadBatcher.flush() + markReadBatcher.clearStaged() + } + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.flow = flow } diff --git a/MlemTests/Community List/CommunityListModelTests.swift b/MlemTests/Community List/CommunityListModelTests.swift index aad9f4f96..6e77a3fd6 100644 --- a/MlemTests/Community List/CommunityListModelTests.swift +++ b/MlemTests/Community List/CommunityListModelTests.swift @@ -36,8 +36,8 @@ final class CommunityListModelTests: XCTestCase { CommunityListModel() } - // assert that even though a subscription and favorite are available nothing is present without `load()` being called - XCTAssert(model.communities.isEmpty) + // assert that initial state propagates as expected--favorited appear from tracker, but subscribed does not appear until load + XCTAssert(model.subscribed.count == 0 && model.favorited.count == 1) } func testLoadingWithNoSubscriptionsOrFavourites() async throws { @@ -53,7 +53,7 @@ final class CommunityListModelTests: XCTestCase { // ask the model to load await model.load() // assert after loading it's empty as there are no subscriptions or favourites for this user - XCTAssert(model.communities.isEmpty) + XCTAssert(model.subscribed.isEmpty && model.favorited.isEmpty) } func testLoadingWithSubscriptionAndFavorite() async throws { @@ -79,39 +79,10 @@ final class CommunityListModelTests: XCTestCase { await model.load() // assert both the favorite and subscription are present in the model - XCTAssert(model.communities.count == 2) - XCTAssert(model.communities[0].name == "favorite community") - XCTAssert(model.communities[1].name == "subscribed community") - } - - func testDuplicatesAreHandledCorrectly() async throws { - let community: APICommunity = .mock(id: 42) - - // set the above community as our only favorite - favoritesData = try JSONEncoder().encode([ - FavoriteCommunity(forAccountID: account.id, community: community) - ]) - - // use the same community as our only subscriptiom - let subscription = APICommunityView.mock(community: community, subscribed: .subscribed) - - let model = withDependencies { - $0.mainQueue = .immediate - $0.favoriteCommunitiesTracker = favoritesTracker - $0.communityRepository.subscriptions = { _ in - // provide a subscription for the user - [subscription] - } - } operation: { - CommunityListModel() - } - - // ask the model to load - await model.load() - // expectation is that although we will load the same community in favorites and subscriptions - // when the two lists combine the duplicate will be excluded, leaving only one copy of it - XCTAssert(model.communities.count == 1) - XCTAssert(model.communities[0].id == 42) + XCTAssert(model.subscribed.count == 1) + XCTAssert(model.favorited.count == 1) + XCTAssert(model.favorited[0].name == "favorite community") + XCTAssert(model.subscribed[0].name == "subscribed community") } func testSubscribedStatusIsCorrect() async throws { @@ -132,7 +103,7 @@ final class CommunityListModelTests: XCTestCase { // ask the model to load await model.load() // assert only one subscription is present - XCTAssert(model.communities.count == 1) + XCTAssert(model.subscribed.count == 1) // assert the model correctly identfies if we're subscribed XCTAssert(model.isSubscribed(to: communityView.community)) // assert the model correctly identifies when we're not subscribed by passing a different community @@ -159,17 +130,17 @@ final class CommunityListModelTests: XCTestCase { // load the model await model.load() // assert we have a blank slate - XCTAssert(model.communities.isEmpty) + XCTAssert(model.subscribed.isEmpty && model.favorited.isEmpty) // tell the model to subscribe to a community - model.updateSubscriptionStatus(for: .mock(id: 42), subscribed: true) + await model.updateSubscriptionStatus(for: .mock(id: 42), subscribed: true) // assert it is _immediately_ added to the communities (state faking) - XCTAssert(model.communities.count == 1) - XCTAssert(model.communities[0].id == 42) + XCTAssert(model.subscribed.count == 1) + XCTAssert(model.subscribed[0].id == 42) // allow suspension so the model can make the remote call (stubbed as `.updateSubscription` above) await Task.megaYield(count: 1000) // assert the community remains in our list as the _remote_ call succeeded - XCTAssert(model.communities.count == 1) - XCTAssert(model.communities[0].id == 42) + XCTAssert(model.subscribed.count == 1) + XCTAssert(model.subscribed[0].id == 42) } func testFailedSubscriptionUpdate() async throws { @@ -192,16 +163,16 @@ final class CommunityListModelTests: XCTestCase { // load the model await model.load() // assert we have a blank slate - XCTAssert(model.communities.isEmpty) + XCTAssert(model.subscribed.isEmpty && model.favorited.isEmpty) // tell the model to subscribe to a community - model.updateSubscriptionStatus(for: .mock(id: 42), subscribed: true) + await model.updateSubscriptionStatus(for: .mock(id: 42), subscribed: true) // assert it is _immediately_ added to the communities (state faking) - XCTAssert(model.communities.count == 1) - XCTAssert(model.communities[0].id == 42) + XCTAssert(model.subscribed.count == 1) + XCTAssert(model.subscribed[0].id == 42) // allow suspension so the model can make the remote call (stubbed as `.updateSubscription` above) await Task.megaYield(count: 1000) // assert the community has been removed from our list as the _remote_ call failed in this test - XCTAssert(model.communities.isEmpty) + XCTAssert(model.subscribed.isEmpty) } func testModelRespondsToFavorites() async throws { @@ -224,12 +195,14 @@ final class CommunityListModelTests: XCTestCase { // add a favorite to the tracker, expectation is the model will observe this change and update itself let favoriteCommunity = APICommunity.mock(id: 42) tracker.favorite(favoriteCommunity) + sleep(1) // give async call time to execute // assert that adding this favorite resulted in the model updating, it should now display a favorites section XCTAssert(model.visibleSections.contains(where: { $0.viewId == "favorites" })) - XCTAssert(model.communities.first! == favoriteCommunity) + XCTAssert(model.favorited.first! == favoriteCommunity) // now unfavorite the community tracker.unfavorite(favoriteCommunity.id) // assert that the favorites section is no longer included + sleep(1) // give async call time to execute XCTAssertFalse(model.visibleSections.contains(where: { $0.viewId == "favorites" })) } @@ -258,33 +231,33 @@ final class CommunityListModelTests: XCTestCase { // ask the model to load await model.load() // assert all the communities are present - XCTAssert(model.communities.count == communities.count) + XCTAssert(model.subscribed.count == communities.count) // assert we have the correct number of visible sections, some will group together... XCTAssert(model.visibleSections.count == 5) // assuming alphabetical ordering, assert we get the correct communities back for each section XCTAssertEqual( // section 0 (aka 'A') should include 'accordion' - model.communities(for: model.visibleSections[0]), + model.visibleSections[0].communities, [communities[0].community] ) XCTAssertEqual( // section 1 (aka 'G') should include 'glockenspiel' - model.communities(for: model.visibleSections[1]), + model.visibleSections[1].communities, [communities[5].community] ) XCTAssertEqual( // section 2 (aka 'H') should include 'harmonica' and 'harp' - model.communities(for: model.visibleSections[2]), + model.visibleSections[2].communities, [communities[2].community, communities[1].community] ) XCTAssertEqual( // section 3 (aka 'T') should include 'trombone' and 'tuba' - model.communities(for: model.visibleSections[3]), + model.visibleSections[3].communities, [communities[3].community, communities[6].community] ) XCTAssertEqual( // section 4 (aka 'X') should include 'xylophone' - model.communities(for: model.visibleSections[4]), + model.visibleSections[4].communities, [communities[4].community] ) } @@ -303,17 +276,17 @@ final class CommunityListModelTests: XCTestCase { // - non-letter (symbols/numerics) // assert we have 26 for alphabet + 3 - XCTAssert(model.allSections().count == 29) + XCTAssert(model.allSections.count == 29) // assert order - XCTAssert(model.allSections()[0].viewId == "top") - XCTAssert(model.allSections()[1].viewId == "favorites") + XCTAssert(model.allSections[0].viewId == "top") + XCTAssert(model.allSections[1].viewId == "favorites") + XCTAssert(model.allSections[2].viewId == "#") let alphabet: [String] = .alphabet - let offset = 2 + let offset = 3 alphabet.enumerated().forEach { index, character in - XCTAssert(model.allSections()[index + offset].viewId == character) + XCTAssert(model.allSections[index + offset].viewId == character) } - XCTAssert(model.allSections()[28].viewId == "non_letter_titles") } // MARK: - Helpers diff --git a/MlemTests/Persistence/PersistenceRepositoryTests.swift b/MlemTests/Persistence/PersistenceRepositoryTests.swift index eebc54156..18ceac586 100644 --- a/MlemTests/Persistence/PersistenceRepositoryTests.swift +++ b/MlemTests/Persistence/PersistenceRepositoryTests.swift @@ -230,7 +230,8 @@ final class PersistenceRepositoryTests: XCTestCase { func testLoadLayoutWidgetsWithValues() async throws { let postWidgets: [LayoutWidgetType] = [.upvote, .downvote] let commentWidgets: [LayoutWidgetType] = [.reply, .share] - let widgets = LayoutWidgetGroups(post: postWidgets, comment: commentWidgets) + let moderatorWidgets: [LayoutWidgetType] = [.resolve, .ban, .remove] + let widgets = LayoutWidgetGroups(post: postWidgets, comment: commentWidgets, moderator: moderatorWidgets) try await repository.saveLayoutWidgets(widgets) // write the examples to disk let loadedWidgets = repository.loadLayoutWidgets() // read them back diff --git a/PrivacyInfo.xcprivacy b/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..7abdf4c4d --- /dev/null +++ b/PrivacyInfo.xcprivacy @@ -0,0 +1,17 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + +