From 7c024dd5fd9d35f8df8a6e11754653c532b6ad88 Mon Sep 17 00:00:00 2001 From: Eric Andrews Date: Wed, 1 Nov 2023 11:26:15 -0400 Subject: [PATCH] Generic Multi-Trackers, Inbox Middleware (#726) --- Mlem.xcodeproj/project.pbxproj | 193 ++++++-- .../xcshareddata/swiftpm/Package.resolved | 9 + Mlem/API/APIClient/APIClient.swift | 7 + .../API/Models/Comments/APICommentReply.swift | 15 + .../Models/Messages/APIPrivateMessage.swift | 15 + Mlem/API/Models/Person/APIPersonMention.swift | 25 +- Mlem/API/Models/ScoringOperation.swift | 11 + .../CreatePrivateMessageRequest.swift | 10 + Mlem/App Constants.swift | 2 + .../InboxRepository+Dependency.swift | 19 + Mlem/Enums/Content Type.swift | 2 +- Mlem/Enums/ContentIdentifiable.swift | 12 + Mlem/Enums/Inbox Item Type.swift | 25 -- Mlem/Enums/LoadingState.swift | 15 + Mlem/Extensions/AssociatedColor.swift | 13 + .../Inbox Items/MentionModel+InboxItem.swift | 18 + .../Inbox Items/MessageModel+InboxItem.swift | 17 + .../Inbox Items/ReplyModel+InboxItem.swift | 17 + .../MentionModel+TrackerItem.swift | 17 + .../MessageModel+TrackerItem.swift | 17 + .../ReplyModel+TrackerItem.swift | 17 + Mlem/Icons.swift | 9 +- .../ConcreteEditorModel.swift | 6 +- .../Replies/ReplyToCommentReply.swift | 9 +- .../Replies/ReplyToMention.swift | 9 +- .../Replies/ReplyToMessage.swift | 8 +- .../Reports/ReportCommentReply.swift | 9 +- .../Reports/ReportMention.swift | 8 +- .../Reports/ReportMessage.swift | 8 +- Mlem/Models/Content/Inbox/MentionModel.swift | 365 +++++++++++++++ Mlem/Models/Content/Inbox/MessageModel.swift | 222 ++++++++++ Mlem/Models/Content/Inbox/ReplyModel.swift | 359 +++++++++++++++ Mlem/Models/Trackers/Feed/ChildTracker.swift | 94 ++++ .../Trackers/Feed/ChildTrackerProtocol.swift | 30 ++ Mlem/Models/Trackers/Feed/CoreTracker.swift | 72 +++ Mlem/Models/Trackers/Feed/ParentTracker.swift | 183 ++++++++ .../Trackers/Feed/ParentTrackerProtocol.swift | 19 + .../Trackers/Feed/StandardTracker.swift | 168 +++++++ Mlem/Models/Trackers/Feed/TrackerItem.swift | 20 + Mlem/Models/Trackers/Feed/TrackerSort.swift | 40 ++ Mlem/Models/Trackers/Inbox/Inbox Item.swift | 35 -- .../Models/Trackers/Inbox/Inbox Tracker.swift | 12 - Mlem/Models/Trackers/Inbox/InboxItem.swift | 46 ++ Mlem/Models/Trackers/Inbox/InboxTracker.swift | 39 ++ .../Trackers/Inbox/MentionTracker.swift | 28 ++ .../Trackers/Inbox/Mentions Tracker.swift | 36 -- .../Trackers/Inbox/MessageTracker.swift | 27 ++ .../Trackers/Inbox/Messages Tracker.swift | 31 -- .../Trackers/Inbox/Replies Tracker.swift | 31 -- Mlem/Models/Trackers/Inbox/ReplyTracker.swift | 28 ++ .../Models/Trackers/Inbox/UnreadTracker.swift | 43 ++ .../Trackers/RecentSearchesTracker.swift | 5 +- Mlem/Repositories/CommentRepository.swift | 50 --- Mlem/Repositories/InboxRepository.swift | 160 +++++++ .../Components/ScoreCounterView.swift | 13 +- .../Shared/Components/End Of Feed View.swift | 32 +- Mlem/Views/Shared/Search Bar/SearchBar.swift | 414 +++++++++--------- Mlem/Views/Tabs/Feeds/Feed View.swift | 5 +- ...Feed View.swift => AllItemsFeedView.swift} | 45 +- .../Feed/InteractionSwipeAndMenuHelpers.swift | 348 --------------- .../Feed/Item Types/Inbox Mention View.swift | 72 +-- .../Feed/Item Types/Inbox Message View.swift | 46 +- .../Feed/Item Types/Inbox Reply View.swift | 72 +-- .../Tabs/Inbox/Feed/Mentions Feed View.swift | 69 +-- .../Tabs/Inbox/Feed/Messages Feed View.swift | 62 +-- .../Tabs/Inbox/Feed/Replies Feed View.swift | 76 +--- Mlem/Views/Tabs/Inbox/Inbox View Logic.swift | 397 ----------------- Mlem/Views/Tabs/Inbox/Inbox View.swift | 82 ++-- Mlem/Views/Tabs/Inbox/InboxView+Logic.swift | 77 ++++ .../Tabs/Search/SearchResultListView.swift | 1 - 70 files changed, 2948 insertions(+), 1548 deletions(-) create mode 100644 Mlem/Dependency/InboxRepository+Dependency.swift create mode 100644 Mlem/Enums/ContentIdentifiable.swift delete mode 100644 Mlem/Enums/Inbox Item Type.swift create mode 100644 Mlem/Enums/LoadingState.swift create mode 100644 Mlem/Extensions/AssociatedColor.swift create mode 100644 Mlem/Extensions/Tracker Items/Inbox Items/MentionModel+InboxItem.swift create mode 100644 Mlem/Extensions/Tracker Items/Inbox Items/MessageModel+InboxItem.swift create mode 100644 Mlem/Extensions/Tracker Items/Inbox Items/ReplyModel+InboxItem.swift create mode 100644 Mlem/Extensions/Tracker Items/MentionModel+TrackerItem.swift create mode 100644 Mlem/Extensions/Tracker Items/MessageModel+TrackerItem.swift create mode 100644 Mlem/Extensions/Tracker Items/ReplyModel+TrackerItem.swift create mode 100644 Mlem/Models/Content/Inbox/MentionModel.swift create mode 100644 Mlem/Models/Content/Inbox/MessageModel.swift create mode 100644 Mlem/Models/Content/Inbox/ReplyModel.swift create mode 100644 Mlem/Models/Trackers/Feed/ChildTracker.swift create mode 100644 Mlem/Models/Trackers/Feed/ChildTrackerProtocol.swift create mode 100644 Mlem/Models/Trackers/Feed/CoreTracker.swift create mode 100644 Mlem/Models/Trackers/Feed/ParentTracker.swift create mode 100644 Mlem/Models/Trackers/Feed/ParentTrackerProtocol.swift create mode 100644 Mlem/Models/Trackers/Feed/StandardTracker.swift create mode 100644 Mlem/Models/Trackers/Feed/TrackerItem.swift create mode 100644 Mlem/Models/Trackers/Feed/TrackerSort.swift delete mode 100644 Mlem/Models/Trackers/Inbox/Inbox Item.swift delete mode 100644 Mlem/Models/Trackers/Inbox/Inbox Tracker.swift create mode 100644 Mlem/Models/Trackers/Inbox/InboxItem.swift create mode 100644 Mlem/Models/Trackers/Inbox/InboxTracker.swift create mode 100644 Mlem/Models/Trackers/Inbox/MentionTracker.swift delete mode 100644 Mlem/Models/Trackers/Inbox/Mentions Tracker.swift create mode 100644 Mlem/Models/Trackers/Inbox/MessageTracker.swift delete mode 100644 Mlem/Models/Trackers/Inbox/Messages Tracker.swift delete mode 100644 Mlem/Models/Trackers/Inbox/Replies Tracker.swift create mode 100644 Mlem/Models/Trackers/Inbox/ReplyTracker.swift create mode 100644 Mlem/Repositories/InboxRepository.swift rename Mlem/Views/Tabs/Inbox/Feed/{All Items Feed View.swift => AllItemsFeedView.swift} (51%) delete mode 100644 Mlem/Views/Tabs/Inbox/Feed/InteractionSwipeAndMenuHelpers.swift delete mode 100644 Mlem/Views/Tabs/Inbox/Inbox View Logic.swift create mode 100644 Mlem/Views/Tabs/Inbox/InboxView+Logic.swift diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index 7bc8c65e3..f07412ebf 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -316,7 +316,6 @@ CD04D5E72A3636FB008EF95B /* Headline Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD04D5E62A3636FB008EF95B /* Headline Post.swift */; }; CD05E7792A4E381A0081D102 /* PostSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD05E7782A4E381A0081D102 /* PostSize.swift */; }; CD05E77F2A4F263B0081D102 /* Menu Function.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD05E77E2A4F263B0081D102 /* Menu Function.swift */; }; - CD05E7812A4F7A4B0081D102 /* Inbox Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD05E7802A4F7A4B0081D102 /* Inbox Tracker.swift */; }; CD0BE42F2A65A73600314B24 /* Haptic Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0BE42E2A65A73600314B24 /* Haptic Manager.swift */; }; CD1446182A58FC3B00610EF1 /* InfoStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1446172A58FC3B00610EF1 /* InfoStackView.swift */; }; CD14461B2A5A4B6D00610EF1 /* PostSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD14461A2A5A4B6D00610EF1 /* PostSettingsView.swift */; }; @@ -325,7 +324,6 @@ CD1446252A5B357900610EF1 /* Document.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1446242A5B357900610EF1 /* Document.swift */; }; CD1446272A5B36DA00610EF1 /* EULA.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1446262A5B36DA00610EF1 /* EULA.swift */; }; CD1824402AA8E24100D9BEB5 /* View+DestructiveConfirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD18243F2AA8E24100D9BEB5 /* View+DestructiveConfirmation.swift */; }; - CD18DC692A51ECB6002C56BC /* InteractionSwipeAndMenuHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD18DC682A51ECB6002C56BC /* InteractionSwipeAndMenuHelpers.swift */; }; CD18DC6B2A5202D4002C56BC /* MarkPersonMentionAsReadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD18DC6A2A5202D4002C56BC /* MarkPersonMentionAsReadRequest.swift */; }; CD18DC6F2A5209C3002C56BC /* MarkPrivateMessageAsReadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD18DC6E2A5209C3002C56BC /* MarkPrivateMessageAsReadRequest.swift */; }; CD18DC732A522A7C002C56BC /* CreatePrivateMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD18DC722A522A7C002C56BC /* CreatePrivateMessageRequest.swift */; }; @@ -344,14 +342,34 @@ CD391F9C2A53980900E213B5 /* ReplyToCommentReply.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD391F9B2A53980900E213B5 /* ReplyToCommentReply.swift */; }; CD391F9E2A539F1800E213B5 /* ReplyToMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD391F9D2A539F1800E213B5 /* ReplyToMention.swift */; }; CD391FA02A545F8600E213B5 /* Compact Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD391F9F2A545F8600E213B5 /* Compact Post.swift */; }; - CD3FBCD32A4A4B8B00B2063F /* Messages Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCD22A4A4B8B00B2063F /* Messages Tracker.swift */; }; - CD3FBCD92A4A6BD100B2063F /* Replies Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCD82A4A6BD100B2063F /* Replies Tracker.swift */; }; CD3FBCDD2A4A6F0600B2063F /* GetReplies.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCDC2A4A6F0600B2063F /* GetReplies.swift */; }; - CD3FBCE12A4A836000B2063F /* All Items Feed View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3FBCE02A4A836000B2063F /* All Items Feed View.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 */; }; + CD4368B42AE23F3500BD8BD1 /* ChildTrackerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368B32AE23F3500BD8BD1 /* ChildTrackerProtocol.swift */; }; + CD4368B62AE23F4700BD8BD1 /* ParentTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368B52AE23F4700BD8BD1 /* ParentTracker.swift */; }; + CD4368B82AE23F5400BD8BD1 /* ParentTrackerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368B72AE23F5400BD8BD1 /* ParentTrackerProtocol.swift */; }; + CD4368BA2AE23F6400BD8BD1 /* TrackerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368B92AE23F6400BD8BD1 /* TrackerItem.swift */; }; + CD4368BC2AE23F6F00BD8BD1 /* TrackerSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368BB2AE23F6F00BD8BD1 /* TrackerSort.swift */; }; + CD4368BE2AE23FA600BD8BD1 /* LoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368BD2AE23FA600BD8BD1 /* LoadingState.swift */; }; + CD4368C12AE23FD400BD8BD1 /* Semaphore in Frameworks */ = {isa = PBXBuildFile; productRef = CD4368C02AE23FD400BD8BD1 /* Semaphore */; }; + CD4368C42AE240B100BD8BD1 /* MentionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368C32AE240B100BD8BD1 /* MentionModel.swift */; }; + CD4368C62AE240BF00BD8BD1 /* MessageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368C52AE240BF00BD8BD1 /* MessageModel.swift */; }; + CD4368C82AE2426700BD8BD1 /* ReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368C72AE2426700BD8BD1 /* ReplyModel.swift */; }; + CD4368CA2AE2428C00BD8BD1 /* ContentIdentifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368C92AE2428C00BD8BD1 /* ContentIdentifiable.swift */; }; + CD4368CC2AE242AD00BD8BD1 /* InboxRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368CB2AE242AD00BD8BD1 /* InboxRepository.swift */; }; + CD4368CE2AE242C900BD8BD1 /* InboxRepository+Dependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368CD2AE242C900BD8BD1 /* InboxRepository+Dependency.swift */; }; + CD4368D02AE245F400BD8BD1 /* MessageTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368CF2AE245F400BD8BD1 /* MessageTracker.swift */; }; + CD4368D22AE2460100BD8BD1 /* ReplyTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368D12AE2460100BD8BD1 /* ReplyTracker.swift */; }; + CD4368D52AE2463900BD8BD1 /* MessageModel+InboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368D42AE2463900BD8BD1 /* MessageModel+InboxItem.swift */; }; + CD4368D72AE2464D00BD8BD1 /* ReplyModel+InboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4368D62AE2464D00BD8BD1 /* ReplyModel+InboxItem.swift */; }; + 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 */; }; CD45BCEE2A75CA7200A2899C /* Thumbnail Image View.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD45BCED2A75CA7200A2899C /* Thumbnail Image View.swift */; }; CD4DBC032A6F803C001A1E61 /* ReplyToPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4DBC022A6F803C001A1E61 /* ReplyToPost.swift */; }; CD4E98A12A69BE980026C4D9 /* AlternativeIconCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4E98A02A69BE980026C4D9 /* AlternativeIconCell.swift */; }; @@ -398,10 +416,16 @@ CDA2C5262A705D6000649D5A /* PostEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA2C5252A705D6000649D5A /* PostEditor.swift */; }; CDB0117D2A6F703800D043EB /* CommentEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB0117C2A6F703800D043EB /* CommentEditor.swift */; }; CDB0117F2A6F70A000D043EB /* Editor Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB0117E2A6F70A000D043EB /* Editor Tracker.swift */; }; + CDB45C5C2AF1A1D800A1FF08 /* CoreTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB45C5B2AF1A1D800A1FF08 /* CoreTracker.swift */; }; + CDB45C5E2AF1A96C00A1FF08 /* AssociatedColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB45C5D2AF1A96C00A1FF08 /* AssociatedColor.swift */; }; + 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 */; }; 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 */; }; + 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 */; }; CDC6A8CA2A6F1C8D00CC11AC /* AssociatedIconProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC6A8C92A6F1C8D00CC11AC /* AssociatedIconProtocol.swift */; }; @@ -448,11 +472,8 @@ CDF1EF182A6C40C9003594B6 /* Menu Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF1EF172A6C40C9003594B6 /* Menu Button.swift */; }; CDF8425C2A49E4C000723DA0 /* APIPersonMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF8425B2A49E4C000723DA0 /* APIPersonMentionView.swift */; }; CDF8425E2A49E61A00723DA0 /* APIPersonMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF8425D2A49E61A00723DA0 /* APIPersonMention.swift */; }; - CDF842612A49EA3900723DA0 /* Mentions Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF842602A49EA3900723DA0 /* Mentions Tracker.swift */; }; CDF842642A49EAFA00723DA0 /* GetPersonMentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF842632A49EAFA00723DA0 /* GetPersonMentions.swift */; }; - CDF842682A49FB9000723DA0 /* Inbox View Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF842672A49FB9000723DA0 /* Inbox View Logic.swift */; }; - CDF8426B2A4A2AB600723DA0 /* Inbox Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF8426A2A4A2AB600723DA0 /* Inbox Item.swift */; }; - CDF8426F2A4A385A00723DA0 /* Inbox Item Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF8426E2A4A385A00723DA0 /* Inbox Item Type.swift */; }; + CDF8426B2A4A2AB600723DA0 /* InboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF8426A2A4A2AB600723DA0 /* InboxItem.swift */; }; CDF9EF332AB2845C003F885B /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF9EF322AB2845C003F885B /* Icons.swift */; }; E40E018C2AABF85500410B2C /* AppRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E40E018B2AABF85500410B2C /* AppRoutes.swift */; }; E40E018E2AABFBDE00410B2C /* AnyNavigationPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = E40E018D2AABFBDE00410B2C /* AnyNavigationPath.swift */; }; @@ -799,7 +820,6 @@ CD04D5E62A3636FB008EF95B /* Headline Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Headline Post.swift"; sourceTree = ""; }; 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 = ""; }; - CD05E7802A4F7A4B0081D102 /* Inbox Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Inbox Tracker.swift"; sourceTree = ""; }; CD0BE42E2A65A73600314B24 /* Haptic Manager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Haptic Manager.swift"; sourceTree = ""; }; CD1446172A58FC3B00610EF1 /* InfoStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoStackView.swift; sourceTree = ""; }; CD14461A2A5A4B6D00610EF1 /* PostSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSettingsView.swift; sourceTree = ""; }; @@ -808,7 +828,6 @@ CD1446242A5B357900610EF1 /* Document.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Document.swift; sourceTree = ""; }; CD1446262A5B36DA00610EF1 /* EULA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EULA.swift; sourceTree = ""; }; CD18243F2AA8E24100D9BEB5 /* View+DestructiveConfirmation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+DestructiveConfirmation.swift"; sourceTree = ""; }; - CD18DC682A51ECB6002C56BC /* InteractionSwipeAndMenuHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionSwipeAndMenuHelpers.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 = ""; }; CD18DC722A522A7C002C56BC /* CreatePrivateMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePrivateMessageRequest.swift; sourceTree = ""; }; @@ -827,14 +846,33 @@ CD391F9B2A53980900E213B5 /* ReplyToCommentReply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyToCommentReply.swift; sourceTree = ""; }; 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 = ""; }; - CD3FBCD22A4A4B8B00B2063F /* Messages Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Messages Tracker.swift"; sourceTree = ""; }; - CD3FBCD82A4A6BD100B2063F /* Replies Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Replies Tracker.swift"; sourceTree = ""; }; CD3FBCDC2A4A6F0600B2063F /* GetReplies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetReplies.swift; sourceTree = ""; }; - CD3FBCE02A4A836000B2063F /* All Items Feed View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "All Items Feed View.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 = ""; }; + CD4368B32AE23F3500BD8BD1 /* ChildTrackerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildTrackerProtocol.swift; sourceTree = ""; }; + CD4368B52AE23F4700BD8BD1 /* ParentTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentTracker.swift; sourceTree = ""; }; + CD4368B72AE23F5400BD8BD1 /* ParentTrackerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentTrackerProtocol.swift; sourceTree = ""; }; + CD4368B92AE23F6400BD8BD1 /* TrackerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerItem.swift; sourceTree = ""; }; + CD4368BB2AE23F6F00BD8BD1 /* TrackerSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerSort.swift; sourceTree = ""; }; + CD4368BD2AE23FA600BD8BD1 /* LoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingState.swift; sourceTree = ""; }; + CD4368C32AE240B100BD8BD1 /* MentionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionModel.swift; sourceTree = ""; }; + CD4368C52AE240BF00BD8BD1 /* MessageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageModel.swift; sourceTree = ""; }; + CD4368C72AE2426700BD8BD1 /* ReplyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyModel.swift; sourceTree = ""; }; + CD4368C92AE2428C00BD8BD1 /* ContentIdentifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentIdentifiable.swift; sourceTree = ""; }; + CD4368CB2AE242AD00BD8BD1 /* InboxRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxRepository.swift; sourceTree = ""; }; + CD4368CD2AE242C900BD8BD1 /* InboxRepository+Dependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InboxRepository+Dependency.swift"; sourceTree = ""; }; + CD4368CF2AE245F400BD8BD1 /* MessageTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTracker.swift; sourceTree = ""; }; + CD4368D12AE2460100BD8BD1 /* ReplyTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyTracker.swift; sourceTree = ""; }; + CD4368D42AE2463900BD8BD1 /* MessageModel+InboxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageModel+InboxItem.swift"; sourceTree = ""; }; + CD4368D62AE2464D00BD8BD1 /* ReplyModel+InboxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReplyModel+InboxItem.swift"; sourceTree = ""; }; + 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 = ""; }; CD45BCED2A75CA7200A2899C /* Thumbnail Image View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Thumbnail Image View.swift"; sourceTree = ""; }; CD4DBC022A6F803C001A1E61 /* ReplyToPost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyToPost.swift; sourceTree = ""; }; CD4E98A02A69BE980026C4D9 /* AlternativeIconCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AlternativeIconCell.swift; path = Mlem/Extensions/AlternativeIconCell.swift; sourceTree = SOURCE_ROOT; }; @@ -881,10 +919,16 @@ CDA2C5252A705D6000649D5A /* PostEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditor.swift; sourceTree = ""; }; CDB0117C2A6F703800D043EB /* CommentEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditor.swift; sourceTree = ""; }; CDB0117E2A6F70A000D043EB /* Editor Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Editor Tracker.swift"; sourceTree = ""; }; + CDB45C5B2AF1A1D800A1FF08 /* CoreTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreTracker.swift; sourceTree = ""; }; + CDB45C5D2AF1A96C00A1FF08 /* AssociatedColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssociatedColor.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; + 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 = ""; }; CDC6A8C92A6F1C8D00CC11AC /* AssociatedIconProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssociatedIconProtocol.swift; sourceTree = ""; }; @@ -931,11 +975,8 @@ CDF1EF172A6C40C9003594B6 /* Menu Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Menu Button.swift"; sourceTree = ""; }; CDF8425B2A49E4C000723DA0 /* APIPersonMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPersonMentionView.swift; sourceTree = ""; }; CDF8425D2A49E61A00723DA0 /* APIPersonMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPersonMention.swift; sourceTree = ""; }; - CDF842602A49EA3900723DA0 /* Mentions Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mentions Tracker.swift"; sourceTree = ""; }; CDF842632A49EAFA00723DA0 /* GetPersonMentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPersonMentions.swift; sourceTree = ""; }; - CDF842672A49FB9000723DA0 /* Inbox View Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Inbox View Logic.swift"; sourceTree = ""; }; - CDF8426A2A4A2AB600723DA0 /* Inbox Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Inbox Item.swift"; sourceTree = ""; }; - CDF8426E2A4A385A00723DA0 /* Inbox Item Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Inbox Item Type.swift"; sourceTree = ""; }; + CDF8426A2A4A2AB600723DA0 /* InboxItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxItem.swift; sourceTree = ""; }; CDF9EF322AB2845C003F885B /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; }; E40E018B2AABF85500410B2C /* AppRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoutes.swift; sourceTree = ""; }; E40E018D2AABFBDE00410B2C /* AnyNavigationPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyNavigationPath.swift; sourceTree = ""; }; @@ -967,6 +1008,7 @@ B104A6D82A59BF3C00B3E725 /* Nuke in Frameworks */, 50C99B562A61D792005D57DD /* Dependencies in Frameworks */, 636250DC2A18111400FC59B4 /* KeychainAccess in Frameworks */, + CD4368C12AE23FD400BD8BD1 /* Semaphore in Frameworks */, B104A6DE2A59BF3C00B3E725 /* NukeVideo in Frameworks */, B104A6DC2A59BF3C00B3E725 /* NukeUI in Frameworks */, ); @@ -1297,6 +1339,7 @@ 500C168D2A66FAAB006F243B /* HapticManager+Dependency.swift */, 5064D03E2A6DE0DB00B22EE3 /* Notifier+Dependency.swift */, 50785F722A98E03F00117245 /* SiteInformationTracker+Dependency.swift */, + CD4368CD2AE242C900BD8BD1 /* InboxRepository+Dependency.swift */, ); path = Dependency; sourceTree = ""; @@ -1310,6 +1353,7 @@ 030E863A2AC6C3B1000283A6 /* PictrsRespository.swift */, 50A881292A72D6BD003E3661 /* CommunityRepository.swift */, 50A881232A71A4CD003E3661 /* PersistenceRepository.swift */, + CD4368CB2AE242AD00BD8BD1 /* InboxRepository.swift */, ); path = Repositories; sourceTree = ""; @@ -1344,6 +1388,14 @@ 50F830F72A4C92BF00D67099 /* FeedTrackerItemProviding.swift */, 50F830F92A4C935C00D67099 /* FeedTracker.swift */, 50D61E5C2AA4904F00A926EC /* FeedTracking.swift */, + CD4368AD2AE23ED400BD8BD1 /* StandardTracker.swift */, + CD4368AF2AE23F1400BD8BD1 /* ChildTracker.swift */, + CD4368B32AE23F3500BD8BD1 /* ChildTrackerProtocol.swift */, + CD4368B52AE23F4700BD8BD1 /* ParentTracker.swift */, + CD4368B72AE23F5400BD8BD1 /* ParentTrackerProtocol.swift */, + CD4368B92AE23F6400BD8BD1 /* TrackerItem.swift */, + CD4368BB2AE23F6F00BD8BD1 /* TrackerSort.swift */, + CDB45C5B2AF1A1D800A1FF08 /* CoreTracker.swift */, ); path = Feed; sourceTree = ""; @@ -1459,6 +1511,7 @@ 6332FDC127EFCB530009A98A /* Extensions */ = { isa = PBXGroup; children = ( + CD4368D32AE2462A00BD8BD1 /* Tracker Items */, CD18243E2AA8E23100D9BEB5 /* View Modifiers */, 63344C612A08460D001BC616 /* View - Border on Specific Sides.swift */, CD82A24B2A70A26900111034 /* View - CustomBadge.swift */, @@ -1497,6 +1550,7 @@ 63F0C7BA2A058CB700A18C5D /* URLSessionWebSocketTask - Send Ping.swift */, 50CC4A712A9CB07F0074C845 /* TimeInterval+Period.swift */, 503422552AAB784000EFE88D /* Environment+AppFlow.swift */, + CDB45C5D2AF1A96C00A1FF08 /* AssociatedColor.swift */, ); path = Extensions; sourceTree = ""; @@ -1943,12 +1997,13 @@ 6317ABCA2A37292700603D76 /* FeedType.swift */, 6307378C2A1CEB7C00039852 /* My Vote.swift */, CD6483352A39F20800EE6CA3 /* Post Type.swift */, - CDF8426E2A4A385A00723DA0 /* Inbox Item Type.swift */, 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.swift */, CDDCF6522A677F45003DA3AC /* TabSelection.swift */, CDE9CE4E2A7B0B1B002B97DD /* Haptic.swift */, CDEBC3242A9A57D200518D9D /* Content Type.swift */, CD2053132ACBAF150000AA38 /* AvatarType.swift */, + CD4368BD2AE23FA600BD8BD1 /* LoadingState.swift */, + CD4368C92AE2428C00BD8BD1 /* ContentIdentifiable.swift */, ); path = Enums; sourceTree = ""; @@ -2003,7 +2058,7 @@ children = ( CDE6A80E2A4908200062D161 /* Feed */, 6DFF50422A48DED3001E648D /* Inbox View.swift */, - CDF842672A49FB9000723DA0 /* Inbox View Logic.swift */, + CD4368DC2AE24E1A00BD8BD1 /* InboxView+Logic.swift */, ); path = Inbox; sourceTree = ""; @@ -2162,6 +2217,27 @@ path = "Response Composers"; sourceTree = ""; }; + CD4368C22AE2409D00BD8BD1 /* Inbox */ = { + isa = PBXGroup; + children = ( + CD4368C32AE240B100BD8BD1 /* MentionModel.swift */, + CD4368C52AE240BF00BD8BD1 /* MessageModel.swift */, + CD4368C72AE2426700BD8BD1 /* ReplyModel.swift */, + ); + path = Inbox; + sourceTree = ""; + }; + CD4368D32AE2462A00BD8BD1 /* Tracker Items */ = { + isa = PBXGroup; + children = ( + CDB45C652AF1AFC900A1FF08 /* Inbox Items */, + CDB45C5F2AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift */, + CDB45C612AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift */, + CDB45C632AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift */, + ); + path = "Tracker Items"; + sourceTree = ""; + }; CD525F662A4B892900BCA794 /* Links */ = { isa = PBXGroup; children = ( @@ -2240,6 +2316,16 @@ path = Editors; sourceTree = ""; }; + CDB45C652AF1AFC900A1FF08 /* Inbox Items */ = { + isa = PBXGroup; + children = ( + CD4368D42AE2463900BD8BD1 /* MessageModel+InboxItem.swift */, + CD4368D62AE2464D00BD8BD1 /* ReplyModel+InboxItem.swift */, + CD4368D82AE2478300BD8BD1 /* MentionModel+InboxItem.swift */, + ); + path = "Inbox Items"; + sourceTree = ""; + }; CDC1C93D2A7AB8B400072E3D /* Accessibility */ = { isa = PBXGroup; children = ( @@ -2293,11 +2379,10 @@ isa = PBXGroup; children = ( CDE6A8142A490AC60062D161 /* Item Types */, - CD3FBCE02A4A836000B2063F /* All Items Feed View.swift */, + CD3FBCE02A4A836000B2063F /* AllItemsFeedView.swift */, CD3FBCE22A4A844800B2063F /* Replies Feed View.swift */, CD3FBCE42A4A89B900B2063F /* Mentions Feed View.swift */, CD3FBCE62A4A8CE300B2063F /* Messages Feed View.swift */, - CD18DC682A51ECB6002C56BC /* InteractionSwipeAndMenuHelpers.swift */, ); path = Feed; sourceTree = ""; @@ -2356,6 +2441,7 @@ CDEBC3262A9A57E900518D9D /* Content */ = { isa = PBXGroup; children = ( + CD4368C22AE2409D00BD8BD1 /* Inbox */, CDEBC3272A9A57F200518D9D /* Content Model Identifier.swift */, CDEBC3292A9A580B00518D9D /* Post Model.swift */, 03FD64FD2AE538C600957AA9 /* Community */, @@ -2380,12 +2466,12 @@ CDF8425F2A49EA2A00723DA0 /* Inbox */ = { isa = PBXGroup; children = ( - CDF8426A2A4A2AB600723DA0 /* Inbox Item.swift */, - CDF842602A49EA3900723DA0 /* Mentions Tracker.swift */, - CD3FBCD22A4A4B8B00B2063F /* Messages Tracker.swift */, - CD3FBCD82A4A6BD100B2063F /* Replies Tracker.swift */, - CD05E7802A4F7A4B0081D102 /* Inbox Tracker.swift */, + CDF8426A2A4A2AB600723DA0 /* InboxItem.swift */, CD82A2582A71775E00111034 /* UnreadTracker.swift */, + CD4368CF2AE245F400BD8BD1 /* MessageTracker.swift */, + CD4368D12AE2460100BD8BD1 /* ReplyTracker.swift */, + CD4368DA2AE247B700BD8BD1 /* MentionTracker.swift */, + CDC3E7FF2AEAFEAF008062CA /* InboxTracker.swift */, ); path = Inbox; sourceTree = ""; @@ -2483,6 +2569,7 @@ B104A6DB2A59BF3C00B3E725 /* NukeUI */, B104A6DD2A59BF3C00B3E725 /* NukeVideo */, 50C99B552A61D792005D57DD /* Dependencies */, + CD4368C02AE23FD400BD8BD1 /* Semaphore */, ); productName = Mlem; productReference = 6363D5C127EE196700E34822 /* Mlem.app */; @@ -2561,6 +2648,7 @@ 636250DA2A18111400FC59B4 /* XCRemoteSwiftPackageReference "KeychainAccess" */, B104A6D62A59BF3C00B3E725 /* XCRemoteSwiftPackageReference "Nuke" */, 50C99B542A61D792005D57DD /* XCRemoteSwiftPackageReference "swift-dependencies" */, + CD4368BF2AE23FD400BD8BD1 /* XCRemoteSwiftPackageReference "Semaphore" */, ); productRefGroup = 6363D5C227EE196700E34822 /* Products */; projectDirPath = ""; @@ -2679,6 +2767,7 @@ 039439932A99098900463032 /* InternetConnectionManager.swift in Sources */, CD82A2592A71775E00111034 /* UnreadTracker.swift in Sources */, CDEBC32E2A9A583900518D9D /* Post Tracker.swift in Sources */, + CD4368CA2AE2428C00BD8BD1 /* ContentIdentifiable.swift in Sources */, CD3FBCE32A4A844800B2063F /* Replies Feed View.swift in Sources */, 637218652A3A2AAD008C4816 /* GetPosts.swift in Sources */, E4DDB4342A819C8000B3A7E0 /* QuickLookView.swift in Sources */, @@ -2702,6 +2791,7 @@ CDE6A8162A490AE00062D161 /* Inbox Message View.swift in Sources */, CD04D5DD2A361564008EF95B /* ReplyButtonView.swift in Sources */, 6314C9EB2A18D9C500B08405 /* Reply Editor.swift in Sources */, + CD4368D22AE2460100BD8BD1 /* ReplyTracker.swift in Sources */, 6DFF50452A48E373001E648D /* GetPrivateMessages.swift in Sources */, ADDC9E3A2A5CEAA100383D58 /* BlockPerson.swift in Sources */, CD6F29A82A77FF1700F20B6B /* MarkPostRead.swift in Sources */, @@ -2711,7 +2801,6 @@ 50811B3E2A9205BA006BA3F2 /* GetCommunityResponse+Mock.swift in Sources */, E48DE4A22AC3F23F004E6291 /* DestinationValue.swift in Sources */, E4DDB4322A81819300B3A7E0 /* Double.swift in Sources */, - CD3FBCD92A4A6BD100B2063F /* Replies Tracker.swift in Sources */, 5016A2B32A67EC0700B257E8 /* NotificationDisplayer.swift in Sources */, E42D9B5A2AD6802B0087693C /* OnboardingRoutes.swift in Sources */, CD1446212A5B328E00610EF1 /* Privacy Policy.swift in Sources */, @@ -2723,6 +2812,7 @@ 500C168E2A66FAAB006F243B /* HapticManager+Dependency.swift in Sources */, 038A16E52A7A97380087987E /* LayoutWidgetView.swift in Sources */, E40E018E2AABFBDE00410B2C /* AnyNavigationPath.swift in Sources */, + CD4368CE2AE242C900BD8BD1 /* InboxRepository+Dependency.swift in Sources */, CD1446252A5B357900610EF1 /* Document.swift in Sources */, CDEBC32C2A9A582500518D9D /* Votes Model.swift in Sources */, CDEBC3282A9A57F200518D9D /* Content Model Identifier.swift in Sources */, @@ -2733,6 +2823,7 @@ 6318EDC727EE4E1500BFCAE8 /* Post.swift in Sources */, 03EC92992AC0BF8A007BBE7E /* APIClient+Pictrs.swift in Sources */, 6372186C2A3A2AAD008C4816 /* SaveComment.swift in Sources */, + CD4368B62AE23F4700BD8BD1 /* ParentTracker.swift in Sources */, E4D4DBA22A7F233200C4F3DE /* FancyTabNavigationSelectionHashValueEnvironmentKey.swift in Sources */, 6DD8677A2A5083A200BEB00F /* Community Sidebar Link.swift in Sources */, 03C898032AC04F61005F3403 /* RecentSearchesView.swift in Sources */, @@ -2741,12 +2832,14 @@ 507573962A5AD5CF00AA7ABD /* ContextualError.swift in Sources */, 50C99B592A61D889005D57DD /* APIClient+Dependency.swift in Sources */, CDDCF6572A678298003DA3AC /* FancyTabBarSelection.swift in Sources */, + CDB45C5E2AF1A96C00A1FF08 /* AssociatedColor.swift in Sources */, CD3FBCE92A4B482700B2063F /* Generic Merge.swift in Sources */, E47B2B762A902DE200629AF7 /* SettingsValues.swift in Sources */, CDA145ED2A510AC100DDAFC9 /* MarkCommentReplyAsReadRequest.swift in Sources */, CD391F982A537E8E00E213B5 /* ReplyToComment.swift in Sources */, 5064D03D2A6DE0AA00B22EE3 /* Notifier.swift in Sources */, CDC65D912A86B830007205E5 /* DeleteAccountView.swift in Sources */, + CDC3E8002AEAFEAF008062CA /* InboxTracker.swift in Sources */, CD391F9C2A53980900E213B5 /* ReplyToCommentReply.swift in Sources */, 030E863D2AC6C49E000283A6 /* PictrsRepository+Dependency.swift in Sources */, 630737892A1CD1E900039852 /* String.swift in Sources */, @@ -2758,10 +2851,10 @@ 637218752A3A2AAD008C4816 /* GetCommunity.swift in Sources */, CDF1EF142A6B6D6E003594B6 /* Feed View Logic.swift in Sources */, 6DFF50432A48DED3001E648D /* Inbox View.swift in Sources */, - CDF8426F2A4A385A00723DA0 /* Inbox Item Type.swift in Sources */, 03EEEAF72AB8ED3C0087F8D8 /* SearchTabPicker.swift in Sources */, CD2053102AC878B50000AA38 /* UpdatedTimestampView.swift in Sources */, CD1446232A5B336900610EF1 /* LicensesView.swift in Sources */, + CD4368D02AE245F400BD8BD1 /* MessageTracker.swift in Sources */, CDDCF6432A66343D003DA3AC /* FancyTabBar.swift in Sources */, 505240E52A86E32700EA4558 /* CommunityListModel.swift in Sources */, CD05E77F2A4F263B0081D102 /* Menu Function.swift in Sources */, @@ -2770,7 +2863,6 @@ 6386E02C2A03D1EC006B3C1D /* App State.swift in Sources */, 504106CD2A744D7F000AAEF8 /* CommentRepository+Dependency.swift in Sources */, 6372186F2A3A2AAD008C4816 /* SearchRequest.swift in Sources */, - CD05E7812A4F7A4B0081D102 /* Inbox Tracker.swift in Sources */, 03EC92952AC064AE007BBE7E /* SearchHomeView.swift in Sources */, 50811B362A920519006BA3F2 /* APISite+Mock.swift in Sources */, 503422562AAB784000EFE88D /* Environment+AppFlow.swift in Sources */, @@ -2791,6 +2883,7 @@ 6332FDC027EFB05F0009A98A /* Settings Item.swift in Sources */, 031A93D62AC847DA0077030C /* UploadConfirmationView.swift in Sources */, CD8C55342A95515C0060B75B /* Onboarding Text.swift in Sources */, + CD4368CC2AE242AD00BD8BD1 /* InboxRepository.swift in Sources */, 50C99B602A6299D8005D57DD /* ErrorHandler.swift in Sources */, 50F830F82A4C92BF00D67099 /* FeedTrackerItemProviding.swift in Sources */, 030D4AE62AA1273200A3393D /* ErrorDetails.swift in Sources */, @@ -2798,7 +2891,9 @@ CD14461B2A5A4B6D00610EF1 /* PostSettingsView.swift in Sources */, CD7B53B52A5F251400006E81 /* CreatePrivateMessageReportRequest.swift in Sources */, 50CC4A7F2AA0D3AA0074C845 /* InstanceMetadataParser.swift in Sources */, + CD4368B82AE23F5400BD8BD1 /* ParentTrackerProtocol.swift in Sources */, 637218492A3A2AAD008C4816 /* APICommentReplyView.swift in Sources */, + CD4368C82AE2426700BD8BD1 /* ReplyModel.swift in Sources */, 6317ABCB2A37292700603D76 /* FeedType.swift in Sources */, CDC65D8F2A86B6DD007205E5 /* DeleteUser.swift in Sources */, CD6483382A3A0F2200EE6CA3 /* NSFW Tag.swift in Sources */, @@ -2809,6 +2904,7 @@ CDA217E42A62FB3300BDA173 /* ReplyToMessage.swift in Sources */, 038A16DF2A75172C0087987E /* LayoutWidgetEditView.swift in Sources */, CD69F5732A4239D70028D4F7 /* Comment Item.swift in Sources */, + CDB45C622AF1AF9B00A1FF08 /* ReplyModel+TrackerItem.swift in Sources */, 6318EDC327EE4D7F00BFCAE8 /* Feed Post.swift in Sources */, CD05E7792A4E381A0081D102 /* PostSize.swift in Sources */, 6D7782362A48EED8008AC1BF /* APIPrivateMessage.swift in Sources */, @@ -2818,7 +2914,6 @@ 5064D0452A71549C00B22EE3 /* NotificationMessage.swift in Sources */, 63344C4D2A07ABEE001BC616 /* Community.swift in Sources */, E4F0B56F2ABD00A000BC3E4A /* PresentationBackgroundInteraction.swift in Sources */, - CDF842612A49EA3900723DA0 /* Mentions Tracker.swift in Sources */, 6D693A4C2A51B99E009E2D76 /* APICommentReport.swift in Sources */, 030E863B2AC6C3B1000283A6 /* PictrsRespository.swift in Sources */, 63344C672A08D4E3001BC616 /* AppearanceSettingsView.swift in Sources */, @@ -2830,12 +2925,14 @@ 637218482A3A2AAD008C4816 /* APICommentReply.swift in Sources */, 032109472AA7C3FC00912DFC /* CommunityLabelView.swift in Sources */, 637218502A3A2AAD008C4816 /* APIPersonAggregates.swift in Sources */, + CD4368D72AE2464D00BD8BD1 /* ReplyModel+InboxItem.swift in Sources */, 6D693A422A5114DF009E2D76 /* APIPostReport.swift in Sources */, 5064D03F2A6DE0DB00B22EE3 /* Notifier+Dependency.swift in Sources */, 6D8003792A45FD1300363206 /* Bundle.swift in Sources */, + CDB45C642AF1AFB900A1FF08 /* MessageModel+TrackerItem.swift in Sources */, 63344C712A098060001BC616 /* Sidebar View.swift in Sources */, 6DE118392A4A20D600810C7E /* Lazy Load Post Link.swift in Sources */, - CDF8426B2A4A2AB600723DA0 /* Inbox Item.swift in Sources */, + CDF8426B2A4A2AB600723DA0 /* InboxItem.swift in Sources */, 637218572A3A2AAD008C4816 /* APISiteView.swift in Sources */, B14E93C02A45CA3400D6DA93 /* Post Link.swift in Sources */, CD2BD6782A79F55800ECFF89 /* ImageSize.swift in Sources */, @@ -2849,6 +2946,7 @@ 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 */, 63F0C7BB2A058CB700A18C5D /* URLSessionWebSocketTask - Send Ping.swift in Sources */, CD8461662A96F9EB0026A627 /* Website Indicator View.swift in Sources */, @@ -2866,6 +2964,7 @@ CD82A24C2A70A26900111034 /* View - CustomBadge.swift in Sources */, B1CB6E752A4C729D00DA9675 /* Bundle - Current App Icon.swift in Sources */, 030D4AE82AA1278400A3393D /* ErrorDetails+Mock.swift in Sources */, + CD4368C62AE240BF00BD8BD1 /* MessageModel.swift in Sources */, 6363D60427EE20A200E34822 /* Expanded Post.swift in Sources */, 6DE1183C2A4A217400810C7E /* Profile View.swift in Sources */, CD04D5DF2A361585008EF95B /* Empty Button Style.swift in Sources */, @@ -2890,6 +2989,7 @@ 637218452A3A2AAD008C4816 /* APICommentAggregates.swift in Sources */, 6D80037B2A46458800363206 /* Lazy Load Expanded Post.swift in Sources */, 6372184F2A3A2AAD008C4816 /* APIPerson.swift in Sources */, + CD4368BC2AE23F6F00BD8BD1 /* TrackerSort.swift in Sources */, CD45BCEE2A75CA7200A2899C /* Thumbnail Image View.swift in Sources */, 03A1B3F92A8400DD00AB0DE0 /* APIContentViewProtocol.swift in Sources */, 6322A5D027F8629700135D4F /* UserLinkView.swift in Sources */, @@ -2900,6 +3000,7 @@ 0394398F2A98EB2300463032 /* APIComment+Mock.swift in Sources */, 63CE4E732A06F5A100405271 /* Access Token.swift in Sources */, E453477E2A9DE37300D1B46F /* Array+SafeIndexing.swift in Sources */, + CD4368BE2AE23FA600BD8BD1 /* LoadingState.swift in Sources */, CD9DD8852A62302A0044EA8E /* ConcreteEditorModel.swift in Sources */, CD4E98A32A69BEDC0026C4D9 /* IconSettingsView.swift in Sources */, 637218692A3A2AAD008C4816 /* CreateCommentLike.swift in Sources */, @@ -2908,7 +3009,7 @@ 6372185A2A3A2AAD008C4816 /* APISubscribedStatus.swift in Sources */, CDDCF6452A66375E003DA3AC /* FancyTabItemViewModifier.swift in Sources */, 6DA61F872A5720EA001EA633 /* RecentSearchesTracker.swift in Sources */, - CD3FBCD32A4A4B8B00B2063F /* Messages Tracker.swift in Sources */, + CD4368DD2AE24E1A00BD8BD1 /* InboxView+Logic.swift in Sources */, 637218762A3A2AAD008C4816 /* BlockCommunity.swift in Sources */, 03CB329E2A6D8E910021EF27 /* PostDetailEditorView.swift in Sources */, CD69F5752A42479A0028D4F7 /* Comment Item Logic.swift in Sources */, @@ -2937,6 +3038,7 @@ 63DF71F12A02999C002AC14E /* App Constants.swift in Sources */, CD82A2532A716B8100111034 /* PersonRepository.swift in Sources */, CD69F55F2A40121D0028D4F7 /* Ellipsis Menu.swift in Sources */, + CD4368D52AE2463900BD8BD1 /* MessageModel+InboxItem.swift in Sources */, 638535712A1779BC00815781 /* GeneralSettingsView.swift in Sources */, CD6483362A39F20800EE6CA3 /* Post Type.swift in Sources */, 507573912A5AD53C00AA7ABD /* Error+Equatable.swift in Sources */, @@ -2987,11 +3089,15 @@ 03B643572A6864CD00F65700 /* TabBarSettingsView.swift in Sources */, CDF842642A49EAFA00723DA0 /* GetPersonMentions.swift in Sources */, 6D405B052A43E82300C65F9C /* Sidebar Header.swift in Sources */, + CD4368B42AE23F3500BD8BD1 /* ChildTrackerProtocol.swift in Sources */, + CD4368D92AE2478300BD8BD1 /* MentionModel+InboxItem.swift in Sources */, 50811B302A92049B006BA3F2 /* APICommunityView+Mock.swift in Sources */, CDA217F32A63202600BDA173 /* NSFW Overlay.swift in Sources */, + CDB45C602AF1AF4900A1FF08 /* MentionModel+TrackerItem.swift in Sources */, 6372184E2A3A2AAD008C4816 /* APIPersonView.swift in Sources */, 6363D5FA27EE1BDA00E34822 /* Settings View.swift in Sources */, CDDCF6532A677F45003DA3AC /* TabSelection.swift in Sources */, + CD4368C42AE240B100BD8BD1 /* MentionModel.swift in Sources */, 6372184D2A3A2AAD008C4816 /* APIErrorResponse.swift in Sources */, CD7B53B92A5F263D00006E81 /* APIPrivateMessageReport.swift in Sources */, 637218602A3A2AAD008C4816 /* EditPost.swift in Sources */, @@ -3038,7 +3144,7 @@ B1A5A8152A4C882F00F203DB /* AlternativeIcon.swift in Sources */, 637218512A3A2AAD008C4816 /* APILocalSiteRateLimit.swift in Sources */, 50BC1ABB2A8D6A5A00E3C48B /* ScoringOperation.swift in Sources */, - CD18DC692A51ECB6002C56BC /* InteractionSwipeAndMenuHelpers.swift in Sources */, + CD4368BA2AE23F6400BD8BD1 /* TrackerItem.swift in Sources */, 6386E0362A042C59006B3C1D /* Contributor.swift in Sources */, E40E018C2AABF85500410B2C /* AppRoutes.swift in Sources */, CD18DC6F2A5209C3002C56BC /* MarkPrivateMessageAsReadRequest.swift in Sources */, @@ -3052,17 +3158,19 @@ 50CC4A722A9CB07F0074C845 /* TimeInterval+Period.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 */, CDE6A8182A490AF20062D161 /* Inbox Mention View.swift in Sources */, CD3FBCDD2A4A6F0600B2063F /* GetReplies.swift in Sources */, + CD4368DB2AE247B700BD8BD1 /* MentionTracker.swift in Sources */, 6332FDCF27EFDD2E0009A98A /* Accounts Page.swift in Sources */, E49E01F42ABD99D300E42BB3 /* Routable.swift in Sources */, - CDF842682A49FB9000723DA0 /* Inbox View Logic.swift in Sources */, 6D15D74C2A44DC240061B5CB /* Date.swift in Sources */, CDF1EF122A6B672C003594B6 /* Feed View.swift in Sources */, CDA217E62A63016A00BDA173 /* ReportMessage.swift in Sources */, CD9DD8832A622A6C0044EA8E /* ReportCommentReply.swift in Sources */, - CD3FBCE12A4A836000B2063F /* All Items Feed View.swift in Sources */, + CD3FBCE12A4A836000B2063F /* AllItemsFeedView.swift in Sources */, 63344C5D2A08070B001BC616 /* Critical Errors.swift in Sources */, 6D91D4582A4159D8006B8F9A /* CommunityListRowViews.swift in Sources */, 63F0C7B92A0533C700A18C5D /* Add Account View.swift in Sources */, @@ -3469,6 +3577,14 @@ minimumVersion = 12.1.2; }; }; + CD4368BF2AE23FD400BD8BD1 /* XCRemoteSwiftPackageReference "Semaphore" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/Semaphore"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.0.8; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3507,6 +3623,11 @@ package = B104A6D62A59BF3C00B3E725 /* XCRemoteSwiftPackageReference "Nuke" */; productName = NukeVideo; }; + CD4368C02AE23FD400BD8BD1 /* Semaphore */ = { + isa = XCSwiftPackageProductDependency; + package = CD4368BF2AE23FD400BD8BD1 /* XCRemoteSwiftPackageReference "Semaphore" */; + productName = Semaphore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6363D5B927EE196700E34822 /* Project object */; diff --git a/Mlem.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mlem.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 539397da1..d9b5f5c17 100644 --- a/Mlem.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mlem.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,15 @@ "version" : "12.1.4" } }, + { + "identity" : "semaphore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/Semaphore", + "state" : { + "revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28", + "version" : "0.0.8" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", diff --git a/Mlem/API/APIClient/APIClient.swift b/Mlem/API/APIClient/APIClient.swift index 4201934de..6ca59cc29 100644 --- a/Mlem/API/APIClient/APIClient.swift +++ b/Mlem/API/APIClient/APIClient.swift @@ -351,12 +351,19 @@ extension APIClient { return try await perform(request: request).privateMessageReportView } + @available(*, deprecated, message: "Use id-based sendPrivateMessage instead") @discardableResult func sendPrivateMessage(content: String, recipient: APIPerson) async throws -> PrivateMessageResponse { let request = try CreatePrivateMessageRequest(session: session, content: content, recipient: recipient) return try await perform(request: request) } + @discardableResult + func sendPrivateMessage(content: String, recipientId: Int) async throws -> PrivateMessageResponse { + let request = try CreatePrivateMessageRequest(session: session, content: content, recipientId: recipientId) + return try await perform(request: request) + } + func markPrivateMessageRead(id: Int, isRead: Bool) async throws -> APIPrivateMessageView { let request = try MarkPrivateMessageAsRead(session: session, privateMessageId: id, read: isRead) return try await perform(request: request).privateMessageView diff --git a/Mlem/API/Models/Comments/APICommentReply.swift b/Mlem/API/Models/Comments/APICommentReply.swift index 032366d7b..2e1a6d3fc 100644 --- a/Mlem/API/Models/Comments/APICommentReply.swift +++ b/Mlem/API/Models/Comments/APICommentReply.swift @@ -14,4 +14,19 @@ struct APICommentReply: Decodable { let commentId: Int let read: Bool let published: Date + + init( + from commentReply: APICommentReply, + id: Int? = nil, + recipientId: Int? = nil, + commentId: Int? = nil, + read: Bool? = nil, + published: Date? = nil + ) { + self.id = id ?? commentReply.id + self.recipientId = recipientId ?? commentReply.recipientId + self.commentId = commentId ?? commentReply.commentId + self.read = read ?? commentReply.read + self.published = published ?? commentReply.published + } } diff --git a/Mlem/API/Models/Messages/APIPrivateMessage.swift b/Mlem/API/Models/Messages/APIPrivateMessage.swift index 5031fcfb7..4656b727a 100644 --- a/Mlem/API/Models/Messages/APIPrivateMessage.swift +++ b/Mlem/API/Models/Messages/APIPrivateMessage.swift @@ -18,4 +18,19 @@ struct APIPrivateMessage: Decodable { let updated: Date? let published: Date let deleted: Bool + + init( + from apiPrivateMessage: APIPrivateMessage, + read: Bool? = nil + ) { + self.id = apiPrivateMessage.id + self.content = apiPrivateMessage.content + self.creatorId = apiPrivateMessage.creatorId + self.recipientId = apiPrivateMessage.recipientId + self.local = apiPrivateMessage.local + self.read = read ?? apiPrivateMessage.read + self.updated = apiPrivateMessage.updated + self.published = apiPrivateMessage.published + self.deleted = apiPrivateMessage.deleted + } } diff --git a/Mlem/API/Models/Person/APIPersonMention.swift b/Mlem/API/Models/Person/APIPersonMention.swift index 3f2404d25..e652a3c9b 100644 --- a/Mlem/API/Models/Person/APIPersonMention.swift +++ b/Mlem/API/Models/Person/APIPersonMention.swift @@ -14,10 +14,33 @@ struct APIPersonMention: Decodable { let commentId: Int let read: Bool let published: Date + + init( + from personMention: APIPersonMention, + id: Int? = nil, + recipientId: Int? = nil, + commentId: Int? = nil, + read: Bool? = nil, + published: Date? = nil + ) { + self.id = id ?? personMention.id + self.recipientId = recipientId ?? personMention.recipientId + self.commentId = commentId ?? personMention.commentId + self.read = read ?? personMention.read + self.published = published ?? personMention.published + } +} + +extension APIPersonMention: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(read) + hasher.combine(published) + } } extension APIPersonMention: Equatable { static func == (lhs: APIPersonMention, rhs: APIPersonMention) -> Bool { - lhs.id == rhs.id + lhs.hashValue == rhs.hashValue } } diff --git a/Mlem/API/Models/ScoringOperation.swift b/Mlem/API/Models/ScoringOperation.swift index c14f652f9..3d06cd1ad 100644 --- a/Mlem/API/Models/ScoringOperation.swift +++ b/Mlem/API/Models/ScoringOperation.swift @@ -7,6 +7,7 @@ // import Foundation +import SwiftUI enum ScoringOperation: Int, Decodable { case upvote = 1 @@ -14,6 +15,16 @@ enum ScoringOperation: Int, Decodable { case resetVote = 0 } +extension ScoringOperation: AssociatedColor { + var color: Color? { + switch self { + case .upvote: return .upvoteColor + case .downvote: return .downvoteColor + case .resetVote: return nil + } + } +} + extension ScoringOperation: AssociatedIcon { var iconName: String { switch self { diff --git a/Mlem/API/Requests/Messages/CreatePrivateMessageRequest.swift b/Mlem/API/Requests/Messages/CreatePrivateMessageRequest.swift index 31395746c..d2c5e1555 100644 --- a/Mlem/API/Requests/Messages/CreatePrivateMessageRequest.swift +++ b/Mlem/API/Requests/Messages/CreatePrivateMessageRequest.swift @@ -21,6 +21,16 @@ struct CreatePrivateMessageRequest: APIPostRequest { let recipient_id: Int } + init( + session: APISession, + content: String, + recipientId: Int + ) throws { + self.instanceURL = try session.instanceUrl + self.body = try .init(auth: session.token, content: content, recipient_id: recipientId) + } + + @available(*, deprecated, message: "Use id-based initializer instead") init( session: APISession, content: String, diff --git a/Mlem/App Constants.swift b/Mlem/App Constants.swift index 60d5361ab..5b9d506ee 100644 --- a/Mlem/App Constants.swift +++ b/Mlem/App Constants.swift @@ -64,4 +64,6 @@ struct AppConstants { static let blockUserPrompt: String = "Really block this user?" static let reportPostPrompt: String = "Really report this post?" + static let reportCommentPrompt: String = "Really report this comment?" + static let reportMessagePrompt: String = "Really report this message?" } diff --git a/Mlem/Dependency/InboxRepository+Dependency.swift b/Mlem/Dependency/InboxRepository+Dependency.swift new file mode 100644 index 000000000..6a847f648 --- /dev/null +++ b/Mlem/Dependency/InboxRepository+Dependency.swift @@ -0,0 +1,19 @@ +// +// InboxRepository+Dependency.swift +// Mlem +// +// Created by Eric Andrews on 2023-09-23. +// +import Dependencies +import Foundation + +extension InboxRepository: DependencyKey { + static let liveValue = InboxRepository() +} + +extension DependencyValues { + var inboxRepository: InboxRepository { + get { self[InboxRepository.self] } + set { self[InboxRepository.self] = newValue } + } +} diff --git a/Mlem/Enums/Content Type.swift b/Mlem/Enums/Content Type.swift index d87a43c16..c483aa9c9 100644 --- a/Mlem/Enums/Content Type.swift +++ b/Mlem/Enums/Content Type.swift @@ -8,5 +8,5 @@ import Foundation enum ContentType: Int, Codable { - case post, comment, community, user + case post, comment, community, user, message, mention, reply } diff --git a/Mlem/Enums/ContentIdentifiable.swift b/Mlem/Enums/ContentIdentifiable.swift new file mode 100644 index 000000000..a1f5ec33c --- /dev/null +++ b/Mlem/Enums/ContentIdentifiable.swift @@ -0,0 +1,12 @@ +// +// ContentIdentifiable.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-14. +// +import Foundation + +// TODO: migrate this to be ContentModel and make subtypes of ContentModel for content with URLs, etc. +protocol ContentIdentifiable { + var uid: ContentModelIdentifier { get } +} diff --git a/Mlem/Enums/Inbox Item Type.swift b/Mlem/Enums/Inbox Item Type.swift deleted file mode 100644 index 220f5d1bc..000000000 --- a/Mlem/Enums/Inbox Item Type.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Inbox Item Types.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Foundation - -enum InboxItemType { - case mention(APIPersonMentionView) - case message(APIPrivateMessageView) - case reply(APICommentReplyView) - - var hasherId: Int { - switch self { - case .mention: - return 0 - case .message: - return 1 - case .reply: - return 2 - } - } -} diff --git a/Mlem/Enums/LoadingState.swift b/Mlem/Enums/LoadingState.swift new file mode 100644 index 000000000..2751ef02d --- /dev/null +++ b/Mlem/Enums/LoadingState.swift @@ -0,0 +1,15 @@ +// +// LoadingState.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-12. +// +import Foundation + +/// Enum of possible loading states that a tracker can be in. +/// - idle: not currently loading, but more items available to load +/// - loading: currently loading more items +/// - done: no more items available to load +enum LoadingState { + case idle, loading, done +} diff --git a/Mlem/Extensions/AssociatedColor.swift b/Mlem/Extensions/AssociatedColor.swift new file mode 100644 index 000000000..e3a80c834 --- /dev/null +++ b/Mlem/Extensions/AssociatedColor.swift @@ -0,0 +1,13 @@ +// +// AssociatedColor.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-31. +// + +import Foundation +import SwiftUI + +protocol AssociatedColor { + var color: Color? { get } +} diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/MentionModel+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/MentionModel+InboxItem.swift new file mode 100644 index 000000000..5663572be --- /dev/null +++ b/Mlem/Extensions/Tracker Items/Inbox Items/MentionModel+InboxItem.swift @@ -0,0 +1,18 @@ +// +// MentionModel+InboxItem.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-20. +// + +import Foundation + +extension MentionModel: InboxItem { + typealias ParentType = AnyInboxItem + + var published: Date { personMention.published } + + var creatorId: Int { comment.creatorId } + + var read: Bool { personMention.read } +} diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/MessageModel+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/MessageModel+InboxItem.swift new file mode 100644 index 000000000..d1f4aabb8 --- /dev/null +++ b/Mlem/Extensions/Tracker Items/Inbox Items/MessageModel+InboxItem.swift @@ -0,0 +1,17 @@ +// +// MessageModel+InboxItem.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-16. +// +import Foundation + +extension MessageModel: InboxItem { + typealias ParentType = AnyInboxItem + + var published: Date { privateMessage.published } + + var creatorId: Int { privateMessage.creatorId } + + var read: Bool { privateMessage.read } +} diff --git a/Mlem/Extensions/Tracker Items/Inbox Items/ReplyModel+InboxItem.swift b/Mlem/Extensions/Tracker Items/Inbox Items/ReplyModel+InboxItem.swift new file mode 100644 index 000000000..d1aaaba55 --- /dev/null +++ b/Mlem/Extensions/Tracker Items/Inbox Items/ReplyModel+InboxItem.swift @@ -0,0 +1,17 @@ +// +// ReplyModel+InboxItem.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-16. +// +import Foundation + +extension ReplyModel: InboxItem { + typealias ParentType = AnyInboxItem + + var published: Date { commentReply.published } + + var creatorId: Int { comment.creatorId } + + var read: Bool { commentReply.read } +} diff --git a/Mlem/Extensions/Tracker Items/MentionModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/MentionModel+TrackerItem.swift new file mode 100644 index 000000000..b76fbb167 --- /dev/null +++ b/Mlem/Extensions/Tracker Items/MentionModel+TrackerItem.swift @@ -0,0 +1,17 @@ +// +// MentionModel+TrackerItem.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-31. +// + +import Foundation + +extension MentionModel: TrackerItem { + func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + switch sortType { + case .published: + return .published(personMention.published) + } + } +} diff --git a/Mlem/Extensions/Tracker Items/MessageModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/MessageModel+TrackerItem.swift new file mode 100644 index 000000000..773fad89f --- /dev/null +++ b/Mlem/Extensions/Tracker Items/MessageModel+TrackerItem.swift @@ -0,0 +1,17 @@ +// +// MessageModel+TrackerItem.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-31. +// + +import Foundation + +extension MessageModel: TrackerItem { + func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + switch sortType { + case .published: + return .published(privateMessage.published) + } + } +} diff --git a/Mlem/Extensions/Tracker Items/ReplyModel+TrackerItem.swift b/Mlem/Extensions/Tracker Items/ReplyModel+TrackerItem.swift new file mode 100644 index 000000000..bd4acb795 --- /dev/null +++ b/Mlem/Extensions/Tracker Items/ReplyModel+TrackerItem.swift @@ -0,0 +1,17 @@ +// +// ReplyModel+TrackerItem.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-31. +// + +import Foundation + +extension ReplyModel: TrackerItem { + func sortVal(sortType: TrackerSortType) -> TrackerSortVal { + switch sortType { + case .published: + return .published(commentReply.published) + } + } +} diff --git a/Mlem/Icons.swift b/Mlem/Icons.swift index 4e27f0f13..1bdfd6629 100644 --- a/Mlem/Icons.swift +++ b/Mlem/Icons.swift @@ -34,9 +34,9 @@ struct Icons { static let unsaveFill: String = "bookmark.slash.fill" // mark read - static let markRead: String = "envelope" + static let markRead: String = "envelope.open" static let markReadFill: String = "envelope.open.fill" - static let markUnread: String = "envelope.open" + static let markUnread: String = "envelope" static let markUnreadFill: String = "envelope.fill" // moderation @@ -124,7 +124,6 @@ struct Icons { static let hide: String = "eye.slash" static let show: String = "eye" static let blurNsfw: String = "eye.trianglebadge.exclamationmark" - static let endOfFeed: String = "figure.climbing" static let noContent: String = "binoculars" static let noPosts: String = "text.bubble" static let time: String = "clock" @@ -134,6 +133,10 @@ struct Icons { static let personFill: String = "person.fill" static let close: String = "multiply" + // end of feed + static let endOfFeedHobbit: String = "figure.climbing" + static let endOfFeedCartoon: String = "figure.wave" + // common operations static let share: String = "square.and.arrow.up" static let subscribe: String = "plus.circle" diff --git a/Mlem/Models/Composers/Response Composers/ConcreteEditorModel.swift b/Mlem/Models/Composers/Response Composers/ConcreteEditorModel.swift index bca8860f3..95d934992 100644 --- a/Mlem/Models/Composers/Response Composers/ConcreteEditorModel.swift +++ b/Mlem/Models/Composers/Response Composers/ConcreteEditorModel.swift @@ -75,7 +75,7 @@ extension ConcreteEditorModel { } /// Create a ConcreteEditorModel to reply to or report a comment reply - init(commentReply: APICommentReplyView, operation: InboxItemOperation) { + init(commentReply: ReplyModel, operation: InboxItemOperation) { switch operation { case .replyToInboxItem: self.editorModel = ReplyToCommentReply(commentReply: commentReply) case .reportInboxItem: self.editorModel = ReportCommentReply(commentReply: commentReply) @@ -83,7 +83,7 @@ extension ConcreteEditorModel { } /// Create a ConcreteEditorModel to reply to or report a mention - init(mention: APIPersonMentionView, operation: InboxItemOperation) { + init(mention: MentionModel, operation: InboxItemOperation) { switch operation { case .replyToInboxItem: self.editorModel = ReplyToMention(mention: mention) case .reportInboxItem: self.editorModel = ReportMention(mention: mention) @@ -91,7 +91,7 @@ extension ConcreteEditorModel { } /// Create a ConcreteEditorModel to reply to or report a message - init(message: APIPrivateMessageView, operation: InboxItemOperation) { + init(message: MessageModel, operation: InboxItemOperation) { switch operation { case .replyToInboxItem: self.editorModel = ReplyToMessage(message: message) case .reportInboxItem: self.editorModel = ReportMessage(message: message) diff --git a/Mlem/Models/Composers/Response Composers/Replies/ReplyToCommentReply.swift b/Mlem/Models/Composers/Response Composers/Replies/ReplyToCommentReply.swift index 61b6f4685..2e0675d23 100644 --- a/Mlem/Models/Composers/Response Composers/Replies/ReplyToCommentReply.swift +++ b/Mlem/Models/Composers/Response Composers/Replies/ReplyToCommentReply.swift @@ -15,16 +15,13 @@ struct ReplyToCommentReply: ResponseEditorModel { let canUpload: Bool = true let modalName: String = "New Comment" let prefillContents: String? = nil - let commentReply: APICommentReplyView + let commentReply: ReplyModel var id: Int { commentReply.id } func embeddedView() -> AnyView { - AnyView(InboxReplyView( - reply: commentReply, - menuFunctions: [] - ) - .padding(.horizontal)) + AnyView(InboxReplyView(reply: commentReply) + .padding(.horizontal)) } 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 8d7cbbb89..fee25d704 100644 --- a/Mlem/Models/Composers/Response Composers/Replies/ReplyToMention.swift +++ b/Mlem/Models/Composers/Response Composers/Replies/ReplyToMention.swift @@ -15,16 +15,15 @@ struct ReplyToMention: ResponseEditorModel { let canUpload: Bool = true let modalName: String = "New Comment" let prefillContents: String? = nil - let mention: APIPersonMentionView + let mention: MentionModel var id: Int { mention.id } func embeddedView() -> AnyView { - AnyView(InboxMentionView( - mention: mention, - menuFunctions: [] + AnyView( + InboxMentionView(mention: mention) + .padding(.horizontal) ) - .padding(.horizontal)) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Composers/Response Composers/Replies/ReplyToMessage.swift b/Mlem/Models/Composers/Response Composers/Replies/ReplyToMessage.swift index 42aad5aa9..866f7e2ce 100644 --- a/Mlem/Models/Composers/Response Composers/Replies/ReplyToMessage.swift +++ b/Mlem/Models/Composers/Response Composers/Replies/ReplyToMessage.swift @@ -10,23 +10,23 @@ import Foundation import SwiftUI struct ReplyToMessage: ResponseEditorModel { - @Dependency(\.apiClient) var apiClient + @Dependency(\.inboxRepository) var inboxRepository @Dependency(\.hapticManager) var hapticManager var id: Int { message.id } let canUpload: Bool = true let modalName: String = "New Message" let prefillContents: String? = nil - let message: APIPrivateMessageView + let message: MessageModel func embeddedView() -> AnyView { - AnyView(InboxMessageView(message: message, menuFunctions: []) + AnyView(InboxMessageView(message: message) .padding(.horizontal, AppConstants.postAndCommentSpacing)) } func sendResponse(responseContents: String) async throws { do { - try await apiClient.sendPrivateMessage(content: responseContents, recipient: message.creator) + _ = try await inboxRepository.sendMessage(content: responseContents, recipientId: message.creator.id) hapticManager.play(haptic: .success, priority: .high) } catch { hapticManager.play(haptic: .failure, priority: .high) diff --git a/Mlem/Models/Composers/Response Composers/Reports/ReportCommentReply.swift b/Mlem/Models/Composers/Response Composers/Reports/ReportCommentReply.swift index 06a4aeb73..96ef410ae 100644 --- a/Mlem/Models/Composers/Response Composers/Reports/ReportCommentReply.swift +++ b/Mlem/Models/Composers/Response Composers/Reports/ReportCommentReply.swift @@ -16,14 +16,11 @@ struct ReportCommentReply: ResponseEditorModel { let canUpload: Bool = false let modalName: String = "Report Comment" let prefillContents: String? = nil - let commentReply: APICommentReplyView + let commentReply: ReplyModel func embeddedView() -> AnyView { - AnyView(InboxReplyView( - reply: commentReply, - menuFunctions: [] - ) - .padding(.horizontal)) + AnyView(InboxReplyView(reply: commentReply) + .padding(.horizontal)) } 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 6f9bded15..acf107089 100644 --- a/Mlem/Models/Composers/Response Composers/Reports/ReportMention.swift +++ b/Mlem/Models/Composers/Response Composers/Reports/ReportMention.swift @@ -16,11 +16,13 @@ struct ReportMention: ResponseEditorModel { let canUpload: Bool = false let modalName: String = "Report Comment" let prefillContents: String? = nil - let mention: APIPersonMentionView + let mention: MentionModel func embeddedView() -> AnyView { - AnyView(InboxMentionView(mention: mention, menuFunctions: []) - .padding(.horizontal)) + AnyView( + InboxMentionView(mention: mention) + .padding(.horizontal) + ) } func sendResponse(responseContents: String) async throws { diff --git a/Mlem/Models/Composers/Response Composers/Reports/ReportMessage.swift b/Mlem/Models/Composers/Response Composers/Reports/ReportMessage.swift index 92245bc6a..05e858f2c 100644 --- a/Mlem/Models/Composers/Response Composers/Reports/ReportMessage.swift +++ b/Mlem/Models/Composers/Response Composers/Reports/ReportMessage.swift @@ -10,23 +10,23 @@ import Foundation import SwiftUI struct ReportMessage: ResponseEditorModel { - @Dependency(\.apiClient) var apiClient + @Dependency(\.inboxRepository) var inboxRepository @Dependency(\.hapticManager) var hapticManager var id: Int { message.id } let canUpload: Bool = false let modalName: String = "Report Message" let prefillContents: String? = nil - let message: APIPrivateMessageView + let message: MessageModel func embeddedView() -> AnyView { - AnyView(InboxMessageView(message: message, menuFunctions: []) + AnyView(InboxMessageView(message: message) .padding(.horizontal)) } func sendResponse(responseContents: String) async throws { do { - try await apiClient.reportPrivateMessage(id: message.id, reason: responseContents) + _ = try await inboxRepository.reportMessage(id: message.id, reason: responseContents) hapticManager.play(haptic: .violentSuccess, priority: .high) } catch { hapticManager.play(haptic: .failure, priority: .high) diff --git a/Mlem/Models/Content/Inbox/MentionModel.swift b/Mlem/Models/Content/Inbox/MentionModel.swift new file mode 100644 index 000000000..4ed01ac58 --- /dev/null +++ b/Mlem/Models/Content/Inbox/MentionModel.swift @@ -0,0 +1,365 @@ +// +// MentionModel.swift +// Mlem +// +// Created by Eric Andrews on 2023-09-23. +// + +import Dependencies +import Foundation + +/// Internal representation of a person mention +class MentionModel: ContentIdentifiable, ObservableObject { + @Dependency(\.inboxRepository) var inboxRepository + @Dependency(\.apiClient) var apiClient + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier + @Dependency(\.hapticManager) var hapticManager + + @Published var personMention: APIPersonMention + @Published var comment: APIComment + var creator: APIPerson + var post: APIPost + var community: APICommunity + var recipient: APIPerson + @Published var numReplies: Int + @Published var votes: VotesModel + @Published var creatorBannedFromCommunity: Bool + @Published var subscribed: APISubscribedStatus + @Published var saved: Bool + @Published var creatorBlocked: Bool + + // prevents a voting operation from ocurring while another is ocurring + var voting: Bool = false + + var uid: ContentModelIdentifier { .init(contentType: .mention, contentId: personMention.id) } + + init(from personMentionView: APIPersonMentionView) { + self.personMention = personMentionView.personMention + self.comment = personMentionView.comment + self.creator = personMentionView.creator + self.post = personMentionView.post + self.community = personMentionView.community + self.recipient = personMentionView.recipient + self.numReplies = personMentionView.counts.childCount + self.votes = VotesModel(from: personMentionView.counts, myVote: personMentionView.myVote) + self.creatorBannedFromCommunity = personMentionView.creatorBannedFromCommunity + self.subscribed = personMentionView.subscribed + self.saved = personMentionView.saved + self.creatorBlocked = personMentionView.creatorBlocked + } + + init( + from mentionModel: MentionModel, + personMention: APIPersonMention? = nil, + comment: APIComment? = nil, + creator: APIPerson? = nil, + post: APIPost? = nil, + community: APICommunity? = nil, + recipient: APIPerson? = nil, + numReplies: Int? = nil, + votes: VotesModel? = nil, + creatorBannedFromCommunity: Bool? = nil, + subscribed: APISubscribedStatus? = nil, + saved: Bool? = nil, + creatorBlocked: Bool? = nil + ) { + self.personMention = personMention ?? mentionModel.personMention + self.comment = comment ?? mentionModel.comment + self.creator = creator ?? mentionModel.creator + self.post = post ?? mentionModel.post + self.community = community ?? mentionModel.community + self.recipient = recipient ?? mentionModel.recipient + self.numReplies = numReplies ?? mentionModel.numReplies + self.votes = votes ?? mentionModel.votes + self.creatorBannedFromCommunity = creatorBannedFromCommunity ?? mentionModel.creatorBannedFromCommunity + self.subscribed = subscribed ?? mentionModel.subscribed + self.saved = saved ?? mentionModel.saved + self.creatorBlocked = creatorBlocked ?? mentionModel.creatorBlocked + } +} + +extension MentionModel { + @MainActor + func setPersonMention(_ personMention: APIPersonMention) { + self.personMention = personMention + } + + @MainActor + func setVotes(_ newVotes: VotesModel) { + votes = newVotes + } + + /// Re-initializes all fields to match the given MentionModel + @MainActor + func reinit(from mentionModel: MentionModel) { + personMention = mentionModel.personMention + comment = mentionModel.comment + creator = mentionModel.creator + post = mentionModel.post + community = mentionModel.community + recipient = mentionModel.recipient + votes = mentionModel.votes + creatorBannedFromCommunity = mentionModel.creatorBannedFromCommunity + subscribed = mentionModel.subscribed + saved = mentionModel.saved + creatorBlocked = mentionModel.creatorBlocked + } + + func vote(inputOp: ScoringOperation, unreadTracker: UnreadTracker) async { + guard !voting else { + return + } + + voting = true + defer { voting = false } + + hapticManager.play(haptic: .gentleSuccess, priority: .low) + let operation = votes.myVote == inputOp ? ScoringOperation.resetVote : inputOp + + let original: MentionModel = .init(from: self) + + // state fake + await setVotes(votes.applyScoringOperation(operation: operation)) + await setPersonMention(APIPersonMention(from: personMention, read: true)) + + do { + let updatedMention = try await inboxRepository.voteOnMention(self, vote: operation) + await reinit(from: updatedMention) + if !original.personMention.read { + _ = try await inboxRepository.markMentionRead(id: personMention.id, isRead: true) + await unreadTracker.readMention() + } + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await reinit(from: original) + } + } + + func toggleRead(unreadTracker: UnreadTracker) async { + hapticManager.play(haptic: .gentleSuccess, priority: .low) + + // store original state + let originalPersonMention = APIPersonMention(from: personMention) + + // state fake + await setPersonMention(APIPersonMention(from: personMention, read: !personMention.read)) + await unreadTracker.toggleMentionRead(originalState: originalPersonMention.read) + + // 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 reinit(from: newMessage) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await setPersonMention(originalPersonMention) + await unreadTracker.toggleMentionRead(originalState: !originalPersonMention.read) + } + } + + @MainActor + func reply(editorTracker: EditorTracker, unreadTracker: UnreadTracker) { + editorTracker.openEditor(with: ConcreteEditorModel( + mention: self, + operation: InboxItemOperation.replyToInboxItem + )) + + // 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() + } + } + + @MainActor + func report(editorTracker: EditorTracker, unreadTracker: UnreadTracker) { + editorTracker.openEditor(with: ConcreteEditorModel( + mention: self, + operation: InboxItemOperation.reportInboxItem + )) + } + + func blockUser(userId: Int) async { + do { + let response = try await apiClient.blockPerson(id: userId, shouldBlock: true) + + if response.blocked { + hapticManager.play(haptic: .violentSuccess, priority: .high) + await notifier.add(.success("Blocked user")) + } + } catch { + errorHandler.handle( + .init( + message: "Unable to block user", + style: .toast, + underlyingError: error + ) + ) + } + } + + // MARK: - Menu functions and swipe actions + + // swiftlint:disable function_body_length + func menuFunctions( + unreadTracker: UnreadTracker, + editorTracker: EditorTracker + ) -> [MenuFunction] { + var ret: [MenuFunction] = .init() + + // upvote + 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) { + await self.vote(inputOp: .upvote, unreadTracker: unreadTracker) + } + }) + + // downvote + 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) { + await self.vote(inputOp: .downvote, unreadTracker: unreadTracker) + } + }) + + // toggle read + ret.append(MenuFunction.standardMenuFunction( + text: personMention.read ? "Mark unread" : "Mark read", + imageName: personMention.read ? Icons.markUnread : Icons.markRead, + destructiveActionPrompt: nil, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.toggleRead(unreadTracker: unreadTracker) + } + }) + + // reply + ret.append(MenuFunction.standardMenuFunction( + text: "Reply", + imageName: Icons.reply, + destructiveActionPrompt: nil, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.reply(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + }) + + // report + ret.append(MenuFunction.standardMenuFunction( + text: "Report", + imageName: Icons.moderationReport, + destructiveActionPrompt: AppConstants.reportCommentPrompt, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.report(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + }) + + // block + ret.append(MenuFunction.standardMenuFunction( + text: "Block", + imageName: Icons.userBlock, + destructiveActionPrompt: AppConstants.blockUserPrompt, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.blockUser(userId: self.creator.id) + } + }) + + return ret + } + + // swiftlint:enable function_body_length + + func swipeActions( + unreadTracker: UnreadTracker, + editorTracker: EditorTracker + ) -> SwipeConfiguration { + var leadingActions: [SwipeAction] = .init() + var trailingActions: [SwipeAction] = .init() + + leadingActions.append(SwipeAction( + symbol: .init( + emptyName: votes.myVote == .upvote ? Icons.resetVoteSquare : Icons.upvoteSquare, + fillName: votes.myVote == .upvote ? Icons.resetVoteSquareFill : Icons.upvoteSquareFill + ), + color: .upvoteColor + ) { + Task(priority: .userInitiated) { + await self.vote(inputOp: .upvote, unreadTracker: unreadTracker) + } + }) + + leadingActions.append(SwipeAction( + symbol: .init( + emptyName: votes.myVote == .downvote ? Icons.resetVoteSquare : Icons.downvoteSquare, + fillName: votes.myVote == .downvote ? Icons.resetVoteSquareFill : Icons.downvoteSquareFill + ), + color: .downvoteColor + ) { + Task(priority: .userInitiated) { + await self.vote(inputOp: .downvote, unreadTracker: unreadTracker) + } + }) + + trailingActions.append(SwipeAction( + symbol: .init( + emptyName: personMention.read ? Icons.markRead : Icons.markUnread, + fillName: personMention.read ? Icons.markUnreadFill : Icons.markReadFill + ), + color: .purple + ) { + Task(priority: .userInitiated) { + await self.toggleRead(unreadTracker: unreadTracker) + } + }) + + trailingActions.append(SwipeAction( + symbol: .init(emptyName: Icons.reply, fillName: Icons.replyFill), + color: .upvoteColor + ) { + Task(priority: .userInitiated) { + await self.reply(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + }) + + return SwipeConfiguration(leadingActions: leadingActions, trailingActions: trailingActions) + } +} + +extension MentionModel: Hashable { + /// Hashes all fields for which state changes should trigger view updates. + func hash(into hasher: inout Hasher) { + hasher.combine(uid) + hasher.combine(personMention.read) + hasher.combine(comment.updated) + hasher.combine(comment.deleted) + hasher.combine(votes) + hasher.combine(saved) + } +} + +extension MentionModel: Identifiable { + var id: Int { hashValue } +} + +extension MentionModel: Equatable { + static func == (lhs: MentionModel, rhs: MentionModel) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Mlem/Models/Content/Inbox/MessageModel.swift b/Mlem/Models/Content/Inbox/MessageModel.swift new file mode 100644 index 000000000..a778928d1 --- /dev/null +++ b/Mlem/Models/Content/Inbox/MessageModel.swift @@ -0,0 +1,222 @@ +// +// MessageModel.swift +// Mlem +// +// Created by Eric Andrews on 2023-09-23. +// + +import Dependencies +import Foundation + +/** + Internal model to represent a private message. + + Note: To make the transition to internal models smoother, this is currently identical to APIPrivateMessageView + */ +class MessageModel: ContentIdentifiable, ObservableObject { + @Dependency(\.inboxRepository) var inboxRepository + @Dependency(\.apiClient) var apiClient + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier + @Dependency(\.hapticManager) var hapticManager + + @Published var creator: APIPerson + @Published var recipient: APIPerson + @Published var privateMessage: APIPrivateMessage + + var uid: ContentModelIdentifier { .init(contentType: .message, contentId: privateMessage.id) } + + /// Creates a MessageModel from the raw APIPrivateMessageView returned by the Lemmy API + /// - Parameter from: APIPrivateMessageView returned by the Lemmy API + init(from apiPrivateMessageView: APIPrivateMessageView) { + self.creator = apiPrivateMessageView.creator + self.recipient = apiPrivateMessageView.recipient + self.privateMessage = apiPrivateMessageView.privateMessage + } + + // MARK: Main actor actions + + /// Re-initializes all fields to match the given MessageModel + @MainActor + func reinit(from messageModel: MessageModel) { + creator = messageModel.creator + recipient = messageModel.recipient + privateMessage = messageModel.privateMessage + } + + @MainActor + func setPrivateMessage(_ privateMessage: APIPrivateMessage) { + self.privateMessage = privateMessage + } + + func toggleRead(unreadTracker: UnreadTracker) async { + hapticManager.play(haptic: .gentleSuccess, priority: .low) + + // store original state + let originalPrivateMessage = privateMessage + + // state fake + await setPrivateMessage(APIPrivateMessage(from: privateMessage, read: !privateMessage.read)) + await unreadTracker.toggleMessageRead(originalState: originalPrivateMessage.read) + + // 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 reinit(from: newMessage) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await setPrivateMessage(originalPrivateMessage) + await unreadTracker.toggleMessageRead(originalState: !originalPrivateMessage.read) + } + } + + @MainActor + func reply(editorTracker: EditorTracker, unreadTracker: UnreadTracker) { + editorTracker.openEditor(with: ConcreteEditorModel( + message: self, + operation: InboxItemOperation.replyToInboxItem + )) + + // 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() + } + } + + @MainActor + func report(editorTracker: EditorTracker) { + editorTracker.openEditor(with: ConcreteEditorModel( + message: self, + operation: InboxItemOperation.reportInboxItem + )) + } + + func blockUser(userId: Int) async { + do { + let response = try await apiClient.blockPerson(id: userId, shouldBlock: true) + + if response.blocked { + hapticManager.play(haptic: .violentSuccess, priority: .high) + await notifier.add(.success("Blocked user")) + } + } catch { + errorHandler.handle( + .init( + message: "Unable to block user", + style: .toast, + underlyingError: error + ) + ) + } + } + + // MARK: - Menu functions and swipe actions + + func menuFunctions( + unreadTracker: UnreadTracker, + editorTracker: EditorTracker + ) -> [MenuFunction] { + 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) { + await self.toggleRead(unreadTracker: unreadTracker) + } + }) + + // reply + ret.append(MenuFunction.standardMenuFunction( + text: "Reply", + imageName: Icons.reply, + destructiveActionPrompt: nil, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.reply(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + }) + + // report + ret.append(MenuFunction.standardMenuFunction( + text: "Report", + imageName: Icons.moderationReport, + destructiveActionPrompt: AppConstants.reportMessagePrompt, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.report(editorTracker: editorTracker) + } + }) + + // block + ret.append(MenuFunction.standardMenuFunction( + text: "Block User", + imageName: Icons.userBlock, + destructiveActionPrompt: AppConstants.blockUserPrompt, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.blockUser(userId: self.creator.id) + } + }) + + return ret + } + + func swipeActions( + unreadTracker: UnreadTracker, + editorTracker: EditorTracker + ) -> SwipeConfiguration { + var trailingActions: [SwipeAction] = .init() + + trailingActions.append(SwipeAction( + symbol: .init( + emptyName: privateMessage.read ? Icons.markUnreadFill : Icons.markReadFill, + fillName: privateMessage.read ? Icons.markRead : Icons.markUnread + ), + color: .purple + ) { + Task(priority: .userInitiated) { + await self.toggleRead(unreadTracker: unreadTracker) + } + }) + + trailingActions.append(SwipeAction( + symbol: .init(emptyName: Icons.reply, fillName: Icons.replyFill), + color: .blue + ) { + Task(priority: .userInitiated) { + await self.reply(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + }) + + return SwipeConfiguration(leadingActions: .init(), trailingActions: trailingActions) + } +} + +extension MessageModel: Hashable { + /// Hashes all fields for which state changes should trigger view updates. + func hash(into hasher: inout Hasher) { + hasher.combine(uid) + hasher.combine(privateMessage.read) + hasher.combine(privateMessage.updated) + } +} + +extension MessageModel: Identifiable { + var id: Int { hashValue } +} + +extension MessageModel: Equatable { + static func == (lhs: MessageModel, rhs: MessageModel) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Mlem/Models/Content/Inbox/ReplyModel.swift b/Mlem/Models/Content/Inbox/ReplyModel.swift new file mode 100644 index 000000000..7e79f9862 --- /dev/null +++ b/Mlem/Models/Content/Inbox/ReplyModel.swift @@ -0,0 +1,359 @@ +// +// ReplyModel.swift +// Mlem +// +// Created by Eric Andrews on 2023-09-23. +// + +import Dependencies +import Foundation + +/// Internal representation of a comment reply +class ReplyModel: ObservableObject, ContentIdentifiable { + @Dependency(\.inboxRepository) var inboxRepository + @Dependency(\.apiClient) var apiClient + @Dependency(\.errorHandler) var errorHandler + @Dependency(\.notifier) var notifier + @Dependency(\.hapticManager) var hapticManager + + @Published var commentReply: APICommentReply + @Published var comment: APIComment + var creator: APIPerson + var post: APIPost + var community: APICommunity + var recipient: APIPerson + @Published var numReplies: Int + @Published var votes: VotesModel + @Published var creatorBannedFromCommunity: Bool + @Published var subscribed: APISubscribedStatus + @Published var saved: Bool + @Published var creatorBlocked: Bool + + var uid: ContentModelIdentifier { .init(contentType: .reply, contentId: commentReply.id) } + + // prevents a voting operation from ocurring while another is ocurring + var voting: Bool = false + + init(from replyView: APICommentReplyView) { + self.commentReply = replyView.commentReply + self.comment = replyView.comment + self.creator = replyView.creator + self.post = replyView.post + self.community = replyView.community + self.recipient = replyView.recipient + self.numReplies = replyView.counts.childCount + self.votes = VotesModel(from: replyView.counts, myVote: replyView.myVote) + self.creatorBannedFromCommunity = replyView.creatorBannedFromCommunity + self.subscribed = replyView.subscribed + self.saved = replyView.saved + self.creatorBlocked = replyView.creatorBlocked + } + + init( + from replyModel: ReplyModel, + commentReply: APICommentReply? = nil, + comment: APIComment? = nil, + creator: APIPerson? = nil, + post: APIPost? = nil, + community: APICommunity? = nil, + recipient: APIPerson? = nil, + numReplies: Int? = nil, + votes: VotesModel? = nil, + creatorBannedFromCommunity: Bool? = nil, + subscribed: APISubscribedStatus? = nil, + saved: Bool? = nil, + creatorBlocked: Bool? = nil + ) { + self.commentReply = commentReply ?? replyModel.commentReply + self.comment = comment ?? replyModel.comment + self.creator = creator ?? replyModel.creator + self.post = post ?? replyModel.post + self.community = community ?? replyModel.community + self.recipient = recipient ?? replyModel.recipient + self.numReplies = numReplies ?? replyModel.numReplies + self.votes = votes ?? replyModel.votes + self.creatorBannedFromCommunity = creatorBannedFromCommunity ?? replyModel.creatorBannedFromCommunity + self.subscribed = subscribed ?? replyModel.subscribed + self.saved = saved ?? replyModel.saved + self.creatorBlocked = creatorBlocked ?? replyModel.creatorBlocked + } +} + +extension ReplyModel { + @MainActor + func setCommentReply(_ commentReply: APICommentReply) { + self.commentReply = commentReply + } + + @MainActor + func setVotes(_ newVotes: VotesModel) { + votes = newVotes + } + + /// Re-initializes all fields to match the given ReplyModel + @MainActor + func reinit(from replyModel: ReplyModel) { + commentReply = replyModel.commentReply + comment = replyModel.comment + creator = replyModel.creator + post = replyModel.post + community = replyModel.community + recipient = replyModel.recipient + numReplies = replyModel.numReplies + votes = replyModel.votes + creatorBannedFromCommunity = replyModel.creatorBannedFromCommunity + subscribed = replyModel.subscribed + saved = replyModel.saved + creatorBlocked = replyModel.creatorBlocked + } + + func vote(inputOp: ScoringOperation, unreadTracker: UnreadTracker) async { + guard !voting else { + return + } + + voting = true + defer { voting = false } + + hapticManager.play(haptic: .gentleSuccess, priority: .low) + let operation = votes.myVote == inputOp ? ScoringOperation.resetVote : inputOp + + let original: ReplyModel = .init(from: self) + + // state fake + await setVotes(votes.applyScoringOperation(operation: operation)) + await setCommentReply(APICommentReply(from: commentReply, read: true)) + + do { + let updatedReply = try await inboxRepository.voteOnCommentReply(self, vote: operation) + await reinit(from: updatedReply) + if !original.commentReply.read { + _ = try await inboxRepository.markReplyRead(id: commentReply.id, isRead: true) + await unreadTracker.readReply() + } + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await reinit(from: original) + } + } + + func toggleRead(unreadTracker: UnreadTracker) async { + hapticManager.play(haptic: .gentleSuccess, priority: .low) + + // store original state + let originalCommentReply = commentReply + + // state fake + await setCommentReply(APICommentReply(from: commentReply, read: !commentReply.read)) + await unreadTracker.toggleReplyRead(originalState: originalCommentReply.read) + + // 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 reinit(from: newReply) + } catch { + hapticManager.play(haptic: .failure, priority: .high) + errorHandler.handle(error) + await setCommentReply(originalCommentReply) + await unreadTracker.toggleReplyRead(originalState: !originalCommentReply.read) + } + } + + @MainActor + func reply(editorTracker: EditorTracker, unreadTracker: UnreadTracker) { + editorTracker.openEditor(with: ConcreteEditorModel( + commentReply: self, + operation: InboxItemOperation.replyToInboxItem + )) + } + + @MainActor + func report(editorTracker: EditorTracker, unreadTracker: UnreadTracker) { + editorTracker.openEditor(with: ConcreteEditorModel( + commentReply: self, + operation: InboxItemOperation.reportInboxItem + )) + } + + func blockUser(userId: Int) async { + do { + let response = try await apiClient.blockPerson(id: userId, shouldBlock: true) + + if response.blocked { + hapticManager.play(haptic: .violentSuccess, priority: .high) + await notifier.add(.success("Blocked user")) + } + } catch { + errorHandler.handle( + .init( + message: "Unable to block user", + style: .toast, + underlyingError: error + ) + ) + } + } + + // MARK: - Menu functions and swipe actions + + // swiftlint:disable function_body_length + func menuFunctions( + unreadTracker: UnreadTracker, + editorTracker: EditorTracker + ) -> [MenuFunction] { + var ret: [MenuFunction] = .init() + + // upvote + 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) { + await self.vote(inputOp: .upvote, unreadTracker: unreadTracker) + } + }) + + // downvote + 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) { + await self.vote(inputOp: .downvote, unreadTracker: unreadTracker) + } + }) + + // toggle read + 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) { + await self.toggleRead(unreadTracker: unreadTracker) + } + }) + + // reply + ret.append(MenuFunction.standardMenuFunction( + text: "Reply", + imageName: Icons.reply, + destructiveActionPrompt: nil, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.reply(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + }) + + // report + ret.append(MenuFunction.standardMenuFunction( + text: "Report", + imageName: Icons.moderationReport, + destructiveActionPrompt: AppConstants.reportCommentPrompt, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.report(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + }) + + // block + ret.append(MenuFunction.standardMenuFunction( + text: "Block", + imageName: Icons.userBlock, + destructiveActionPrompt: AppConstants.blockUserPrompt, + enabled: true + ) { + Task(priority: .userInitiated) { + await self.blockUser(userId: self.creator.id) + } + }) + + return ret + } + + // swiftlint:enable function_body_length + + func swipeActions( + unreadTracker: UnreadTracker, + editorTracker: EditorTracker + ) -> SwipeConfiguration { + var leadingActions: [SwipeAction] = .init() + var trailingActions: [SwipeAction] = .init() + + leadingActions.append(SwipeAction( + symbol: .init( + emptyName: votes.myVote == .upvote ? Icons.resetVoteSquare : Icons.upvoteSquare, + fillName: votes.myVote == .upvote ? Icons.resetVoteSquareFill : Icons.upvoteSquareFill + ), + color: .upvoteColor + ) { + Task(priority: .userInitiated) { + await self.vote(inputOp: .upvote, unreadTracker: unreadTracker) + } + }) + + leadingActions.append(SwipeAction( + symbol: .init( + emptyName: votes.myVote == .downvote ? Icons.resetVoteSquare : Icons.downvoteSquare, + fillName: votes.myVote == .downvote ? Icons.resetVoteSquareFill : Icons.downvoteSquareFill + ), + color: .downvoteColor + ) { + Task(priority: .userInitiated) { + await self.vote(inputOp: .downvote, unreadTracker: unreadTracker) + } + }) + + trailingActions.append(SwipeAction( + symbol: .init( + emptyName: commentReply.read ? Icons.markRead : Icons.markUnread, + fillName: commentReply.read ? Icons.markUnreadFill : Icons.markReadFill + ), + color: .purple + ) { + Task(priority: .userInitiated) { + await self.toggleRead(unreadTracker: unreadTracker) + } + }) + + trailingActions.append(SwipeAction( + symbol: .init(emptyName: Icons.reply, fillName: Icons.replyFill), + color: .upvoteColor + ) { + Task(priority: .userInitiated) { + await self.reply(editorTracker: editorTracker, unreadTracker: unreadTracker) + } + }) + + return SwipeConfiguration(leadingActions: leadingActions, trailingActions: trailingActions) + } +} + +extension ReplyModel: Hashable { + /// Hashes all fields for which state changes should trigger view updates. + func hash(into hasher: inout Hasher) { + hasher.combine(uid) + hasher.combine(commentReply.read) + hasher.combine(comment.updated) + hasher.combine(votes) + hasher.combine(saved) + } +} + +extension ReplyModel: Identifiable { + var id: Int { hashValue } +} + +extension ReplyModel: Equatable { + static func == (lhs: ReplyModel, rhs: ReplyModel) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Mlem/Models/Trackers/Feed/ChildTracker.swift b/Mlem/Models/Trackers/Feed/ChildTracker.swift new file mode 100644 index 000000000..2c722a03b --- /dev/null +++ b/Mlem/Models/Trackers/Feed/ChildTracker.swift @@ -0,0 +1,94 @@ +// +// ChildTracker.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-16. +// +import Foundation + +class ChildTracker: StandardTracker, ChildTrackerProtocol { + private weak var parentTracker: (any ParentTrackerProtocol)? + private var cursor: Int = 0 + + func toParent(item: Item) -> ParentItem { + preconditionFailure("This method must be implemented by the inheriting class") + } + + func setParentTracker(_ newParent: any ParentTrackerProtocol) { + parentTracker = newParent + } + + /// Gets the next item in the feed stream and increments the cursor + /// **WARNING** this is NOT a thread-safe function! Only one thread at a time may call this function! + /// - Returns: next item in the feed stream + func consumeNextItem() -> ParentItem? { + assert(cursor < items.count, "consumeNextItem called on a tracker without a next item (cursor: \(cursor), count: \(items.count))!") + + if cursor < items.count { + cursor += 1 + return toParent(item: items[cursor - 1]) + } + + return nil + } + + /// Gets the sort value of the next item in feed stream for a given sort type without affecting the cursor. The sort type must match the sort type of this tracker. + /// **WARNING** this is NOT a thread-safe function! Only one thread at a time may call this function! + /// - Parameter sortType: type of sorting being performed + /// - Returns: sorting value of the next tracker item corresponding to the given sort type + func nextItemSortVal(sortType: TrackerSortType) async throws -> TrackerSortVal? { + assert(sortType == self.sortType, "Conflicting types for sortType! This will lead to unexpected sorting behavior.") + + if cursor < items.count { + return items[cursor].sortVal(sortType: sortType) + } else { + // if done loading, return nil + if loadingState == .done { + return nil + } + + // otherwise, wait for the next page to load and try to return the first value + // if the next page is already loading, this call to loadNextPage will be noop, but still wait until that load completes thanks to the semaphore + await loadNextPage() + return cursor < items.count ? items[cursor].sortVal(sortType: sortType) : nil + } + } + + /// Resets the cursor to 0 but does not unload any items + func resetCursor() { + cursor = 0 + } + + func refresh(clearBeforeRefresh: Bool, notifyParent: Bool = true) async throws { + try await refresh(clearBeforeRefresh: clearBeforeRefresh) + cursor = 0 + + if notifyParent, let parentTracker { + await parentTracker.refresh(clearBeforeFetch: clearBeforeRefresh) + } + } + + func reset(notifyParent: Bool = true) async { + await reset() + cursor = 0 + if notifyParent, let parentTracker { + await parentTracker.reset() + } + } + + @discardableResult override func filter(with filter: @escaping (Item) -> Bool) async -> Int { + let newItems = items.filter(filter) + let removed = items.count - newItems.count + + 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) + } +} diff --git a/Mlem/Models/Trackers/Feed/ChildTrackerProtocol.swift b/Mlem/Models/Trackers/Feed/ChildTrackerProtocol.swift new file mode 100644 index 000000000..82d0140a4 --- /dev/null +++ b/Mlem/Models/Trackers/Feed/ChildTrackerProtocol.swift @@ -0,0 +1,30 @@ +// +// ChildTrackerProtocol.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-15. +// +import Foundation + +protocol ChildTrackerProtocol: AnyObject { + associatedtype Item: TrackerItem + associatedtype ParentItem: TrackerItem + + // stream support methods + + func setParentTracker(_ newParent: any ParentTrackerProtocol) + + func consumeNextItem() -> ParentItem? + + func nextItemSortVal(sortType: TrackerSortType) async throws -> TrackerSortVal? + + func resetCursor() + + // loading methods + + func reset(notifyParent: Bool) async + + func refresh(clearBeforeRefresh: Bool, notifyParent: Bool) async throws + + @discardableResult func filter(with filter: @escaping (Item) -> Bool) async -> Int +} diff --git a/Mlem/Models/Trackers/Feed/CoreTracker.swift b/Mlem/Models/Trackers/Feed/CoreTracker.swift new file mode 100644 index 000000000..f06f4b8c2 --- /dev/null +++ b/Mlem/Models/Trackers/Feed/CoreTracker.swift @@ -0,0 +1,72 @@ +// +// CoreTracker.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-31. +// + +import Foundation + +/// Class providing common tracker functionality for BasicTracker and ParentTracker +class CoreTracker: ObservableObject { + @Published var items: [Item] = .init() + @Published private(set) var loadingState: LoadingState = .idle + + // uids of items that should trigger loading. threshold is several items before the end, to give the illusion of infinite loading. fallbackThreshold is the last item in feed, and exists to catch loading if the user scrolled too fast to trigger threshold + private(set) var threshold: ContentModelIdentifier? + private(set) var fallbackThreshold: ContentModelIdentifier? + + private(set) var internetSpeed: InternetSpeed + private(set) var sortType: TrackerSortType + + init(internetSpeed: InternetSpeed, sortType: TrackerSortType) { + self.internetSpeed = internetSpeed + self.sortType = sortType + } + + /// If the given item is the loading threshold item, loads more content + /// This should be called as an .onAppear of every item in a feed that should support infinite scrolling + func loadIfThreshold(_ item: Item) { + if loadingState == .idle, item.uid == threshold || item.uid == fallbackThreshold { + // this is a synchronous function that wraps the loading as a task so that the task is attached to the tracker itself, not the view that calls it, and is therefore safe from being cancelled by view redraws + Task(priority: .userInitiated) { + await loadNextPage() + } + } + } + + func loadNextPage() async { + preconditionFailure("This method must be overridden by the inheriting class") + } + + /// Updates the loading state + @MainActor + func setLoading(_ newState: LoadingState) { + loadingState = newState + } + + /// Sets the items to a new array + @MainActor + func setItems(_ newItems: [Item]) { + items = newItems + updateThresholds() + } + + /// Adds the given items to the items array + /// - Parameter toAdd: items to add + @MainActor + func addItems(_ newItems: [Item]) async { + items.append(contentsOf: newItems) + updateThresholds() + } + + private func updateThresholds() { + if items.isEmpty { + threshold = nil + } else { + let thresholdIndex = max(0, items.count + AppConstants.infiniteLoadThresholdOffset) + threshold = items[thresholdIndex].uid + fallbackThreshold = items.last?.uid + } + } +} diff --git a/Mlem/Models/Trackers/Feed/ParentTracker.swift b/Mlem/Models/Trackers/Feed/ParentTracker.swift new file mode 100644 index 000000000..abaa5b5e9 --- /dev/null +++ b/Mlem/Models/Trackers/Feed/ParentTracker.swift @@ -0,0 +1,183 @@ +// +// ParentTracker.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-15. +// + +import Dependencies +import Foundation +import Semaphore + +class ParentTracker: CoreTracker, ParentTrackerProtocol { + @Dependency(\.errorHandler) var errorHandler + + private var childTrackers: [any ChildTrackerProtocol] = .init() + private let loadingSemaphore: AsyncSemaphore = .init(value: 1) + + init(internetSpeed: InternetSpeed, sortType: TrackerSortType, childTrackers: [any ChildTrackerProtocol]) { + self.childTrackers = childTrackers + + super.init(internetSpeed: internetSpeed, sortType: sortType) + + for child in self.childTrackers { + child.setParentTracker(self) + } + } + + func addChildTracker(_ newChild: some ChildTrackerProtocol) { + newChild.setParentTracker(self) + } + + // MARK: main actor methods + + // note: all of the methods in here run on the main loop. items shouldn't be touched directly, but instead should be manipulated using these methods to ensure we aren't publishing updates from the background + + // MARK: loading methods + + /// Loads the next page of items + override func loadNextPage() async { + guard loadingState != .done else { + return + } + await addItems(fetchNextItems(numItems: internetSpeed.pageSize)) + } + + /// Refreshes the tracker, clearing all items and loading new ones + /// - Parameter clearBeforeFetch: true to clear items before fetch + func refresh(clearBeforeFetch: Bool = false) async { + if clearBeforeFetch { + await setItems(.init()) + } + + await resetChildren() + + let newItems = await fetchNextItems(numItems: internetSpeed.pageSize) + await setItems(newItems) + } + + /// Resets the tracker to an empty state + func reset() async { + await setItems(.init()) + await resetChildren() + } + + 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) + } + } + + /// Filters out items according to the given filtering function. + /// - Parameter filter: function that, given an Item, returns true if the item should REMAIN in the tracker + func filter(with filter: @escaping (Item) -> Bool) async { + // build set of uids to remove + var uidsToFilter: Set = .init() + items.forEach { item in + if !filter(item) { + uidsToFilter.insert(item.uid) + } + } + + // function to remove items from child trackers based on uid--this makes the Item-specific filtering applied here generically applicable to any child tracker + let filterFunc = { (item: any TrackerItem) in + !uidsToFilter.contains(item.uid) + } + + // apply filtering to children + let removed = await withTaskGroup(of: Int.self) { taskGroup in + childTrackers.forEach { child in + taskGroup.addTask { await child.filter(with: filterFunc) } + } + + // aggregate count of removed + var removed = 0 + for await result in taskGroup { + removed += result + } + + return removed + } + + // reload all non-removed items + let remaining = items.count - removed + let newItems = await fetchNextItems(numItems: max(remaining, abs(AppConstants.infiniteLoadThresholdOffset) + 1)) + await setItems(newItems) + } + + // MARK: private loading methods + + private func fetchNextItems(numItems: Int) async -> [Item] { + assert(numItems > abs(AppConstants.infiniteLoadThresholdOffset), "cannot load fewer items than infinite load offset") + + // only one thread may execute this function at a time because + await loadingSemaphore.wait() + defer { loadingSemaphore.signal() } + + await setLoading(.loading) + + var newItems: [Item] = .init() + for _ in 0 ..< numItems { + if let nextItem = await computeNextItem() { + newItems.append(nextItem) + } else { + await setLoading(.done) + break + } + } + + if loadingState != .done { + await setLoading(.idle) + } + + return newItems + } + + private func computeNextItem() async -> Item? { + var sortVal: TrackerSortVal? + var trackerToConsume: (any ChildTrackerProtocol)? + + for tracker in childTrackers { + (sortVal, trackerToConsume) = await compareNextTrackerItem( + sortType: sortType, + lhsVal: sortVal, + lhsTracker: trackerToConsume, + rhsTracker: tracker + ) + } + + if let trackerToConsume { + guard let nextItem = trackerToConsume.consumeNextItem() as? Item else { + assertionFailure("Could not convert child item to Item!") + return nil + } + + return nextItem + } + + return nil + } + + private func compareNextTrackerItem( + sortType: TrackerSortType, + lhsVal: TrackerSortVal?, + lhsTracker: (any ChildTrackerProtocol)?, + rhsTracker: any ChildTrackerProtocol + ) async -> (TrackerSortVal?, (any ChildTrackerProtocol)?) { + do { + guard let rhsVal = try await rhsTracker.nextItemSortVal(sortType: sortType) else { + return (lhsVal, lhsTracker) + } + + guard let lhsVal else { + return (rhsVal, rhsTracker) + } + + return lhsVal > rhsVal ? (lhsVal, lhsTracker) : (rhsVal, rhsTracker) + } catch { + errorHandler.handle(error) + return (lhsVal, lhsTracker) + } + } +} diff --git a/Mlem/Models/Trackers/Feed/ParentTrackerProtocol.swift b/Mlem/Models/Trackers/Feed/ParentTrackerProtocol.swift new file mode 100644 index 000000000..566808c3a --- /dev/null +++ b/Mlem/Models/Trackers/Feed/ParentTrackerProtocol.swift @@ -0,0 +1,19 @@ +// +// ParentTrackerProtocol.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-17. +// +import Foundation + +protocol ParentTrackerProtocol: AnyObject { + associatedtype Item: TrackerItem + + func loadIfThreshold(_ item: Item) + + func refresh(clearBeforeFetch: Bool) async + + func reset() async + + func filter(with filter: @escaping (Item) -> Bool) async +} diff --git a/Mlem/Models/Trackers/Feed/StandardTracker.swift b/Mlem/Models/Trackers/Feed/StandardTracker.swift new file mode 100644 index 000000000..7f0024345 --- /dev/null +++ b/Mlem/Models/Trackers/Feed/StandardTracker.swift @@ -0,0 +1,168 @@ +// +// StandardTracker.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-15. +// + +import Dependencies +import Foundation +import Semaphore + +class StandardTracker: CoreTracker { + @Dependency(\.errorHandler) var errorHandler + + // loading state + private var ids: Set = .init(minimumCapacity: 1000) + private(set) var page: Int = 0 // number of the most recently loaded page--0 indicates no content + private let loadingSemaphore: AsyncSemaphore = .init(value: 1) + + // MARK: - Main actor methods + + @MainActor + func update(with item: Item) { + guard let index = items.firstIndex(where: { $0.uid == item.uid }) else { + return + } + + items[index] = item + } + + // MARK: - External methods + + override func loadNextPage() async { + do { + try await loadPage(page + 1) + } catch { + errorHandler.handle(error) + } + } + + func refresh(clearBeforeRefresh: Bool) async throws { + try await loadPage(1, clearBeforeRefresh: clearBeforeRefresh) + } + + func reset() async { + do { + try await loadPage(0) + } catch { + assertionFailure("Exception thrown when resetting, this should not be possible!") + await clear() // this is not a thread-safe use of clear, but I'm using it here because we should never get here + } + } + + // MARK: - Internal tracking methods + + /// Loads the requested page. To account for the fact that multiple threads might request a load at the same time, this function requires that the caller pass in what it thinks is the next page to load. If that is not the next page by the time that call is allowed to execute, its request will be ignored. + /// This grants this function an additional, extremely useful property: calling `await loadPage` while `loadPage` is already being executed will, practically speaking, await the in-flight request. + /// There is additional logic to handle the reset case--because page is updated at the end of this call, if reset() set the page to 0 itself and a reset call were made while another loading call was in-flight, the in-flight call would update page before the reset call went through and the reset call's load would be aborted. Instead, this method takes on responsibility for resetting--calling it on page 0 clears the tracker, and page 1 refreshes it + /// - Parameter page: page number to load + func loadPage(_ pageToLoad: Int, clearBeforeRefresh: Bool = false) async throws { + assert(!clearBeforeRefresh || pageToLoad == 1, "clearBeforeRefresh cannot be true if not loading page 1") + + // only one thread may execute this function at a time + await loadingSemaphore.wait() + defer { loadingSemaphore.signal() } + + // special reset cases + if pageToLoad == 0 { + print("[\(Item.self) tracker] clearing") + await clear() + return + } + + if pageToLoad == 1 { + print("[\(Item.self) tracker] refreshing") + if clearBeforeRefresh { + await clear() + } else { + // if not clearing before reset, still clear these fields in order to sanitize the loading state--we just keep the items in place until we have received new ones, which will be set below + page = 0 + ids = .init(minimumCapacity: 1000) + await setLoading(.idle) + } + } + + if pageToLoad > 1 { + print("[\(Item.self) tracker] loading page \(pageToLoad)") + } + + // do not continue to load if done. this check has to come after the clear/refresh cases because those cases can be called on a .done tracker + guard loadingState != .done else { + print("[\(Item.self) tracker] done loading, will not continue") + return + } + + // do nothing if this is not the next page to load + guard pageToLoad == page + 1 else { + print("[\(Item.self) tracker] will not load page \(pageToLoad) of items (have loaded \(page) pages)") + return + } + + var newItems: [Item] = .init() + while newItems.count < internetSpeed.pageSize { + let fetchedItems = try await fetchPage(page: page + 1) + page += 1 + + if fetchedItems.isEmpty { + print("[\(Item.self) tracker] fetch returned no items, setting loading state to done") + await setLoading(.done) + break + } + + newItems.append(contentsOf: fetchedItems) + } + + let allowedItems = storeIdsAndDedupe(newItems: newItems) + + // if loading page 1, we can just do a straight assignment regardless of whether we did clearBeforeReset + if pageToLoad == 1 { + await setItems(allowedItems) + } else { + await addItems(allowedItems) + } + + if loadingState != .done { + await setLoading(.idle) + } + } + + // MARK: - Helpers + + /// Fetches the next page of items. This method must be overridden by the instantiating class because different items are loaded differently. Relies on the instantiating class to handle fetch parameters such as unreadOnly and page size. + /// - Parameters: + /// - page: page number to fetch + /// - Returns: requested page of items + func fetchPage(page: Int) async throws -> [Item] { + preconditionFailure("This method must be implemented by the inheriting class") + } + + /// Filters out items according to the given filtering function. + /// - Parameter filter: function that, given an Item, returns true if the item should REMAIN in the tracker + @discardableResult func filter(with filter: @escaping (Item) -> Bool) async -> Int { + let newItems = items.filter(filter) + let removed = items.count - newItems.count + + await setItems(newItems) + + return removed + } + + /// Given an array of Items, adds their ids to ids. Returns the input filtered to only items not previously present in ids. + /// - Parameter newMessages: array of MessageModel + /// - Returns: newMessages, filtered to only messages not already present in ids + private func storeIdsAndDedupe(newItems: [Item]) -> [Item] { + let accepted = newItems.filter { ids.insert($0.uid).inserted } + return accepted + } + + /// Clears the tracker to an empty state. + /// **WARNING:** + /// **DO NOT** call this method from anywhere but loadPage! This is *purely* a helper function for loadPage and *will* lead to unexpected behavior if called elsewhere! + private func clear() async { + ids = .init(minimumCapacity: 1000) + page = 0 + await setLoading(.idle) + await setItems(.init()) + } +} diff --git a/Mlem/Models/Trackers/Feed/TrackerItem.swift b/Mlem/Models/Trackers/Feed/TrackerItem.swift new file mode 100644 index 000000000..907d6abbe --- /dev/null +++ b/Mlem/Models/Trackers/Feed/TrackerItem.swift @@ -0,0 +1,20 @@ +// +// TrackerSortableNew.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-15. +// +import Foundation + +protocol TrackerItem: Equatable { + var uid: ContentModelIdentifier { get } + func sortVal(sortType: TrackerSortType) -> TrackerSortVal + + static func == (lhs: any TrackerItem, rhs: any TrackerItem) -> Bool +} + +extension TrackerItem { + static func == (lhs: any TrackerItem, rhs: any TrackerItem) -> Bool { + lhs.uid == rhs.uid + } +} diff --git a/Mlem/Models/Trackers/Feed/TrackerSort.swift b/Mlem/Models/Trackers/Feed/TrackerSort.swift new file mode 100644 index 000000000..014e9b182 --- /dev/null +++ b/Mlem/Models/Trackers/Feed/TrackerSort.swift @@ -0,0 +1,40 @@ +// +// TrackerSort.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-15. +// +import Foundation + +enum TrackerSortType { + case published +} + +enum TrackerSortVal: Comparable { + case published(Date) + + static func typeEquals(lhs: TrackerSortVal, rhs: TrackerSortVal) -> Bool { + switch lhs { + case .published: + switch rhs { + case .published: + return true + } + } + } + + static func < (lhs: TrackerSortVal, rhs: TrackerSortVal) -> Bool { + guard typeEquals(lhs: lhs, rhs: rhs) else { + assertionFailure("Compare called on trackersortvals with different types") + return true + } + + switch lhs { + case let .published(lhsDate): + switch rhs { + case let .published(rhsDate): + return lhsDate < rhsDate + } + } + } +} diff --git a/Mlem/Models/Trackers/Inbox/Inbox Item.swift b/Mlem/Models/Trackers/Inbox/Inbox Item.swift deleted file mode 100644 index 6178610b1..000000000 --- a/Mlem/Models/Trackers/Inbox/Inbox Item.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Inbox Item.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Foundation -import SwiftUI - -/// Wrapper for items in the inbox to allow a unified, sorted feed -struct InboxItem: Identifiable { - let published: Date - let baseId: Int - var id: Int { hashId() } - let read: Bool - let type: InboxItemType - - private func hashId() -> Int { - var hasher = Hasher() - hasher.combine(baseId) - hasher.combine(type.hasherId) - return hasher.finalize() - } -} - -extension InboxItem: Comparable { - static func == (lhs: InboxItem, rhs: InboxItem) -> Bool { - lhs.id == rhs.id - } - - static func < (lhs: InboxItem, rhs: InboxItem) -> Bool { - lhs.published < rhs.published - } -} diff --git a/Mlem/Models/Trackers/Inbox/Inbox Tracker.swift b/Mlem/Models/Trackers/Inbox/Inbox Tracker.swift deleted file mode 100644 index 135abe7d2..000000000 --- a/Mlem/Models/Trackers/Inbox/Inbox Tracker.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Inbox Tracker.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-30. -// - -import Foundation - -protocol InboxTracker: FeedTracking { - var unreadOnly: Bool { get set } -} diff --git a/Mlem/Models/Trackers/Inbox/InboxItem.swift b/Mlem/Models/Trackers/Inbox/InboxItem.swift new file mode 100644 index 000000000..11d56c646 --- /dev/null +++ b/Mlem/Models/Trackers/Inbox/InboxItem.swift @@ -0,0 +1,46 @@ +// +// InboxItem.swift +// Mlem +// +// Created by Eric Andrews on 2023-06-26. +// + +import Foundation +import SwiftUI + +protocol InboxItem: Identifiable, ContentIdentifiable, TrackerItem { + var published: Date { get } + var uid: ContentModelIdentifier { get } + var creatorId: Int { get } + var read: Bool { get } + var id: Int { get } +} + +enum AnyInboxItem: InboxItem { + case reply(ReplyModel) + case mention(MentionModel) + case message(MessageModel) + + var value: any InboxItem { + switch self { + case let .reply(reply): + return reply + case let .mention(mention): + return mention + case let .message(message): + return message + } + } + + var published: Date { value.published } + + var uid: ContentModelIdentifier { value.uid } + + var creatorId: Int { value.creatorId } + + var read: Bool { value.read } + + var id: Int { value.id } + + func sortVal(sortType: TrackerSortType) -> TrackerSortVal { value.sortVal(sortType: sortType) } +} diff --git a/Mlem/Models/Trackers/Inbox/InboxTracker.swift b/Mlem/Models/Trackers/Inbox/InboxTracker.swift new file mode 100644 index 000000000..597b03d3f --- /dev/null +++ b/Mlem/Models/Trackers/Inbox/InboxTracker.swift @@ -0,0 +1,39 @@ +// +// InboxTracker.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-26. +// + +import Dependencies +import Foundation + +class InboxTracker: ParentTracker { + @Dependency(\.inboxRepository) var inboxRepository + @Dependency(\.hapticManager) var hapticManager + + func filterRead() async { + await filter { item in + !item.read + } + } + + func markAllAsRead(unreadTracker: UnreadTracker) async { + do { + try await inboxRepository.markAllAsRead() + await unreadTracker.reset() + // TODO: state fake read for everything? I don't love the clearBeforeFetch here but it's better than the long wait with no indicator + await refresh(clearBeforeFetch: true) + } catch { + errorHandler.handle(error) + hapticManager.play(haptic: .failure, priority: .high) + } + } + + // this function isn't actually used right now because there isn't a nice way to filter blocked users on refresh, but it'll be useful some day + func filterUser(id: Int) async { + await filter { item in + item.creatorId != id + } + } +} diff --git a/Mlem/Models/Trackers/Inbox/MentionTracker.swift b/Mlem/Models/Trackers/Inbox/MentionTracker.swift new file mode 100644 index 000000000..bcb62f005 --- /dev/null +++ b/Mlem/Models/Trackers/Inbox/MentionTracker.swift @@ -0,0 +1,28 @@ +// +// MentionTracker.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-20. +// + +import Dependencies +import Foundation + +class MentionTracker: ChildTracker { + @Dependency(\.inboxRepository) var inboxRepository + + var unreadOnly: Bool + + init(internetSpeed: InternetSpeed, sortType: TrackerSortType, unreadOnly: Bool) { + self.unreadOnly = unreadOnly + super.init(internetSpeed: internetSpeed, sortType: sortType) + } + + override func fetchPage(page: Int) async throws -> [MentionModel] { + try await inboxRepository.loadMentions(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) + } + + override func toParent(item: MentionModel) -> AnyInboxItem { + .mention(item) + } +} diff --git a/Mlem/Models/Trackers/Inbox/Mentions Tracker.swift b/Mlem/Models/Trackers/Inbox/Mentions Tracker.swift deleted file mode 100644 index a4ef0ccd5..000000000 --- a/Mlem/Models/Trackers/Inbox/Mentions Tracker.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Mentions Tracker.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Dependencies -import Foundation -import SwiftUI - -class MentionsTracker: InboxTracker { - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast - @AppStorage("shouldFilterRead") var unreadOnly = false - - @Dependency(\.apiClient) var apiClient - - @Published var items: [APIPersonMentionView] = [] - - var ids: Set = .init(minimumCapacity: 1000) - - var isLoading: Bool = false - - var page: Int = 0 - - var shouldPerformMergeSorting: Bool = true - - func retrieveItems(for page: Int) async throws -> [APIPersonMentionView] { - try await apiClient.getPersonMentions( - sort: .new, - page: page, - limit: internetSpeed.pageSize, - unreadOnly: unreadOnly - ) - } -} diff --git a/Mlem/Models/Trackers/Inbox/MessageTracker.swift b/Mlem/Models/Trackers/Inbox/MessageTracker.swift new file mode 100644 index 000000000..f62c88a86 --- /dev/null +++ b/Mlem/Models/Trackers/Inbox/MessageTracker.swift @@ -0,0 +1,27 @@ +// +// MessageTracker.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-15. +// +import Dependencies +import Foundation + +class MessageTracker: ChildTracker { + @Dependency(\.inboxRepository) var inboxRepository + + var unreadOnly: Bool + + init(internetSpeed: InternetSpeed, sortType: TrackerSortType, unreadOnly: Bool) { + self.unreadOnly = unreadOnly + super.init(internetSpeed: internetSpeed, sortType: sortType) + } + + override func fetchPage(page: Int) async throws -> [MessageModel] { + try await inboxRepository.loadMessages(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) + } + + override func toParent(item: MessageModel) -> AnyInboxItem { + .message(item) + } +} diff --git a/Mlem/Models/Trackers/Inbox/Messages Tracker.swift b/Mlem/Models/Trackers/Inbox/Messages Tracker.swift deleted file mode 100644 index b103e4b8e..000000000 --- a/Mlem/Models/Trackers/Inbox/Messages Tracker.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Messages Tracker.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Dependencies -import Foundation -import SwiftUI - -class MessagesTracker: InboxTracker { - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast - @AppStorage("shouldFilterRead") var unreadOnly = false - - @Dependency(\.apiClient) var apiClient - - var items: [APIPrivateMessageView] = [] - - var ids: Set = .init(minimumCapacity: 1000) - - var isLoading: Bool = false - - var page: Int = 0 - - var shouldPerformMergeSorting: Bool = true - - func retrieveItems(for page: Int) async throws -> [APIPrivateMessageView] { - try await apiClient.getPrivateMessages(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) - } -} diff --git a/Mlem/Models/Trackers/Inbox/Replies Tracker.swift b/Mlem/Models/Trackers/Inbox/Replies Tracker.swift deleted file mode 100644 index 79d759e73..000000000 --- a/Mlem/Models/Trackers/Inbox/Replies Tracker.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Replies Tracker.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Dependencies -import Foundation -import SwiftUI - -class RepliesTracker: InboxTracker { - @AppStorage("internetSpeed") var internetSpeed: InternetSpeed = .fast - @AppStorage("shouldFilterRead") var unreadOnly = false - - @Dependency(\.apiClient) var apiClient - - var isLoading: Bool = false - - var items: [APICommentReplyView] = [] - - var ids: Set = .init(minimumCapacity: 1000) - - var page: Int = 0 - - var shouldPerformMergeSorting: Bool = true - - func retrieveItems(for page: Int) async throws -> [APICommentReplyView] { - try await apiClient.getReplies(sort: .new, page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) - } -} diff --git a/Mlem/Models/Trackers/Inbox/ReplyTracker.swift b/Mlem/Models/Trackers/Inbox/ReplyTracker.swift new file mode 100644 index 000000000..2854afa92 --- /dev/null +++ b/Mlem/Models/Trackers/Inbox/ReplyTracker.swift @@ -0,0 +1,28 @@ +// +// ReplyTrackerNew.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-15. +// + +import Dependencies +import Foundation + +class ReplyTracker: ChildTracker { + @Dependency(\.inboxRepository) var inboxRepository + + var unreadOnly: Bool + + init(internetSpeed: InternetSpeed, sortType: TrackerSortType, unreadOnly: Bool) { + self.unreadOnly = unreadOnly + super.init(internetSpeed: internetSpeed, sortType: sortType) + } + + override func fetchPage(page: Int) async throws -> [ReplyModel] { + try await inboxRepository.loadReplies(page: page, limit: internetSpeed.pageSize, unreadOnly: unreadOnly) + } + + override func toParent(item: ReplyModel) -> AnyInboxItem { + .reply(item) + } +} diff --git a/Mlem/Models/Trackers/Inbox/UnreadTracker.swift b/Mlem/Models/Trackers/Inbox/UnreadTracker.swift index a79068e9a..fee5f5c5e 100644 --- a/Mlem/Models/Trackers/Inbox/UnreadTracker.swift +++ b/Mlem/Models/Trackers/Inbox/UnreadTracker.swift @@ -28,33 +28,76 @@ class UnreadTracker: ObservableObject { total = counts.replies + counts.mentions + counts.privateMessages } + @MainActor + func reset() { + replies = 0 + mentions = 0 + messages = 0 + total = 0 + } + + @MainActor func readReply() { replies -= 1 total -= 1 } + @MainActor func unreadReply() { replies += 1 total += 1 } + @MainActor func readMention() { mentions -= 1 total -= 1 } + @MainActor func unreadMention() { mentions += 1 total += 1 } + @MainActor func readMessage() { messages -= 1 total -= 1 } + @MainActor func unreadMessage() { messages += 1 total += 1 } + + // convenience methods to flip a read state (if originalState is true (read), will unread a message; if false, will read a message) + + @MainActor + func toggleReplyRead(originalState: Bool) { + if originalState { + unreadReply() + } else { + readReply() + } + } + + @MainActor + func toggleMentionRead(originalState: Bool) { + if originalState { + unreadMention() + } else { + readMention() + } + } + + @MainActor + func toggleMessageRead(originalState: Bool) { + if originalState { + unreadMessage() + } else { + readMessage() + } + } } diff --git a/Mlem/Models/Trackers/RecentSearchesTracker.swift b/Mlem/Models/Trackers/RecentSearchesTracker.swift index 72af815ec..2f72678f4 100644 --- a/Mlem/Models/Trackers/RecentSearchesTracker.swift +++ b/Mlem/Models/Trackers/RecentSearchesTracker.swift @@ -36,8 +36,9 @@ class RecentSearchesTracker: ObservableObject { case .user: let user = try await personRepository.loadUser(for: id.contentId) newSearches.append(AnyContentModel(user)) - case .comment: - break + default: + assertionFailure("Received unexpected content type in recent searches \(id.contentType)") + return } } diff --git a/Mlem/Repositories/CommentRepository.swift b/Mlem/Repositories/CommentRepository.swift index 943ab8d47..06b5404d0 100644 --- a/Mlem/Repositories/CommentRepository.swift +++ b/Mlem/Repositories/CommentRepository.swift @@ -42,52 +42,6 @@ class CommentRepository { } } - func voteOnCommentReply(_ reply: APICommentReplyView, vote: ScoringOperation) async throws -> APICommentReplyView { - // no haptics here as we defer to the `voteOnComment` method which will produce them if necessary - do { - let updatedCommentView = try await voteOnComment(id: reply.comment.id, vote: vote) - return .init( - commentReply: reply.commentReply, - comment: updatedCommentView.comment, - creator: updatedCommentView.creator, - post: updatedCommentView.post, - community: updatedCommentView.community, - recipient: reply.recipient, - counts: updatedCommentView.counts, - creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, - subscribed: updatedCommentView.subscribed, - saved: updatedCommentView.saved, - creatorBlocked: updatedCommentView.creatorBlocked, - myVote: updatedCommentView.myVote - ) - } catch { - throw error - } - } - - func voteOnPersonMention(_ mention: APIPersonMentionView, vote: ScoringOperation) async throws -> APIPersonMentionView { - // no haptics here as we defer to the `voteOnComment` method which will produce them if necessary - do { - let updatedCommentView = try await voteOnComment(id: mention.comment.id, vote: vote) - return .init( - personMention: mention.personMention, - comment: updatedCommentView.comment, - creator: mention.creator, - post: updatedCommentView.post, - community: updatedCommentView.community, - recipient: mention.recipient, - counts: updatedCommentView.counts, - creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, - subscribed: updatedCommentView.subscribed, - saved: updatedCommentView.saved, - creatorBlocked: updatedCommentView.creatorBlocked, - myVote: updatedCommentView.myVote - ) - } catch { - throw error - } - } - @discardableResult func postComment( content: String, @@ -169,8 +123,4 @@ class CommentRepository { throw error } } - - func markCommentReadStatus(id: Int, isRead: Bool) async throws -> CommentReplyResponse { - try await apiClient.markCommentReplyRead(id: id, isRead: isRead) - } } diff --git a/Mlem/Repositories/InboxRepository.swift b/Mlem/Repositories/InboxRepository.swift new file mode 100644 index 000000000..816670344 --- /dev/null +++ b/Mlem/Repositories/InboxRepository.swift @@ -0,0 +1,160 @@ +// +// InboxRepository.swift +// Mlem +// +// Created by Eric Andrews on 2023-09-23. +// +import Dependencies +import Foundation + +/// Repository for inbox items +class InboxRepository { + @Dependency(\.apiClient) var apiClient + @Dependency(\.commentRepository) var commentRepository + @Dependency(\.hapticManager) var hapticManager + + func markAllAsRead() async throws { + try await apiClient.markAllAsRead() + } + + // MARK: - replies + + func loadReplies( + page: Int, + limit: Int, + unreadOnly: Bool + ) async throws -> [ReplyModel] { + try await apiClient.getReplies( + sort: .new, + page: page, + limit: limit, + unreadOnly: unreadOnly + ) + .map { ReplyModel(from: $0) } + } + + 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) + let updatedCommentReplyView = APICommentReplyView( + commentReply: reply.commentReply, + comment: updatedCommentView.comment, + creator: updatedCommentView.creator, + post: updatedCommentView.post, + community: updatedCommentView.community, + recipient: reply.recipient, + counts: updatedCommentView.counts, + creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, + subscribed: updatedCommentView.subscribed, + saved: updatedCommentView.saved, + creatorBlocked: updatedCommentView.creatorBlocked, + myVote: updatedCommentView.myVote + ) + return ReplyModel(from: updatedCommentReplyView) + } catch { + throw error + } + } + + func markReplyRead(id: Int, isRead: Bool) async throws -> ReplyModel { + let updatedReply = try await apiClient.markCommentReplyRead(id: id, isRead: isRead) + return ReplyModel(from: updatedReply.commentReplyView) + } + + // MARK: - mentions + + func loadMentions( + page: Int, + limit: Int, + unreadOnly: Bool + ) async throws -> [MentionModel] { + try await apiClient.getPersonMentions( + sort: .new, + page: page, + limit: limit, + unreadOnly: unreadOnly + ) + .map { MentionModel(from: $0) } + } + + func markMentionRead(id: Int, isRead: Bool) async throws -> MentionModel { + let response = try await apiClient.markPersonMentionAsRead(mentionId: id, isRead: isRead) + return MentionModel(from: response) + } + + 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) + let updatedPersonMention = APIPersonMentionView( + personMention: mention.personMention, + comment: updatedCommentView.comment, + creator: mention.creator, + post: updatedCommentView.post, + community: updatedCommentView.community, + recipient: mention.recipient, + counts: updatedCommentView.counts, + creatorBannedFromCommunity: updatedCommentView.creatorBannedFromCommunity, + subscribed: updatedCommentView.subscribed, + saved: updatedCommentView.saved, + creatorBlocked: updatedCommentView.creatorBlocked, + myVote: updatedCommentView.myVote + ) + return MentionModel(from: updatedPersonMention) + } catch { + throw error + } + } + + // MARK: - messages + + /// Loads a page of private messages + /// - Parameters: + /// - page: page number to load + /// - limit: number of items per page to load + /// - unreadOnly: whether to load only unread items (true) or all items (false) + /// - Returns: [PrivateMessageModel] containing requested messages + func loadMessages( + page: Int, + limit: Int, + unreadOnly: Bool + ) async throws -> [MessageModel] { + try await apiClient.getPrivateMessages( + page: page, + limit: limit, + unreadOnly: unreadOnly + ) + .map { MessageModel(from: $0) } + } + + /// Sends a private message + /// - Parameters: + /// - content: body of the message + /// - recipientId: id of the person to whom the message should be sent + /// - Returns: PrivateMessageModel with the sent message + func sendMessage(content: String, recipientId: Int) async throws -> MessageModel { + let response = try await apiClient.sendPrivateMessage(content: content, recipientId: recipientId) + return MessageModel(from: response.privateMessageView) + } + + /// Marks a private message as read or unread + /// - Parameters: + /// - id: id of the private message to mark as read + /// - isRead: whether to mark the private message as read (true) or unread (false) + /// - Returns: PrivateMessageModel with the updated state of the private message + func markMessageRead(id: Int, isRead: Bool) async throws -> MessageModel { + let response = try await apiClient.markPrivateMessageRead(id: id, isRead: isRead) + return MessageModel(from: response) + } + + // TODO: migrate APIPrivateMessageReportView to middleware model + /// Reports a private message + /// - Parameters: + /// - id: id of the message to report + /// - reason: reason for reporting the message + /// - Returns: APIPrivateMessageReportView with the report info + func reportMessage(id: Int, reason: String) async throws -> APIPrivateMessageReportView { + try await apiClient.reportPrivateMessage(id: id, reason: reason) + } +} diff --git a/Mlem/Views/Shared/Components/Components/ScoreCounterView.swift b/Mlem/Views/Shared/Components/Components/ScoreCounterView.swift index fc2ccd973..1f18f384a 100644 --- a/Mlem/Views/Shared/Components/Components/ScoreCounterView.swift +++ b/Mlem/Views/Shared/Components/Components/ScoreCounterView.swift @@ -17,24 +17,13 @@ struct ScoreCounterView: View { let upvote: () async -> Void let downvote: () async -> Void - var scoreColor: Color { - switch vote { - case .upvote: - return Color.upvoteColor - case .resetVote: - return Color.primary - case .downvote: - return Color.downvoteColor - } - } - var body: some View { HStack(spacing: 6) { UpvoteButtonView(vote: vote, upvote: upvote) .offset(x: AppConstants.postAndCommentSpacing) Text(String(score)) - .foregroundColor(scoreColor) + .foregroundColor(vote.color ?? .primary) if siteInformation.enableDownvotes { DownvoteButtonView(vote: vote, downvote: downvote) diff --git a/Mlem/Views/Shared/Components/End Of Feed View.swift b/Mlem/Views/Shared/Components/End Of Feed View.swift index 300abebe5..e23ed9625 100644 --- a/Mlem/Views/Shared/Components/End Of Feed View.swift +++ b/Mlem/Views/Shared/Components/End Of Feed View.swift @@ -8,18 +8,40 @@ import Foundation import SwiftUI +struct EndOfFeedViewContent { + let icon: String + let message: String +} + +enum EndOfFeedViewType { + case hobbit, cartoon + + var viewContent: EndOfFeedViewContent { + switch self { + case .hobbit: + 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!") + } + } +} + struct EndOfFeedView: View { - let isLoading: Bool + let loadingState: LoadingState + let viewType: EndOfFeedViewType var body: some View { Group { - if isLoading { + switch loadingState { + case .idle: + EmptyView() + case .loading: LoadingView(whatIsLoading: .posts) - } else { + case .done: HStack { - Image(systemName: Icons.endOfFeed) + Image(systemName: viewType.viewContent.icon) - Text("I think I've found the bottom!") + Text(viewType.viewContent.message) } .foregroundColor(.secondary) } diff --git a/Mlem/Views/Shared/Search Bar/SearchBar.swift b/Mlem/Views/Shared/Search Bar/SearchBar.swift index 8cdedd392..71a7ff4bd 100644 --- a/Mlem/Views/Shared/Search Bar/SearchBar.swift +++ b/Mlem/Views/Shared/Search Bar/SearchBar.swift @@ -9,291 +9,293 @@ import SwiftUI #if (os(iOS) && canImport(CoreTelephony)) || os(macOS) || targetEnvironment(macCatalyst) -/// A specialized view for receiving search-related information from the user. -public struct SearchBar: DefaultTextInputType { - @Binding fileprivate var text: String + /// A specialized view for receiving search-related information from the user. + public struct SearchBar: DefaultTextInputType { + @Binding fileprivate var text: String // var customAppKitOrUIKitClass: AppKitOrUIKitSearchBar.Type? // UISearchBar - private let onEditingChanged: (Bool) -> Void - private let onCommit: () -> Void - private var isInitialFirstResponder: Bool? - private var isFocused: Binding? + private let onEditingChanged: (Bool) -> Void + private let onCommit: () -> Void + private var isInitialFirstResponder: Bool? + private var isFocused: Binding? - private var placeholder: String? + private var placeholder: String? - #if os(iOS) || targetEnvironment(macCatalyst) - private var iconImageConfiguration: [UISearchBar.Icon: UIImage] = [:] - #endif + #if os(iOS) || targetEnvironment(macCatalyst) + private var iconImageConfiguration: [UISearchBar.Icon: UIImage] = [:] + #endif - private var showsCancelButton: Bool? - private var onCancel: () -> Void = { } + private var showsCancelButton: Bool? + private var onCancel: () -> Void = {} - #if os(iOS) || targetEnvironment(macCatalyst) - private var returnKeyType: UIReturnKeyType? - private var enablesReturnKeyAutomatically: Bool? - private var isSecureTextEntry: Bool = false - private var textContentType: UITextContentType? - private var keyboardType: UIKeyboardType? - #endif + #if os(iOS) || targetEnvironment(macCatalyst) + private var returnKeyType: UIReturnKeyType? + private var enablesReturnKeyAutomatically: Bool? + private var isSecureTextEntry: Bool = false + private var textContentType: UITextContentType? + private var keyboardType: UIKeyboardType? + #endif - public init( - _ title: S, - text: Binding, - onEditingChanged: @escaping (Bool) -> Void = { _ in }, - onCommit: @escaping () -> Void = { } - ) { - self.placeholder = String(title) - self._text = text - self.onCommit = onCommit - self.onEditingChanged = onEditingChanged - } + public init( + _ title: some StringProtocol, + text: Binding, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + onCommit: @escaping () -> Void = {} + ) { + self.placeholder = String(title) + self._text = text + self.onCommit = onCommit + self.onEditingChanged = onEditingChanged + } - public init( - text: Binding, - onEditingChanged: @escaping (Bool) -> Void = { _ in }, - onCommit: @escaping () -> Void = { } - ) { - self._text = text - self.onCommit = onCommit - self.onEditingChanged = onEditingChanged + public init( + text: Binding, + onEditingChanged: @escaping (Bool) -> Void = { _ in }, + onCommit: @escaping () -> Void = {} + ) { + self._text = text + self.onCommit = onCommit + self.onEditingChanged = onEditingChanged + } } -} -@available(macCatalystApplicationExtension, unavailable) -@available(iOSApplicationExtension, unavailable) -@available(tvOSApplicationExtension, unavailable) -extension SearchBar: UIViewRepresentable { - public typealias UIViewType = UISearchBar + @available(macCatalystApplicationExtension, unavailable) + @available(iOSApplicationExtension, unavailable) + @available(tvOSApplicationExtension, unavailable) + extension SearchBar: UIViewRepresentable { + public typealias UIViewType = UISearchBar - public func makeUIView(context: Context) -> UIViewType { - let uiView = _UISearchBar() + public func makeUIView(context: Context) -> UIViewType { + let uiView = _UISearchBar() - uiView.delegate = context.coordinator + uiView.delegate = context.coordinator - if context.environment.isEnabled { - DispatchQueue.main.async { - if (isInitialFirstResponder ?? isFocused?.wrappedValue) ?? false { - uiView.becomeFirstResponder() + if context.environment.isEnabled { + DispatchQueue.main.async { + if (isInitialFirstResponder ?? isFocused?.wrappedValue) ?? false { + uiView.becomeFirstResponder() + } } } - } - return uiView - } + return uiView + } - public func updateUIView(_ uiView: UIViewType, context: Context) { - context.coordinator.base = self + public func updateUIView(_ uiView: UIViewType, context: Context) { + context.coordinator.base = self - _updateUISearchBar(uiView, environment: context.environment) - } + _updateUISearchBar(uiView, environment: context.environment) + } - func _updateUISearchBar( - _ uiView: UIViewType, - environment: EnvironmentValues - ) { - uiView.isUserInteractionEnabled = environment.isEnabled + func _updateUISearchBar( + _ uiView: UIViewType, + environment: EnvironmentValues + ) { + uiView.isUserInteractionEnabled = environment.isEnabled - do { - uiView.searchTextField.autocorrectionType = environment.disableAutocorrection.map({ $0 ? .no : .yes }) ?? .default + do { + uiView.searchTextField.autocorrectionType = environment.disableAutocorrection.map { $0 ? .no : .yes } ?? .default - if let placeholder = placeholder { - uiView.placeholder = placeholder - } + if let placeholder { + uiView.placeholder = placeholder + } - for (icon, image) in iconImageConfiguration where uiView.image( - for: icon, state: .normal) == nil { // FIXME: This is a performance hack. - uiView.setImage(image, for: icon, state: .normal) - } + for (icon, image) in iconImageConfiguration where uiView.image( + for: icon, state: .normal + ) == nil { // FIXME: This is a performance hack. + uiView.setImage(image, for: icon, state: .normal) + } - if let showsCancelButton = showsCancelButton { - if uiView.showsCancelButton != showsCancelButton { - uiView.setShowsCancelButton(showsCancelButton, animated: true) + if let showsCancelButton { + if uiView.showsCancelButton != showsCancelButton { + uiView.setShowsCancelButton(showsCancelButton, animated: true) + } } } - } - - do { - _assignIfNotEqual(returnKeyType ?? .default, to: &uiView.returnKeyType) - _assignIfNotEqual(keyboardType ?? .default, to: &uiView.keyboardType) - _assignIfNotEqual(enablesReturnKeyAutomatically ?? false, to: &uiView.enablesReturnKeyAutomatically) - } - do { - if uiView.text != text { - uiView.text = text + do { + _assignIfNotEqual(returnKeyType ?? .default, to: &uiView.returnKeyType) + _assignIfNotEqual(keyboardType ?? .default, to: &uiView.keyboardType) + _assignIfNotEqual(enablesReturnKeyAutomatically ?? false, to: &uiView.enablesReturnKeyAutomatically) } + + do { + if uiView.text != text { + uiView.text = text + } - if !uiView.searchTextField.tokens.isEmpty { - uiView.searchTextField.tokens = [] + if !uiView.searchTextField.tokens.isEmpty { + uiView.searchTextField.tokens = [] + } } - } - (uiView as? _UISearchBar)?.isFirstResponderBinding = isFocused + (uiView as? _UISearchBar)?.isFirstResponderBinding = isFocused - do { - if let uiView = uiView as? _UISearchBar, environment.isEnabled { - DispatchQueue.main.async { - if let isFocused = isFocused, uiView.window != nil { - uiView.isFirstResponderBinding = isFocused + do { + if let uiView = uiView as? _UISearchBar, environment.isEnabled { + DispatchQueue.main.async { + if let isFocused, uiView.window != nil { + uiView.isFirstResponderBinding = isFocused - if isFocused.wrappedValue && !uiView.isFirstResponder { - uiView.becomeFirstResponder() - } else if !isFocused.wrappedValue && uiView.isFirstResponder { - uiView.resignFirstResponder() + if isFocused.wrappedValue, !uiView.isFirstResponder { + uiView.becomeFirstResponder() + } else if !isFocused.wrappedValue, uiView.isFirstResponder { + uiView.resignFirstResponder() + } } } } } } - } - public class Coordinator: NSObject, UISearchBarDelegate { - var base: SearchBar + public class Coordinator: NSObject, UISearchBarDelegate { + var base: SearchBar - init(base: SearchBar) { - self.base = base - } + init(base: SearchBar) { + self.base = base + } - public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - base.isFocused?.removeDuplicates().wrappedValue = true + public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + base.isFocused?.removeDuplicates().wrappedValue = true - base.onEditingChanged(true) - } + base.onEditingChanged(true) + } - public func searchBar(_ searchBar: UIViewType, textDidChange searchText: String) { - base.text = searchText - } + public func searchBar(_ searchBar: UIViewType, textDidChange searchText: String) { + base.text = searchText + } - public func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { - return true - } + public func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { + true + } - public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { - base.isFocused?.removeDuplicates().wrappedValue = false + public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + base.isFocused?.removeDuplicates().wrappedValue = false - base.onEditingChanged(false) - } + base.onEditingChanged(false) + } - public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - searchBar.endEditing(true) + public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.endEditing(true) - base.isFocused?.removeDuplicates().wrappedValue = false + base.isFocused?.removeDuplicates().wrappedValue = false - base.onCancel() - } + base.onCancel() + } - public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - searchBar.endEditing(true) + public func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.endEditing(true) - // base.isFocused?.removeDuplicates().wrappedValue = false + // base.isFocused?.removeDuplicates().wrappedValue = false - base.onCommit() + base.onCommit() + } } - } - public func makeCoordinator() -> Coordinator { - Coordinator(base: self) + public func makeCoordinator() -> Coordinator { + Coordinator(base: self) + } } -} -// MARK: - API + // MARK: - API -extension SearchBar { - @available(macCatalystApplicationExtension, unavailable) - @available(iOSApplicationExtension, unavailable) - @available(tvOSApplicationExtension, unavailable) - public func isInitialFirstResponder(_ isInitialFirstResponder: Bool) -> Self { - then({ $0.isInitialFirstResponder = isInitialFirstResponder }) + public extension SearchBar { + @available(macCatalystApplicationExtension, unavailable) + @available(iOSApplicationExtension, unavailable) + @available(tvOSApplicationExtension, unavailable) + func isInitialFirstResponder(_ isInitialFirstResponder: Bool) -> Self { + then { $0.isInitialFirstResponder = isInitialFirstResponder } + } + + @available(macCatalystApplicationExtension, unavailable) + @available(iOSApplicationExtension, unavailable) + @available(tvOSApplicationExtension, unavailable) + func focused(_ isFocused: Binding) -> Self { + then { $0.isFocused = isFocused } + } } @available(macCatalystApplicationExtension, unavailable) @available(iOSApplicationExtension, unavailable) @available(tvOSApplicationExtension, unavailable) - public func focused(_ isFocused: Binding) -> Self { - then({ $0.isFocused = isFocused }) - } -} - -@available(macCatalystApplicationExtension, unavailable) -@available(iOSApplicationExtension, unavailable) -@available(tvOSApplicationExtension, unavailable) -extension SearchBar { - #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) - public func placeholder(_ placeholder: String?) -> Self { - then({ $0.placeholder = placeholder }) - } - #endif + public extension SearchBar { + #if os(iOS) || os(macOS) || targetEnvironment(macCatalyst) + func placeholder(_ placeholder: String?) -> Self { + then { $0.placeholder = placeholder } + } + #endif - public func showsCancelButton(_ showsCancelButton: Bool) -> Self { - then({ $0.showsCancelButton = showsCancelButton }) - } + func showsCancelButton(_ showsCancelButton: Bool) -> Self { + then { $0.showsCancelButton = showsCancelButton } + } - public func onCancel(perform action: @escaping () -> Void) -> Self { - then({ $0.onCancel = action }) - } + func onCancel(perform action: @escaping () -> Void) -> Self { + then { $0.onCancel = action } + } - public func returnKeyType(_ returnKeyType: UIReturnKeyType) -> Self { - then({ $0.returnKeyType = returnKeyType }) - } + func returnKeyType(_ returnKeyType: UIReturnKeyType) -> Self { + then { $0.returnKeyType = returnKeyType } + } - public func enablesReturnKeyAutomatically(_ enablesReturnKeyAutomatically: Bool) -> Self { - then({ $0.enablesReturnKeyAutomatically = enablesReturnKeyAutomatically }) - } + func enablesReturnKeyAutomatically(_ enablesReturnKeyAutomatically: Bool) -> Self { + then { $0.enablesReturnKeyAutomatically = enablesReturnKeyAutomatically } + } - public func textContentType(_ textContentType: UITextContentType?) -> Self { - then({ $0.textContentType = textContentType }) - } + func textContentType(_ textContentType: UITextContentType?) -> Self { + then { $0.textContentType = textContentType } + } - public func keyboardType(_ keyboardType: UIKeyboardType) -> Self { - then({ $0.keyboardType = keyboardType }) + func keyboardType(_ keyboardType: UIKeyboardType) -> Self { + then { $0.keyboardType = keyboardType } + } } -} -// MARK: - Auxiliary + // MARK: - Auxiliary -#if os(iOS) || targetEnvironment(macCatalyst) -private final class _UISearchBar: UISearchBar { - var isFirstResponderBinding: Binding? + #if os(iOS) || targetEnvironment(macCatalyst) + private final class _UISearchBar: UISearchBar { + var isFirstResponderBinding: Binding? - override init(frame: CGRect) { - super.init(frame: frame) - } + override init(frame: CGRect) { + super.init(frame: frame) + } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - @discardableResult - override func becomeFirstResponder() -> Bool { - let result = super.becomeFirstResponder() + @discardableResult + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() - isFirstResponderBinding?.wrappedValue = result + isFirstResponderBinding?.wrappedValue = result - return result - } + return result + } - @discardableResult - override func resignFirstResponder() -> Bool { - let result = super.resignFirstResponder() + @discardableResult + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() - isFirstResponderBinding?.wrappedValue = !result + isFirstResponderBinding?.wrappedValue = !result - return result - } -} -#endif + return result + } + } + #endif -// MARK: - Development Preview - + // MARK: - Development Preview - -#if (os(iOS) && canImport(CoreTelephony)) || targetEnvironment(macCatalyst) -@available(macCatalystApplicationExtension, unavailable) -@available(iOSApplicationExtension, unavailable) -@available(tvOSApplicationExtension, unavailable) -struct SearchBar_Previews: PreviewProvider { - static var previews: some View { - SearchBar("Search...", text: .constant("")) - } -} -#endif + #if (os(iOS) && canImport(CoreTelephony)) || targetEnvironment(macCatalyst) + @available(macCatalystApplicationExtension, unavailable) + @available(iOSApplicationExtension, unavailable) + @available(tvOSApplicationExtension, unavailable) + struct SearchBar_Previews: PreviewProvider { + static var previews: some View { + SearchBar("Search...", text: .constant("")) + } + } + #endif #endif diff --git a/Mlem/Views/Tabs/Feeds/Feed View.swift b/Mlem/Views/Tabs/Feeds/Feed View.swift index c9e579e6b..eab3d79ed 100644 --- a/Mlem/Views/Tabs/Feeds/Feed View.swift +++ b/Mlem/Views/Tabs/Feeds/Feed View.swift @@ -136,7 +136,8 @@ struct FeedView: View { feedPost(for: post) } - EndOfFeedView(isLoading: isLoading && postTracker.page > 1) + // TODO: update to use proper LoadingState + EndOfFeedView(loadingState: isLoading && postTracker.page > 1 ? .loading : .done, viewType: .hobbit) } } } @@ -152,7 +153,7 @@ struct FeedView: View { @ViewBuilder private func noPostsView() -> some View { VStack { - if let errorDetails = errorDetails { + if let errorDetails { ErrorView(errorDetails) .frame(maxWidth: .infinity) } else if isLoading { diff --git a/Mlem/Views/Tabs/Inbox/Feed/All Items Feed View.swift b/Mlem/Views/Tabs/Inbox/Feed/AllItemsFeedView.swift similarity index 51% rename from Mlem/Views/Tabs/Inbox/Feed/All Items Feed View.swift rename to Mlem/Views/Tabs/Inbox/Feed/AllItemsFeedView.swift index b8f799822..adc62f39d 100644 --- a/Mlem/Views/Tabs/Inbox/Feed/All Items Feed View.swift +++ b/Mlem/Views/Tabs/Inbox/Feed/AllItemsFeedView.swift @@ -1,5 +1,5 @@ // -// All Items Feed View.swift +// AllItemsFeedView.swift // Mlem // // Created by Eric Andrews on 2023-06-26. @@ -8,13 +8,14 @@ import Foundation import SwiftUI -extension InboxView { - @ViewBuilder - func inboxFeedView() -> some View { +struct AllItemsFeedView: View { + @ObservedObject var inboxTracker: ParentTracker + + var body: some View { Group { - if allItems.isEmpty, isLoading { + if inboxTracker.items.isEmpty, inboxTracker.loadingState == .loading { LoadingView(whatIsLoading: .inbox) - } else if allItems.isEmpty { + } else if inboxTracker.items.isEmpty { noItemsView() } else { LazyVStack(spacing: 0) { @@ -39,21 +40,31 @@ extension InboxView { // delete it, recompile, paste it, and it should work. Go figure. @ViewBuilder func inboxListView() -> some View { - ForEach(allItems) { item in + ForEach(inboxTracker.items, id: \.uid) { item in VStack(spacing: 0) { - Group { - switch item.type { - case let .mention(mention): - inboxMentionViewWithInteraction(mention: mention) - case let .message(message): - inboxMessageViewWithInteraction(message: message) - case let .reply(reply): - inboxReplyViewWithInteraction(reply: reply) - } - } + 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/InteractionSwipeAndMenuHelpers.swift b/Mlem/Views/Tabs/Inbox/Feed/InteractionSwipeAndMenuHelpers.swift deleted file mode 100644 index f2a12d722..000000000 --- a/Mlem/Views/Tabs/Inbox/Feed/InteractionSwipeAndMenuHelpers.swift +++ /dev/null @@ -1,348 +0,0 @@ -// -// Messages Feed View Logic.swift -// Mlem -// -// Created by Eric Andrews on 2023-07-02. -// - -import Foundation - -extension InboxView { - // MARK: Replies - - - func upvoteCommentReplySwipeAction(commentReply: APICommentReplyView) -> SwipeAction { - let (emptySymbolName, fullSymbolName) = commentReply.myVote == .upvote ? - (Icons.resetVoteSquare, Icons.resetVoteSquareFill) : - (Icons.upvoteSquare, Icons.upvoteSquareFill) - return SwipeAction( - symbol: .init(emptyName: emptySymbolName, fillName: fullSymbolName), - color: .upvoteColor - ) { - voteOnCommentReply(commentReply: commentReply, inputOp: .upvote) - } - } - - func downvoteCommentReplySwipeAction(commentReply: APICommentReplyView) -> SwipeAction { - let (emptySymbolName, fullSymbolName) = commentReply.myVote == .downvote ? - (Icons.resetVoteSquare, Icons.resetVoteSquareFill) : - (Icons.downvoteSquare, Icons.downvoteSquareFill) - return SwipeAction( - symbol: .init(emptyName: emptySymbolName, fillName: fullSymbolName), - color: .downvoteColor - ) { - voteOnCommentReply(commentReply: commentReply, inputOp: .downvote) - } - } - - func toggleCommentReplyReadSwipeAction(commentReply: APICommentReplyView) -> SwipeAction { - let (emptySymbolName, fullSymbolName) = commentReply.commentReply.read ? - (Icons.markUnread, Icons.markUnreadFill) : - (Icons.markRead, Icons.markReadFill) - return SwipeAction( - symbol: .init(emptyName: emptySymbolName, fillName: fullSymbolName), - color: .purple - ) { - toggleCommentReplyRead(commentReplyView: commentReply) - } - } - - func replyToCommentReplySwipeAction(commentReply: APICommentReplyView) -> SwipeAction? { - SwipeAction( - symbol: .init(emptyName: Icons.reply, fillName: Icons.replyFill), - color: .accentColor - ) { - replyToCommentReply(commentReply: commentReply) - } - } - - // swiftlint:disable function_body_length - func genCommentReplyMenuGroup(commentReply: APICommentReplyView) -> [MenuFunction] { - var ret: [MenuFunction] = .init() - - // upvote - let (upvoteText, upvoteImg) = commentReply.myVote == .upvote ? - ("Undo upvote", "arrow.up.square.fill") : - ("Upvote", "arrow.up.square") - ret.append(MenuFunction.standardMenuFunction(text: upvoteText, imageName: upvoteImg, destructiveActionPrompt: nil, enabled: true) { - voteOnCommentReply(commentReply: commentReply, inputOp: .upvote) - }) - - // downvote - let (downvoteText, downvoteImg) = commentReply.myVote == .downvote ? - ("Undo downvote", "arrow.down.square.fill") : - ("Downvote", "arrow.down.square") - ret.append(MenuFunction.standardMenuFunction( - text: downvoteText, - imageName: downvoteImg, - destructiveActionPrompt: nil, - enabled: true - ) { - voteOnCommentReply(commentReply: commentReply, inputOp: .downvote) - }) - - // mark read - let (markReadText, markReadImg) = commentReply.commentReply.read ? - ("Mark unread", "envelope.fill") : - ("Mark read", "envelope.open") - ret.append(MenuFunction.standardMenuFunction( - text: markReadText, - imageName: markReadImg, - destructiveActionPrompt: nil, - enabled: true - ) { - toggleCommentReplyRead(commentReplyView: commentReply) - }) - - // reply - ret.append(MenuFunction.standardMenuFunction( - text: "Reply", - imageName: "arrowshape.turn.up.left", - destructiveActionPrompt: nil, - enabled: true - ) { - replyToCommentReply(commentReply: commentReply) - }) - - // report - ret.append(MenuFunction.standardMenuFunction( - text: "Report Comment", - imageName: Icons.moderationReport, - destructiveActionPrompt: nil, - enabled: true - ) { - reportCommentReply(commentReply: commentReply) - }) - - // block - ret.append(MenuFunction.standardMenuFunction( - text: "Block User", - imageName: Icons.userBlock, - destructiveActionPrompt: AppConstants.blockUserPrompt, - enabled: true - ) { - Task(priority: .userInitiated) { - await blockUser(userId: commentReply.creator.id) - } - }) - - return ret - } - - // swiftlint:enable function_body_length - - // MARK: Mentions - - - func upvoteMentionSwipeAction(mentionView: APIPersonMentionView) -> SwipeAction { - let (emptySymbolName, fullSymbolName) = mentionView.myVote == .upvote ? - (Icons.resetVoteSquare, Icons.resetVoteSquareFill) : - (Icons.upvoteSquare, Icons.upvoteSquareFill) - return SwipeAction( - symbol: .init(emptyName: emptySymbolName, fillName: fullSymbolName), - color: .upvoteColor - ) { - voteOnMention(mention: mentionView, inputOp: .upvote) - } - } - - func downvoteMentionSwipeAction(mentionView: APIPersonMentionView) -> SwipeAction { - let (emptySymbolName, fullSymbolName) = mentionView.myVote == .downvote ? - (Icons.resetVoteSquare, Icons.resetVoteSquareFill) : - (Icons.downvoteSquare, Icons.downvoteSquareFill) - return SwipeAction( - symbol: .init(emptyName: emptySymbolName, fillName: fullSymbolName), - color: .downvoteColor - ) { - voteOnMention(mention: mentionView, inputOp: .downvote) - } - } - - func toggleMentionReadSwipeAction(mentionView: APIPersonMentionView) -> SwipeAction { - let (emptySymbolName, fullSymbolName) = mentionView.personMention.read ? - (Icons.markUnread, Icons.markUnreadFill) : - (Icons.markRead, Icons.markReadFill) - return SwipeAction( - symbol: .init(emptyName: emptySymbolName, fillName: fullSymbolName), - color: .purple - ) { - toggleMentionRead(mention: mentionView) - } - } - - func replyToMentionSwipeAction(mentionView: APIPersonMentionView) -> SwipeAction? { - SwipeAction( - symbol: .init(emptyName: Icons.reply, fillName: Icons.replyFill), - color: .accentColor - ) { - replyToMention(mention: mentionView) - } - } - - // swiftlint:disable function_body_length - func genMentionMenuGroup(mention: APIPersonMentionView) -> [MenuFunction] { - var ret: [MenuFunction] = .init() - - // upvote - let (upvoteText, upvoteImg) = mention.myVote == .upvote ? - ("Undo upvote", "arrow.up.square.fill") : - ("Upvote", "arrow.up.square") - ret.append(MenuFunction.standardMenuFunction(text: upvoteText, imageName: upvoteImg, destructiveActionPrompt: nil, enabled: true) { - voteOnMention(mention: mention, inputOp: .upvote) - }) - - // downvote - let (downvoteText, downvoteImg) = mention.myVote == .downvote ? - ("Undo downvote", "arrow.down.square.fill") : - ("Downvote", "arrow.down.square") - ret.append(MenuFunction.standardMenuFunction( - text: downvoteText, - imageName: downvoteImg, - destructiveActionPrompt: nil, - enabled: true - ) { - voteOnMention(mention: mention, inputOp: .downvote) - }) - - // mark read - let (markReadText, markReadImg) = mention.personMention.read ? - ("Mark unread", "envelope.fill") : - ("Mark read", "envelope.open") - ret.append(MenuFunction.standardMenuFunction( - text: markReadText, - imageName: markReadImg, - destructiveActionPrompt: nil, - enabled: true - ) { - toggleMentionRead(mention: mention) - }) - - // reply - ret.append(MenuFunction.standardMenuFunction( - text: "Reply", - imageName: "arrowshape.turn.up.left", - destructiveActionPrompt: nil, - enabled: true - ) { - replyToMention(mention: mention) - }) - - // report - ret.append(MenuFunction.standardMenuFunction( - text: "Report Comment", - imageName: Icons.moderationReport, - destructiveActionPrompt: nil, - enabled: true - ) { - reportMention(mention: mention) - }) - - // block - ret.append(MenuFunction.standardMenuFunction( - text: "Block User", - imageName: Icons.userBlock, - destructiveActionPrompt: AppConstants.blockUserPrompt, - enabled: true - ) { - Task(priority: .userInitiated) { - await blockUser(userId: mention.creator.id) - } - }) - - return ret - } - - // swiftlint:enable function_body_length - - // MARK: Messages - - - func toggleMessageReadSwipeAction(message: APIPrivateMessageView) -> SwipeAction { - let (emptySymbolName, fullSymbolName) = message.privateMessage.read ? - (Icons.markUnread, Icons.markUnreadFill) : - (Icons.markRead, Icons.markReadFill) - return SwipeAction( - symbol: .init(emptyName: emptySymbolName, fillName: fullSymbolName), - color: .purple - ) { - toggleMessageRead(message: message) - } - } - - func replyToMessageSwipeAction(message: APIPrivateMessageView) -> SwipeAction { - SwipeAction( - symbol: .init(emptyName: Icons.reply, fillName: Icons.replyFill), - color: .accentColor - ) { - replyToMessage(message: message) - } - } - - func genMessageMenuGroup(message: APIPrivateMessageView) -> [MenuFunction] { - var ret: [MenuFunction] = .init() - - // mark read - let (markReadText, markReadImg) = message.privateMessage.read ? - ("Mark unread", "envelope.fill") : - ("Mark read", "envelope.open") - ret.append(MenuFunction.standardMenuFunction( - text: markReadText, - imageName: markReadImg, - destructiveActionPrompt: nil, - enabled: true - ) { - toggleMessageRead(message: message) - }) - - // reply - ret.append(MenuFunction.standardMenuFunction( - text: "Reply", - imageName: "arrowshape.turn.up.left", - destructiveActionPrompt: nil, - enabled: true - ) { - replyToMessage(message: message) - }) - - // report - ret.append(MenuFunction.standardMenuFunction( - text: "Report Message", - imageName: Icons.moderationReport, - destructiveActionPrompt: nil, - enabled: true - ) { - reportMessage(message: message) - }) - - // block - ret.append(MenuFunction.standardMenuFunction( - text: "Block User", - imageName: Icons.userBlock, - destructiveActionPrompt: AppConstants.blockUserPrompt, - enabled: true - ) { - Task(priority: .userInitiated) { - await blockUser(userId: message.creator.id) - } - }) - - return ret - } - - func blockUser(userId: Int) async { - do { - let response = try await apiClient.blockPerson(id: userId, shouldBlock: true) - - if response.blocked { - hapticManager.play(haptic: .violentSuccess, priority: .high) - await notifier.add(.success("Blocked user")) - filterUser(userId: userId) - } - } catch { - errorHandler.handle( - .init( - message: "Unable to block user", - style: .toast, - underlyingError: error - ) - ) - } - } -} diff --git a/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Mention View.swift b/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Mention View.swift index 73d37d6c3..d580f171d 100644 --- a/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Mention View.swift +++ b/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Mention View.swift @@ -8,39 +8,41 @@ import SwiftUI struct InboxMentionView: View { - let spacing: CGFloat = 10 - let userAvatarWidth: CGFloat = 30 - - let mention: APIPersonMentionView - let menuFunctions: [MenuFunction] - - let voteIconName: String - let voteColor: Color + @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" } - init(mention: APIPersonMentionView, menuFunctions: [MenuFunction]) { - self.mention = mention - self.menuFunctions = menuFunctions - - switch mention.myVote { - case .upvote: - self.voteIconName = Icons.upvote - self.voteColor = .upvoteColor - case .downvote: - self.voteIconName = Icons.downvote - self.voteColor = .downvoteColor - default: - self.voteIconName = Icons.upvote - self.voteColor = .secondary + var body: some View { + NavigationLink(.lazyLoadPostLinkWithContext(.init( + post: mention.post, + scrollTarget: mention.comment.id + ))) { + content + .padding(AppConstants.postAndCommentSpacing) + .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, confirmDestructive: nil) + } + } } + .buttonStyle(EmptyButtonStyle()) } - var body: some View { - VStack(alignment: .leading, spacing: spacing) { + var content: some View { + VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { Text(mention.post.name) .font(.headline) - .padding(.bottom, spacing) + .padding(.bottom, AppConstants.postAndCommentSpacing) UserLinkView( person: mention.creator, @@ -49,10 +51,10 @@ struct InboxMentionView: View { ) .font(.subheadline) - HStack(alignment: .top, spacing: spacing) { + HStack(alignment: .top, spacing: AppConstants.postAndCommentSpacing) { Image(systemName: iconName) .foregroundColor(.accentColor) - .frame(width: userAvatarWidth) + .frame(width: AppConstants.largeAvatarSize) MarkdownView(text: mention.comment.content, isNsfw: false) .font(.subheadline) @@ -63,18 +65,24 @@ struct InboxMentionView: View { HStack { HStack(spacing: 4) { Image(systemName: voteIconName) - Text(mention.counts.score.description) + Text(mention.votes.total.description) + } + .foregroundColor(mention.votes.myVote.color ?? .secondary) + .onTapGesture { + Task(priority: .userInitiated) { + await mention.vote(inputOp: .upvote, unreadTracker: unreadTracker) + } } - .foregroundColor(voteColor) - EllipsisMenu(size: userAvatarWidth, menuFunctions: menuFunctions) + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: mention.menuFunctions(unreadTracker: unreadTracker, editorTracker: editorTracker) + ) Spacer() PublishedTimestampView(date: mention.comment.published) } } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) } } diff --git a/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Message View.swift b/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Message View.swift index 4592285d3..7b6897960 100644 --- a/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Message View.swift +++ b/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Message View.swift @@ -8,29 +8,43 @@ import SwiftUI struct InboxMessageView: View { - let spacing: CGFloat = 10 - let userAvatarWidth: CGFloat = 30 - - let message: APIPrivateMessageView - let menuFunctions: [MenuFunction] + @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: APIPrivateMessageView, menuFunctions: [MenuFunction]) { + init(message: MessageModel) { self.message = message - self.menuFunctions = menuFunctions } var body: some View { - VStack(alignment: .leading, spacing: spacing) { + content + .padding(AppConstants.postAndCommentSpacing) + .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, confirmDestructive: nil) + } + } + } + + var content: some View { + VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { Text("Direct message") .font(.headline.smallCaps()) - .padding(.bottom, spacing) + .padding(.bottom, AppConstants.postAndCommentSpacing) - HStack(alignment: .top, spacing: spacing) { + HStack(alignment: .top, spacing: AppConstants.postAndCommentSpacing) { Image(systemName: iconName) .foregroundColor(.accentColor) - .frame(height: userAvatarWidth) + .frame(width: AppConstants.largeAvatarSize, height: AppConstants.largeAvatarSize) MarkdownView(text: message.privateMessage.content, isNsfw: false) .font(.subheadline) @@ -44,14 +58,18 @@ struct InboxMessageView: View { .font(.subheadline) HStack { - EllipsisMenu(size: userAvatarWidth, menuFunctions: menuFunctions) + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: message.menuFunctions( + unreadTracker: unreadTracker, + editorTracker: editorTracker + ) + ) Spacer() PublishedTimestampView(date: message.privateMessage.published) } } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) } } diff --git a/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Reply View.swift b/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Reply View.swift index 28c623d92..c967e8076 100644 --- a/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Reply View.swift +++ b/Mlem/Views/Tabs/Inbox/Feed/Item Types/Inbox Reply View.swift @@ -10,47 +10,49 @@ import SwiftUI // /user/replies struct InboxReplyView: View { - let spacing: CGFloat = 10 - let userAvatarWidth: CGFloat = 30 - - let reply: APICommentReplyView - let menuFunctions: [MenuFunction] - - let voteIconName: String - let voteColor: Color + @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" } - init(reply: APICommentReplyView, menuFunctions: [MenuFunction]) { - self.reply = reply - self.menuFunctions = menuFunctions - - switch reply.myVote { - case .upvote: - self.voteIconName = Icons.upvote - self.voteColor = .upvoteColor - case .downvote: - self.voteIconName = Icons.downvote - self.voteColor = .downvoteColor - default: - self.voteIconName = Icons.upvote - self.voteColor = .secondary + var body: some View { + NavigationLink(.lazyLoadPostLinkWithContext(.init( + post: reply.post, + scrollTarget: reply.comment.id + ))) { + content + .padding(AppConstants.postAndCommentSpacing) + .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, confirmDestructive: nil) + } + } } + .buttonStyle(EmptyButtonStyle()) } - var body: some View { - VStack(alignment: .leading, spacing: spacing) { + var content: some View { + VStack(alignment: .leading, spacing: AppConstants.postAndCommentSpacing) { Text(reply.post.name) .font(.headline) - .padding(.bottom, spacing) + .padding(.bottom, AppConstants.postAndCommentSpacing) UserLinkView(person: reply.creator, serverInstanceLocation: ServerInstanceLocation.bottom, overrideShowAvatar: true) .font(.subheadline) - HStack(alignment: .top, spacing: spacing) { + HStack(alignment: .top, spacing: AppConstants.postAndCommentSpacing) { Image(systemName: iconName) .foregroundColor(.accentColor) - .frame(width: userAvatarWidth) + .frame(width: AppConstants.largeAvatarSize) MarkdownView(text: reply.comment.content, isNsfw: false) .font(.subheadline) @@ -61,18 +63,24 @@ struct InboxReplyView: View { HStack { HStack(spacing: 4) { Image(systemName: voteIconName) - Text(reply.counts.score.description) + Text(reply.votes.total.description) + } + .foregroundColor(reply.votes.myVote.color ?? .secondary) + .onTapGesture { + Task(priority: .userInitiated) { + await reply.vote(inputOp: .upvote, unreadTracker: unreadTracker) + } } - .foregroundColor(voteColor) - EllipsisMenu(size: userAvatarWidth, menuFunctions: menuFunctions) + EllipsisMenu( + size: AppConstants.largeAvatarSize, + menuFunctions: reply.menuFunctions(unreadTracker: unreadTracker, editorTracker: editorTracker) + ) Spacer() PublishedTimestampView(date: reply.commentReply.published) } } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) } } diff --git a/Mlem/Views/Tabs/Inbox/Feed/Mentions Feed View.swift b/Mlem/Views/Tabs/Inbox/Feed/Mentions Feed View.swift index db1221f60..df440d8af 100644 --- a/Mlem/Views/Tabs/Inbox/Feed/Mentions Feed View.swift +++ b/Mlem/Views/Tabs/Inbox/Feed/Mentions Feed View.swift @@ -8,23 +8,15 @@ import Foundation import SwiftUI -extension InboxView { - @ViewBuilder - func mentionsFeedView() -> some View { - Group { - if mentionsTracker.items.isEmpty, !mentionsTracker.isLoading { - noMentionsView() - } else { - LazyVStack(spacing: 0) { - mentionsListView() - - if mentionsTracker.isLoading { - LoadingView(whatIsLoading: .mentions) - } else { - // this isn't just cute--if it's not here we get weird bouncing behavior if we get here, load, and then there's nothing - Text("That's all!").foregroundColor(.secondary).padding(.vertical, AppConstants.postAndCommentSpacing) - } - } +struct MentionsFeedView: View { + @ObservedObject var mentionTracker: MentionTracker + + var body: some View { + if mentionTracker.loadingState == .done, mentionTracker.items.isEmpty { + noMentionsView() + } else { + LazyVStack(spacing: 0) { + mentionsListView() } } } @@ -42,44 +34,17 @@ extension InboxView { @ViewBuilder func mentionsListView() -> some View { - ForEach(mentionsTracker.items) { mention in + ForEach(mentionTracker.items, id: \.uid) { mention in VStack(spacing: 0) { - inboxMentionViewWithInteraction(mention: mention) + InboxMentionView(mention: mention) + .onAppear { + mentionTracker.loadIfThreshold(mention) + } + Divider() } } - } - - func inboxMentionViewWithInteraction(mention: APIPersonMentionView) -> some View { - NavigationLink(.lazyLoadPostLinkWithContext(.init( - post: mention.post, - scrollTarget: mention.comment.id - ))) { - InboxMentionView(mention: mention, menuFunctions: genMentionMenuGroup(mention: mention)) - .padding(.vertical, AppConstants.postAndCommentSpacing) - .padding(.horizontal) - .background(Color.systemBackground) - .task { - if mentionsTracker.shouldLoadContent(after: mention) { - await loadTrackerPage(tracker: mentionsTracker) - } - } - .addSwipeyActions( - leading: [ - upvoteMentionSwipeAction(mentionView: mention), - downvoteMentionSwipeAction(mentionView: mention) - ], - trailing: [ - toggleMentionReadSwipeAction(mentionView: mention), - replyToMentionSwipeAction(mentionView: mention) - ] - ) - .contextMenu { - ForEach(genMentionMenuGroup(mention: mention)) { item in - MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) - } - } - } - .buttonStyle(EmptyButtonStyle()) + + 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 index 4f4f9a1de..d4d7c3a37 100644 --- a/Mlem/Views/Tabs/Inbox/Feed/Messages Feed View.swift +++ b/Mlem/Views/Tabs/Inbox/Feed/Messages Feed View.swift @@ -1,5 +1,5 @@ // -// Private Messages View.swift +// MessagesFeedView.swift // Mlem // // Created by Eric Andrews on 2023-06-26. @@ -8,23 +8,15 @@ import Foundation import SwiftUI -extension InboxView { - @ViewBuilder - func messagesFeedView() -> some View { - Group { - if messagesTracker.items.isEmpty, !messagesTracker.isLoading { - noMessagesView() - } else { - LazyVStack(spacing: 0) { - messagesListView() - - if messagesTracker.isLoading { - LoadingView(whatIsLoading: .messages) - } else { - // this isn't just cute--if it's not here we get weird bouncing behavior if we get here, load, and then there's nothing - Text("That's all!").foregroundColor(.secondary).padding(.vertical, AppConstants.postAndCommentSpacing) - } - } +struct MessagesFeedView: View { + @ObservedObject var messageTracker: MessageTracker + + var body: some View { + if messageTracker.loadingState == .done, messageTracker.items.isEmpty { + noMessagesView() + } else { + LazyVStack(spacing: 0) { + messagesListView() } } } @@ -42,37 +34,17 @@ extension InboxView { @ViewBuilder func messagesListView() -> some View { - ForEach(messagesTracker.items) { message in + ForEach(messageTracker.items, id: \.uid) { message in VStack(spacing: 0) { - inboxMessageViewWithInteraction(message: message) + InboxMessageView(message: message) + .onAppear { + messageTracker.loadIfThreshold(message) + } Divider() } } - } - - @ViewBuilder - func inboxMessageViewWithInteraction(message: APIPrivateMessageView) -> some View { - InboxMessageView(message: message, menuFunctions: genMessageMenuGroup(message: message)) - .padding(.vertical, AppConstants.postAndCommentSpacing) - .padding(.horizontal) - .background(Color.systemBackground) - .task { - if messagesTracker.shouldLoadContent(after: message) { - await loadTrackerPage(tracker: messagesTracker) - } - } - .addSwipeyActions( - leading: [], - trailing: [ - toggleMessageReadSwipeAction(message: message), - replyToMessageSwipeAction(message: message) - ] - ) - .contextMenu { - ForEach(genMessageMenuGroup(message: message)) { item in - MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) - } - } + + 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 index 950e5f9da..daafbef80 100644 --- a/Mlem/Views/Tabs/Inbox/Feed/Replies Feed View.swift +++ b/Mlem/Views/Tabs/Inbox/Feed/Replies Feed View.swift @@ -8,27 +8,15 @@ import Foundation import SwiftUI -extension InboxView { - @ViewBuilder - func repliesFeedView() -> some View { - Group { - if repliesTracker.items.isEmpty { - if repliesTracker.isLoading { - LoadingView(whatIsLoading: .replies) - } else { - noRepliesView() - } - } else { - LazyVStack(spacing: 0) { - repliesListView() - - if repliesTracker.isLoading { - LoadingView(whatIsLoading: .replies) - } else { - // this isn't just cute--if it's not here we get weird bouncing behavior if we get here, load, and then there's nothing - Text("That's all!").foregroundColor(.secondary).padding(.vertical, AppConstants.postAndCommentSpacing) - } - } +struct RepliesFeedView: View { + @ObservedObject var replyTracker: ReplyTracker + + var body: some View { + if replyTracker.loadingState == .done, replyTracker.items.isEmpty { + noRepliesView() + } else { + LazyVStack(spacing: 0) { + repliesListView() } } } @@ -46,49 +34,17 @@ extension InboxView { @ViewBuilder func repliesListView() -> some View { - ForEach(repliesTracker.items) { reply in + ForEach(replyTracker.items, id: \.uid) { reply in VStack(spacing: 0) { - inboxReplyViewWithInteraction(reply: reply) + InboxReplyView(reply: reply) + .onAppear { + replyTracker.loadIfThreshold(reply) + } Divider() } } - } - - func inboxReplyViewWithInteraction(reply: APICommentReplyView) -> some View { - NavigationLink(.lazyLoadPostLinkWithContext(.init( - post: reply.post, - scrollTarget: reply.comment.id - ))) { - InboxReplyView(reply: reply, menuFunctions: genCommentReplyMenuGroup(commentReply: reply)) - .padding(.vertical, AppConstants.postAndCommentSpacing) - .padding(.horizontal) - .background(Color.systemBackground) - .task { - if repliesTracker.shouldLoadContent(after: reply) { - await loadTrackerPage(tracker: repliesTracker) - } - } - .destructiveConfirmation( - isPresentingConfirmDestructive: $isPresentingConfirmDestructive, - confirmationMenuFunction: confirmationMenuFunction - ) - .addSwipeyActions( - leading: [ - upvoteCommentReplySwipeAction(commentReply: reply), - downvoteCommentReplySwipeAction(commentReply: reply) - ], - trailing: [ - toggleCommentReplyReadSwipeAction(commentReply: reply), - replyToCommentReplySwipeAction(commentReply: reply) - ] - ) - .contextMenu { - ForEach(genCommentReplyMenuGroup(commentReply: reply)) { item in - MenuButton(menuFunction: item, confirmDestructive: confirmDestructive) - } - } - } - .buttonStyle(EmptyButtonStyle()) + + EndOfFeedView(loadingState: replyTracker.loadingState, viewType: .cartoon) } } diff --git a/Mlem/Views/Tabs/Inbox/Inbox View Logic.swift b/Mlem/Views/Tabs/Inbox/Inbox View Logic.swift deleted file mode 100644 index 8b42af6c5..000000000 --- a/Mlem/Views/Tabs/Inbox/Inbox View Logic.swift +++ /dev/null @@ -1,397 +0,0 @@ -// -// Inbox Feed View Logic.swift -// Mlem -// -// Created by Eric Andrews on 2023-06-26. -// - -import Foundation - -extension InboxView { - // MARK: Tracker Updates - - func refreshFeed(clearBeforeFetch: Bool = false) async { - defer { isLoading = false } - do { - isLoading = true - - if clearBeforeFetch { - allItems = .init() - } - - // load feeds in parallel - async let repliesRefresh: () = refreshRepliesTracker() - async let mentionsRefresh: () = refreshMentionsTracker() - async let messagesRefresh: () = refreshMessagesTracker() - async let unreadRefresh: () = unreadTracker.update(with: personRepository.getUnreadCounts()) - - _ = try await [repliesRefresh, mentionsRefresh, messagesRefresh, unreadRefresh] - - errorOccurred = false - - if curTab == .all { - aggregateAllTrackers() - } - } catch APIClientError.networking { - errorOccurred = true - errorMessage = "Network error occurred, check your internet and retry" - } catch let APIClientError.response(message, _) { - print(message) - errorOccurred = true - errorMessage = "API error occurred, try refreshing" - } catch APIClientError.cancelled { - print("Failed while loading feed (request cancelled)") - errorOccurred = true - errorMessage = "Request was cancelled, try refreshing" - } catch APIClientError.invalidSession { - errorHandler.handle(APIClientError.invalidSession) - } catch let message { - print(message) - errorOccurred = true - errorMessage = "A decoding error occurred, try refreshing." - } - } - - // TODO: I think the refresh methods below need to account for when the show/hide unread value has changed - // as changing it while on another tab and then switching back does not refresh without the user doing - // pull down to refresh. - - func refreshRepliesTracker() async throws { - if curTab == .all || curTab == .replies { - try await repliesTracker.refresh() - } - } - - func refreshMentionsTracker() async throws { - if curTab == .all || curTab == .mentions { - try await mentionsTracker.refresh() - } - } - - func refreshMessagesTracker() async throws { - if curTab == .all || curTab == .messages { - try await messagesTracker.refresh() - } - } - - func filterUser(userId: Int) { - repliesTracker.filter { reply in - reply.creator.id != userId - } - mentionsTracker.filter { mention in - mention.creator.id != userId - } - messagesTracker.filter { message in - message.creator.id != userId - } - - aggregateAllTrackers() - } - - func filterRead() async { - shouldFilterRead.toggle() - await refreshFeed(clearBeforeFetch: true) - } - - func markAllAsRead() async { - do { - try await personRepository.markAllAsRead() - await refreshFeed() - } catch { - errorHandler.handle(error) - } - } - - func loadTrackerPage(tracker: any InboxTracker) async { - do { - try await tracker.loadNextPage() - aggregateAllTrackers() - // TODO: make that call above return the new items and do a nice neat merge sort that doesn't re-merge the whole damn array - } catch { - errorHandler.handle(error) - } - } - - func aggregateAllTrackers() { - let mentions = mentionsTracker.items.map { item in - InboxItem( - published: item.personMention.published, - baseId: item.id, - read: item.personMention.read, - type: .mention(item) - ) - } - - let messages = messagesTracker.items.map { item in - InboxItem( - published: item.privateMessage.published, - baseId: item.id, - read: item.privateMessage.read, - type: .message(item) - ) - } - - let replies = repliesTracker.items.map { item in - InboxItem( - published: item.commentReply.published, - baseId: item.id, - read: item.commentReply.read, - type: .reply(item) - ) - } - - allItems = merge(arr1: mentions, arr2: messages, compare: wasPostedAfter) - allItems = merge(arr1: allItems, arr2: replies, compare: wasPostedAfter) - isLoading = false - } - - // MARK: - Replies - - /// Marks a comment reply as read or unread. Has no effect if the requested read status is already the current read status - /// - Parameters: - /// - commentReplyView: commentReplyView to mark - /// - read: true to mark as read, false to mark as unread - func markCommentReplyRead(commentReplyView: APICommentReplyView, read: Bool) async { - // skip noop case - guard commentReplyView.commentReply.read != read else { - return - } - - do { - let response = try await commentRepository.markCommentReadStatus( - id: commentReplyView.id, - isRead: read - ) - - repliesTracker.update(with: response.commentReplyView) - - // TODO: should this be done _before_ the call, and then reverted in the `catch` if required? - // answer: it should be state faked in middleware - if commentReplyView.commentReply.read { - unreadTracker.unreadReply() - } else { - unreadTracker.readReply() - } - - if curTab == .all { aggregateAllTrackers() } - } catch { - hapticManager.play(haptic: .failure, priority: .low) - errorHandler.handle(error) - } - } - - func voteOnCommentReply(commentReply: APICommentReplyView, inputOp: ScoringOperation) { - Task(priority: .userInitiated) { - let operation = commentReply.myVote == inputOp ? ScoringOperation.resetVote : inputOp - do { - let updatedReply = try await commentRepository.voteOnCommentReply(commentReply, vote: operation) - repliesTracker.update(with: updatedReply) - - // note: this call performs tracker aggregation if needed - await markCommentReplyRead(commentReplyView: commentReply, read: true) - } catch { - errorHandler.handle(error) - } - } - } - - func toggleCommentReplyRead(commentReplyView: APICommentReplyView) { - hapticManager.play(haptic: .gentleSuccess, priority: .low) - Task(priority: .userInitiated) { - await markCommentReplyRead(commentReplyView: commentReplyView, read: !commentReplyView.commentReply.read) - } - } - - func replyToCommentReply(commentReply: APICommentReplyView) { - editorTracker.openEditor(with: ConcreteEditorModel( - commentReply: commentReply, - operation: InboxItemOperation.replyToInboxItem - )) - Task(priority: .background) { - await markCommentReplyRead(commentReplyView: commentReply, read: true) - } - } - - func reportCommentReply(commentReply: APICommentReplyView) { - editorTracker.openEditor(with: ConcreteEditorModel( - commentReply: commentReply, - operation: InboxItemOperation.reportInboxItem - )) - } - - // MARK: Mentions - - /// Marks a person mention as read or unread. Has no effect if the requested read status is already the current read status - /// - Parameters: - /// - mention: mention to mark - /// - read: true to mark as read, false to mark as unread - func markMentionRead(mention: APIPersonMentionView, read: Bool) async { - // skip noop case - guard mention.personMention.read != read else { - return - } - - do { - let updatedMention = try await apiClient.markPersonMentionAsRead( - mentionId: mention.personMention.id, - isRead: !mention.personMention.read - ) - - mentionsTracker.update(with: updatedMention) - - // TODO: should this be done before the above call and reverted in the catch if necessary? - if mention.personMention.read { - unreadTracker.unreadMention() - } else { - unreadTracker.readMention() - } - - if curTab == .all { aggregateAllTrackers() } - } catch { - hapticManager.play(haptic: .failure, priority: .high) - errorHandler.handle(error) - } - } - - func voteOnMention(mention: APIPersonMentionView, inputOp: ScoringOperation) { - hapticManager.play(haptic: .gentleSuccess, priority: .low) - Task(priority: .userInitiated) { - let operation = mention.myVote == inputOp ? ScoringOperation.resetVote : inputOp - do { - let updatedMention = try await commentRepository.voteOnPersonMention(mention, vote: operation) - mentionsTracker.update(with: updatedMention) - - // note: this call performs tracker aggregation if needed - await markMentionRead(mention: mention, read: true) - } catch { - hapticManager.play(haptic: .failure, priority: .high) - errorHandler.handle(error) - } - } - } - - func toggleMentionRead(mention: APIPersonMentionView) { - hapticManager.play(haptic: .gentleSuccess, priority: .low) - Task(priority: .userInitiated) { - await markMentionRead(mention: mention, read: !mention.personMention.read) - } - } - - func replyToMention(mention: APIPersonMentionView) { - editorTracker.openEditor(with: ConcreteEditorModel( - mention: mention, - operation: InboxItemOperation.replyToInboxItem - )) - Task(priority: .background) { - await markMentionRead(mention: mention, read: true) - } - } - - func reportMention(mention: APIPersonMentionView) { - editorTracker.openEditor(with: ConcreteEditorModel( - mention: mention, - operation: InboxItemOperation.reportInboxItem - )) - } - - // MARK: Messages - - func toggleMessageRead(message: APIPrivateMessageView) { - hapticManager.play(haptic: .gentleSuccess, priority: .low) - Task(priority: .userInitiated) { - do { - let updatedMessage = try await apiClient.markPrivateMessageRead( - id: message.id, - isRead: !message.privateMessage.read - ) - - messagesTracker.update(with: updatedMessage) - - // TODO: should this be done before the above call and reverted in the catch if necessary? - if message.privateMessage.read { - unreadTracker.unreadMessage() - } else { - unreadTracker.readMessage() - } - - if curTab == .all { aggregateAllTrackers() } - } catch { - hapticManager.play(haptic: .failure, priority: .low) - errorHandler.handle(error) - } - } - } - - func replyToMessage(message: APIPrivateMessageView) { - editorTracker.openEditor(with: ConcreteEditorModel( - message: message, - operation: InboxItemOperation.replyToInboxItem - )) - - // state fake the read message--replies mark messages as read automatically, but we don't get an object back from the editor to update, so we're doing it locally. This should be _way_ cleaner once we have middleware models for all this. - let updatedMessage = APIPrivateMessageView( - creator: message.creator, - recipient: message.recipient, - privateMessage: APIPrivateMessage( - id: message.privateMessage.id, - content: message.privateMessage.content, - creatorId: message.privateMessage.creatorId, - recipientId: message.privateMessage.recipientId, - local: message.privateMessage.local, - read: true, - updated: message.privateMessage.updated, - published: message.privateMessage.published, - deleted: message.privateMessage.deleted - ) - ) - messagesTracker.update(with: updatedMessage) - unreadTracker.readMessage() - if curTab == .all { aggregateAllTrackers() } - } - - func reportMessage(message: APIPrivateMessageView) { - editorTracker.openEditor(with: ConcreteEditorModel( - message: message, - operation: InboxItemOperation.reportInboxItem - )) - } - - // MARK: - Helpers - - /// returns true if lhs was posted after rhs - func wasPostedAfter(lhs: InboxItem, rhs: InboxItem) -> Bool { - lhs.published > rhs.published - } - - func genMenuFunctions() -> [MenuFunction] { - var ret: [MenuFunction] = .init() - - let (filterReadText, filterReadSymbol) = shouldFilterRead - ? ("Show All", Icons.filterFill) - : ("Show Only Unread", Icons.filter) - - ret.append(MenuFunction.standardMenuFunction( - text: filterReadText, - imageName: filterReadSymbol, - destructiveActionPrompt: nil, - enabled: true - ) { - Task(priority: .userInitiated) { - await filterRead() - } - }) - - ret.append(MenuFunction.standardMenuFunction( - text: "Mark All as Read", - imageName: "envelope.open", - destructiveActionPrompt: nil, - enabled: true - ) { - Task(priority: .userInitiated) { - await markAllAsRead() - } - }) - - return ret - } -} diff --git a/Mlem/Views/Tabs/Inbox/Inbox View.swift b/Mlem/Views/Tabs/Inbox/Inbox View.swift index 98ec97df7..ae3f702f6 100644 --- a/Mlem/Views/Tabs/Inbox/Inbox View.swift +++ b/Mlem/Views/Tabs/Inbox/Inbox View.swift @@ -19,14 +19,6 @@ enum InboxTab: String, CaseIterable, Identifiable { } } -enum ComposingTypes { - case commentReply(APICommentReplyView?) - case mention(APIPersonMentionView?) - case message(APIPerson?) -} - -// NOTE: -// all of the subordinate views are defined as functions in extensions because otherwise the tracker logic gets *ugly* struct InboxView: View { @Dependency(\.apiClient) var apiClient @Dependency(\.commentRepository) var commentRepository @@ -66,16 +58,41 @@ struct InboxView: View { @AppStorage("shouldFilterRead") var shouldFilterRead: Bool = false // item feeds - @State var allItems: [InboxItem] = .init() - @StateObject var mentionsTracker: MentionsTracker = .init() - @StateObject var messagesTracker: MessagesTracker = .init() - @StateObject var repliesTracker: RepliesTracker = .init() + @StateObject var inboxTracker: InboxTracker + @StateObject var replyTracker: ReplyTracker + @StateObject var mentionTracker: MentionTracker + @StateObject var messageTracker: MessageTracker @StateObject var dummyPostTracker: PostTracker // used for navigation init() { // TODO: once the post tracker is changed we won't need this here... @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) + self._dummyPostTracker = StateObject(wrappedValue: .init(internetSpeed: internetSpeed, upvoteOnSave: upvoteOnSave)) } @@ -98,6 +115,12 @@ struct InboxView: View { } .listStyle(PlainListStyle()) .handleLemmyViews() + .environmentObject(inboxTracker) + .onChange(of: shouldFilterRead) { newValue in + Task(priority: .userInitiated) { + await handleShouldFilterReadChange(newShouldFilterRead: newValue) + } + } .onChange(of: selectedTagHashValue) { newValue in if newValue == TabSelection.inbox.hashValue { print("switched to inbox tab") @@ -119,41 +142,38 @@ struct InboxView: View { } } .pickerStyle(.segmented) - .padding(.horizontal) + .padding(.horizontal, AppConstants.postAndCommentSpacing) + .padding(.top, AppConstants.postAndCommentSpacing) - ScrollView(showsIndicators: false) { + ScrollView { if errorOccurred { errorView() } else { switch curTab { case .all: - inboxFeedView() + AllItemsFeedView(inboxTracker: inboxTracker) case .replies: - repliesFeedView() + RepliesFeedView(replyTracker: replyTracker) case .mentions: - mentionsFeedView() + MentionsFeedView(mentionTracker: mentionTracker) case .messages: - messagesFeedView() + MessagesFeedView(messageTracker: messageTracker) } } } .fancyTabScrollCompatible() .refreshable { - Task(priority: .userInitiated) { - await refreshFeed() - } + // 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 } } - // load view if empty or account has changed - .task(priority: .userInitiated) { - // if a tracker is empty or the account has changed, refresh - if mentionsTracker.items.isEmpty || - messagesTracker.items.isEmpty || - repliesTracker.items.isEmpty { - print("Inbox tracker is empty") - await refreshFeed() - } else { - print("Inbox tracker is not empty") + .task { + // wrapping in task so view redraws don't cancel + Task(priority: .userInitiated) { + await refresh() } } } diff --git a/Mlem/Views/Tabs/Inbox/InboxView+Logic.swift b/Mlem/Views/Tabs/Inbox/InboxView+Logic.swift new file mode 100644 index 000000000..cb3a57b38 --- /dev/null +++ b/Mlem/Views/Tabs/Inbox/InboxView+Logic.swift @@ -0,0 +1,77 @@ +// +// InboxView+Logic.swift +// Mlem +// +// Created by Eric Andrews on 2023-10-20. +// + +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 toggleFilterRead() { + shouldFilterRead = !shouldFilterRead + } + + func handleShouldFilterReadChange(newShouldFilterRead: Bool) async { + replyTracker.unreadOnly = newShouldFilterRead + mentionTracker.unreadOnly = newShouldFilterRead + messageTracker.unreadOnly = newShouldFilterRead + + if newShouldFilterRead { + await inboxTracker.filterRead() + } else { + await inboxTracker.refresh(clearBeforeFetch: true) + } + } + + func markAllAsRead() async { + await inboxTracker.markAllAsRead(unreadTracker: unreadTracker) + } + + func genMenuFunctions() -> [MenuFunction] { + var ret: [MenuFunction] = .init() + + let (filterReadText, filterReadSymbol) = shouldFilterRead + ? ("Show All", Icons.filterFill) + : ("Show Only Unread", Icons.filter) + + ret.append(MenuFunction.standardMenuFunction( + text: filterReadText, + imageName: filterReadSymbol, + destructiveActionPrompt: nil, + enabled: true + ) { + toggleFilterRead() + }) + + ret.append(MenuFunction.standardMenuFunction( + text: "Mark All as Read", + imageName: "envelope.open", + destructiveActionPrompt: nil, + enabled: true + ) { + Task(priority: .userInitiated) { + await markAllAsRead() + } + }) + + return ret + } +} diff --git a/Mlem/Views/Tabs/Search/SearchResultListView.swift b/Mlem/Views/Tabs/Search/SearchResultListView.swift index 7c83c3b96..ca16b1195 100644 --- a/Mlem/Views/Tabs/Search/SearchResultListView.swift +++ b/Mlem/Views/Tabs/Search/SearchResultListView.swift @@ -40,7 +40,6 @@ struct SearchResultListView: View { } .onChange(of: shouldLoad) { value in if value { - print("Loading page \(contentTracker.page + 1)...") Task(priority: .medium) { try await contentTracker.loadNextPage() } } shouldLoad = false