diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c8e5e6e..e18d841 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -107,7 +107,12 @@ "mcp__xcodebuildmcp__build_sim", "Bash(grep -E \"\\\\.swift$\")", "Bash(/usr/bin/env bash --version)", - "Bash(./run-iphone.sh)" + "Bash(./run-iphone.sh)", + "Bash(chmod +x /private/tmp/claude-501/-Users-adron-Codez-interlinedlist-ios/e4665075-8f75-4abb-9a63-f2a985cfcbab/scratchpad/extract_routes.sh)", + "Bash(bash /private/tmp/claude-501/-Users-adron-Codez-interlinedlist-ios/e4665075-8f75-4abb-9a63-f2a985cfcbab/scratchpad/extract_routes.sh)", + "mcp__xcodebuildmcp__session_set_defaults", + "Bash(echo \"exit=$?\")", + "Bash(awk '/export async function getUserLists/,/^}/' lib/lists/queries.ts)" ], "additionalDirectories": [ "/Users/adron/Codez/interlinedlist-ios/.claude" diff --git a/.gitignore b/.gitignore index 83a5d3a..adf940b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ xcuserdata/ DerivedData/ build/ -.env \ No newline at end of file +.env +# Local QA smoke-test screenshot artifacts +QA/ diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index e126581..7288613 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -7,27 +7,53 @@ objects = { /* Begin PBXBuildFile section */ + 00B1EF932509721ADCD9CA59 /* APIClientDocumentTemplatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE93E83AA6CBE672E89C719 /* APIClientDocumentTemplatesTests.swift */; }; + 015680D1BA27E22F59C2DCEB /* ShareLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD05B653363E3A794B1B1EE7 /* ShareLink.swift */; }; + 05B7A32AAD6202D76C1CC975 /* DocumentSyncConflictTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */; }; + 081D31E74FF9214FF1F7F185 /* SharedDocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D6C33FD59101503195F14F /* SharedDocumentView.swift */; }; 09037C64E9D988371F29AD75 /* ComposeImageUploaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */; }; + 0B87CBFA04966C5AB9F5932C /* MessageLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4EA609D090D3BB9AB12228 /* MessageLinkView.swift */; }; 0C2A6EE423332702FD29DEBA /* Manrope.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 229B4152945DABCD6D88A59A /* Manrope.ttf */; }; 1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */; }; + 153437B4818A068C9ADE32DE /* APIClientLinkMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */; }; 1775C9B4C2CF08267AF3B138 /* WatchersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5828367E38948582EF1DF2A /* WatchersListView.swift */; }; 17CD6CF35900DC95AA284E3C /* PushService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B79D26B822DCA1188DF37E9 /* PushService.swift */; }; + 1C47F69DF0848EF0C45C51EE /* DocumentSyncModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C861AD11BBD66C75EB3EEE /* DocumentSyncModelTests.swift */; }; 2101251E75F418E9EB28DA8F /* ImageUploadProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */; }; + 212793C16484654CC6264FDE /* MutedUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4AE47FA0D5B0B2F713F5AA /* MutedUsersView.swift */; }; + 213A17C03007A464190DB5D6 /* APIClientMessageByIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644B1B09F84332A794680ED7 /* APIClientMessageByIdTests.swift */; }; 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */; }; + 264F2B22B11DA2FF8D2AD68A /* ShareLinksSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */; }; + 29AE2CB98C2CF115F997F74A /* FindPeopleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D812B42F75DBEA52611BE0 /* FindPeopleView.swift */; }; + 2B99F4DE40ABB197360D9650 /* TagDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */; }; + 2BD179B7617ABFD7271B9FB8 /* GitHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802D2FF0552D510BB51D636 /* GitHub.swift */; }; + 362DEA03EA2CDD9E135B1411 /* APIClientDirectMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A3F2C860C6B16F84CFFE12 /* APIClientDirectMessagesTests.swift */; }; 3D042B3B4C9E2317F7A4C04C /* MessageDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544F68F263670763D5E23C59 /* MessageDetailView.swift */; }; 46E2E2E80CBE246E84A5D78B /* MarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3DF89EFAF63A9287656938C /* MarkdownView.swift */; }; 47FBAC21F771B2813EAD8D37 /* Moderation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1147B978D9E72695E4457454 /* Moderation.swift */; }; + 4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */; }; + 55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */; }; + 56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */; }; 5703F883D4E6186DB66E5833 /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */; }; + 58151C239F0FDD01BD1BAEBB /* LinkedInPostingTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */; }; + 5B19E1BF3FA99B70C1F0153A /* APIClientSharingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8BE2B897589E1AEA55ACA75 /* APIClientSharingTests.swift */; }; + 5F19AD96D4278D5B8D87154E /* MessagesInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471B0516327616EC36DE917 /* MessagesInboxView.swift */; }; 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */; }; 6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */; }; + 6A89622E299B0D172D5B5556 /* GitHubModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C337406C10876F331B1888E3 /* GitHubModelTests.swift */; }; 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */; }; 6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; }; 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5914D730B4355F02E89D81 /* ILColor.swift */; }; + 73777B938FC2AF9D06CE5AD4 /* APIClientGitHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */; }; 79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */; }; + 7CD66F2490C7D2D4A3227E6A /* ILWebURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */; }; + 874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */; }; 8BEDD4D904328EC8027314DC /* SpaceGrotesk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */; }; + 945250FBC238FFDDC4E526B7 /* ShareInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236670B73F2B603E5EB318D7 /* ShareInvite.swift */; }; 98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B096C366108083D174366 /* MarkdownBlockTests.swift */; }; 99E688DB379BD4A824487604 /* ComposeImageUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */; }; 9CA0B53AE87AA79D8BAC81FF /* FollowListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF29F5DC946D80A63000209 /* FollowListView.swift */; }; + A057072E1611947A2415D4D9 /* DocumentCollaboratorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5110757D7694EC9FBF4366F7 /* DocumentCollaboratorsView.swift */; }; A1B1C1D1E1F10001 /* InterlinedListApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10002 /* InterlinedListApp.swift */; }; A1B1C1D1E1F10003 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10004 /* RootView.swift */; }; A1B1C1D1E1F10005 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10006 /* User.swift */; }; @@ -60,6 +86,7 @@ A3C3D3E3F3A30055 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C3D3E3F3A30056 /* URLSessionProtocol.swift */; }; A58E4C0D73217AA23A24114C /* JetBrainsMono.ttf in Resources */ = {isa = PBXBuildFile; fileRef = A79A316417B99AB22D2FFDC1 /* JetBrainsMono.ttf */; }; A5C47F6A46E3CE8708789CB2 /* PublicBrowse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */; }; + ADE33267E8D212F2863B1ABB /* APIClientSearchUsersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */; }; B1C1D1E1F1A10001 /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10002 /* DataCache.swift */; }; B1C1D1E1F1A10003 /* AppDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10004 /* AppDataStore.swift */; }; B1C1D1E1F1A10005 /* SkeletonBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10006 /* SkeletonBlock.swift */; }; @@ -71,8 +98,17 @@ C18C73C8A93572993F29D559 /* ImageUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */; }; C1D1E1F1A1B10001 /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D1E1F1A1B10002 /* Organization.swift */; }; C478109CF3BB06C22E121904 /* ReportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7800699C927CE3E133944E62 /* ReportSheet.swift */; }; + C60FC4296F72CEDEEF394848 /* DMThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E55EFCB42C25CBC10B3942 /* DMThreadView.swift */; }; + C6197F8F1B52DAE1B1D4A2F1 /* AppDeepLinkParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */; }; + C68E4AC129D6026DF9FF5A5B /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA0B9A4EE76356EA1EF5785 /* UserSession.swift */; }; + C818B9571C62180579743014 /* DocumentSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C2C3577AEE0C73D51308DA /* DocumentSync.swift */; }; + C83E43066F7C0ABF783444CA /* SharedResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = C470172F5310397C2E6195BC /* SharedResolution.swift */; }; + C841BDE9BCCCB4D2517BBFEE /* ShareInvitesSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */; }; + C8A35B3D8DDE085B8D445014 /* DirectMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C2904D262C0859FD38A830 /* DirectMessage.swift */; }; + C8AFF749A154E5F96FB77DE2 /* APIClientSessionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8D880C1D849E2E67C36116 /* APIClientSessionsTests.swift */; }; CB45081F767583424D54523C /* APIClientPushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09470364B5A0A559FDE3215F /* APIClientPushTests.swift */; }; CE93B45A2285EF8FD995D9FE /* OrganizationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0041BEAB46F9186FB5BF907 /* OrganizationsView.swift */; }; + D00510C1491A00AD0A77370B /* DocumentSyncOutbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF8FDEBF56E4CAB421FD811 /* DocumentSyncOutbox.swift */; }; D0A1D0A1D0A10001 /* OAuthCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A10002 /* OAuthCoordinator.swift */; }; D0A1D0A1D0A10003 /* ChangeEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A10004 /* ChangeEmailView.swift */; }; D0A1D0A1D0A10005 /* ForgotPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A10006 /* ForgotPasswordView.swift */; }; @@ -82,8 +118,20 @@ D0A1D0A1D0A1000D /* OAuthSignInButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000E /* OAuthSignInButton.swift */; }; D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */; }; D6AA238FF5D318D9C03FA6B5 /* PublicListDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */; }; + DAC151BF93B6B223D43714F0 /* APIClientShareInvitesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */; }; + DD652FF47D5509B976706914 /* DocumentSyncMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD5DCAF4C97D6B577F52527 /* DocumentSyncMerge.swift */; }; + DF75ECCB31B861F938E216A1 /* APIClientTagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F53DE7756042E68A3D1C33 /* APIClientTagsTests.swift */; }; + E1642E8329DC145DA96584D4 /* LinkedInTargetModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */; }; E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */; }; + E21121B51C15922CFBF92C43 /* DocumentSyncOutboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */; }; + E64747D9845D6726CC1A1485 /* NetworkReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */; }; + EA48487E20A6BECF67D3C060 /* ILWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */; }; + EC636FE56A7CC64D7D01F64B /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5569B56DBD6747BB9587870 /* SessionsView.swift */; }; + F211AD1D4EB93B9258F88CAC /* APIClientLinkedInTargetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC17A8A26D35CC755EE5988 /* APIClientLinkedInTargetsTests.swift */; }; + F4B74219CE0CB90DAE6B7516 /* ShareInviteModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDCA7EDAA58C66663ACC010 /* ShareInviteModelTests.swift */; }; F93C3A953A0B809D718F9DB5 /* PublicDocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */; }; + FA145F808445802781845261 /* DocumentLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */; }; + FC004747AE60A6796DAD36AB /* APIClientDocumentSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */; }; FCA5249B389CBC18040D6011 /* ComposeImageStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */; }; T1E5T1E5T1E50001 /* InterlinedListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50002 /* InterlinedListTests.swift */; }; T1E5T1E5T1E50003 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50004 /* MockURLSession.swift */; }; @@ -137,27 +185,61 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLinksSheet.swift; sourceTree = ""; }; + 03D6C33FD59101503195F14F /* SharedDocumentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedDocumentView.swift; sourceTree = ""; }; 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTruncationTests.swift; sourceTree = ""; }; + 06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvitesSheet.swift; sourceTree = ""; }; + 08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentLinkView.swift; sourceTree = ""; }; 09470364B5A0A559FDE3215F /* APIClientPushTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientPushTests.swift; sourceTree = ""; }; + 0CC17A8A26D35CC755EE5988 /* APIClientLinkedInTargetsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLinkedInTargetsTests.swift; sourceTree = ""; }; 0EF29F5DC946D80A63000209 /* FollowListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowListView.swift; sourceTree = ""; }; + 0FA0B9A4EE76356EA1EF5785 /* UserSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; + 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIClientGitHubTests.swift; path = APIClientGitHubTests.swift; sourceTree = ""; }; 1147B978D9E72695E4457454 /* Moderation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Moderation.swift; sourceTree = ""; }; + 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncConflictTests.swift; sourceTree = ""; }; + 12F53DE7756042E68A3D1C33 /* APIClientTagsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTagsTests.swift; sourceTree = ""; }; 164718CC67967AC147E30465 /* CrossPostLinksView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CrossPostLinksView.swift; sourceTree = ""; }; + 1D4AE47FA0D5B0B2F713F5AA /* MutedUsersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MutedUsersView.swift; sourceTree = ""; }; 229B4152945DABCD6D88A59A /* Manrope.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/Manrope.ttf; sourceTree = SOURCE_ROOT; }; + 236670B73F2B603E5EB318D7 /* ShareInvite.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvite.swift; sourceTree = ""; }; 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageStrip.swift; sourceTree = ""; }; + 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkedInPostingTarget.swift; sourceTree = ""; }; 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicListDetailView.swift; sourceTree = ""; }; 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 32C2904D262C0859FD38A830 /* DirectMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessage.swift; sourceTree = ""; }; + 3CD5DCAF4C97D6B577F52527 /* DocumentSyncMerge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncMerge.swift; sourceTree = ""; }; + 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkReachability.swift; sourceTree = ""; }; + 44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientShareInvitesTests.swift; sourceTree = ""; }; + 468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentSyncTests.swift; sourceTree = ""; }; + 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURLTests.swift; sourceTree = ""; }; + 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ILWebURL.swift; sourceTree = ""; }; + 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedResolverTests.swift; sourceTree = ""; }; + 4D4EA609D090D3BB9AB12228 /* MessageLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageLinkView.swift; sourceTree = ""; }; + 4E8D880C1D849E2E67C36116 /* APIClientSessionsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSessionsTests.swift; sourceTree = ""; }; 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDataStoreTests.swift; sourceTree = ""; }; + 5110757D7694EC9FBF4366F7 /* DocumentCollaboratorsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentCollaboratorsView.swift; sourceTree = ""; }; 544F68F263670763D5E23C59 /* MessageDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageDetailView.swift; sourceTree = ""; }; + 5802D2FF0552D510BB51D636 /* GitHub.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GitHub.swift; path = GitHub.swift; sourceTree = ""; }; 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessor.swift; sourceTree = ""; }; + 5CF8FDEBF56E4CAB421FD811 /* DocumentSyncOutbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncOutbox.swift; sourceTree = ""; }; + 644B1B09F84332A794680ED7 /* APIClientMessageByIdTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientMessageByIdTests.swift; sourceTree = ""; }; 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientModerationTests.swift; sourceTree = ""; }; 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicBrowse.swift; sourceTree = ""; }; 7800699C927CE3E133944E62 /* ReportSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReportSheet.swift; sourceTree = ""; }; 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessorTests.swift; sourceTree = ""; }; 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/SpaceGrotesk.ttf; sourceTree = SOURCE_ROOT; }; + 7EE93E83AA6CBE672E89C719 /* APIClientDocumentTemplatesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentTemplatesTests.swift; sourceTree = ""; }; 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploader.swift; sourceTree = ""; }; 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownEditor.swift; sourceTree = ""; }; + 8471B0516327616EC36DE917 /* MessagesInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesInboxView.swift; sourceTree = ""; }; + 88C2C3577AEE0C73D51308DA /* DocumentSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSync.swift; sourceTree = ""; }; 8B79D26B822DCA1188DF37E9 /* PushService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushService.swift; sourceTree = ""; }; + 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncMergeTests.swift; sourceTree = ""; }; + 907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLinkMetadataTests.swift; sourceTree = ""; }; + 90E55EFCB42C25CBC10B3942 /* DMThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMThreadView.swift; sourceTree = ""; }; 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.swift; sourceTree = ""; }; + 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSearchUsersTests.swift; sourceTree = ""; }; + 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDeepLinkParseTests.swift; sourceTree = ""; }; 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicDocumentsView.swift; sourceTree = ""; }; A1B1C1D1E1F10000 /* InterlinedList.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InterlinedList.app; sourceTree = BUILT_PRODUCTS_DIR; }; A1B1C1D1E1F10002 /* InterlinedListApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListApp.swift; sourceTree = ""; }; @@ -192,6 +274,7 @@ A3C3D3E3F3A30054 /* DocumentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentsView.swift; sourceTree = ""; }; A3C3D3E3F3A30056 /* URLSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = ""; }; A3DF89EFAF63A9287656938C /* MarkdownView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownView.swift; sourceTree = ""; }; + A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkedInTargetModelTests.swift; sourceTree = ""; }; A79A316417B99AB22D2FFDC1 /* JetBrainsMono.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/JetBrainsMono.ttf; sourceTree = SOURCE_ROOT; }; B1C1D1E1F1A10002 /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; B1C1D1E1F1A10004 /* AppDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDataStore.swift; sourceTree = ""; }; @@ -201,8 +284,16 @@ B1C1D1E1F1A1000C /* DocumentSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentSkeletonView.swift; sourceTree = ""; }; B1C1D1E1F1A1000E /* ListItemFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemFormView.swift; sourceTree = ""; }; B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientGapPhasesTests.swift; sourceTree = ""; }; + B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TagDiscovery.swift; sourceTree = ""; }; + B7D812B42F75DBEA52611BE0 /* FindPeopleView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FindPeopleView.swift; sourceTree = ""; }; + BD05B653363E3A794B1B1EE7 /* ShareLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLink.swift; sourceTree = ""; }; + BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncConflict.swift; sourceTree = ""; }; C0041BEAB46F9186FB5BF907 /* OrganizationsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OrganizationsView.swift; sourceTree = ""; }; C1D1E1F1A1B10002 /* Organization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Organization.swift; sourceTree = ""; }; + C337406C10876F331B1888E3 /* GitHubModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GitHubModelTests.swift; path = GitHubModelTests.swift; sourceTree = ""; }; + C470172F5310397C2E6195BC /* SharedResolution.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedResolution.swift; sourceTree = ""; }; + C5C861AD11BBD66C75EB3EEE /* DocumentSyncModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncModelTests.swift; sourceTree = ""; }; + C8BE2B897589E1AEA55ACA75 /* APIClientSharingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharingTests.swift; sourceTree = ""; }; D0A1D0A1D0A10002 /* OAuthCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthCoordinator.swift; sourceTree = ""; }; D0A1D0A1D0A10004 /* ChangeEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeEmailView.swift; sourceTree = ""; }; D0A1D0A1D0A10006 /* ForgotPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgotPasswordView.swift; sourceTree = ""; }; @@ -210,13 +301,18 @@ D0A1D0A1D0A1000A /* EmailVerificationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailVerificationBanner.swift; sourceTree = ""; }; D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedIdentitiesView.swift; sourceTree = ""; }; D0A1D0A1D0A1000E /* OAuthSignInButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSignInButton.swift; sourceTree = ""; }; + D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncOutboxTests.swift; sourceTree = ""; }; D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploaderTests.swift; sourceTree = ""; }; + DCDCA7EDAA58C66663ACC010 /* ShareInviteModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInviteModelTests.swift; sourceTree = ""; }; E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BlockedUsersView.swift; sourceTree = ""; }; E5828367E38948582EF1DF2A /* WatchersListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchersListView.swift; sourceTree = ""; }; + E6A3F2C860C6B16F84CFFE12 /* APIClientDirectMessagesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientDirectMessagesTests.swift; sourceTree = ""; }; ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; }; + EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DirectMessageModelTests.swift; sourceTree = ""; }; F0470C00F1A0E215224120A1 /* ListWatcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListWatcher.swift; sourceTree = ""; }; F379C02B2FCEB9440069B81C /* InterlinedList.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = InterlinedList.xctestplan; sourceTree = ""; }; F3E6216F2FFB4666000D153A /* InterlinedList.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InterlinedList.entitlements; sourceTree = ""; }; + F5569B56DBD6747BB9587870 /* SessionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = ""; }; F78B096C366108083D174366 /* MarkdownBlockTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownBlockTests.swift; sourceTree = ""; }; FC5914D730B4355F02E89D81 /* ILColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterlinedList/Services/ILColor.swift; sourceTree = SOURCE_ROOT; }; T1E5T1E5T1E50000 /* InterlinedListTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InterlinedListTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -338,6 +434,15 @@ ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */, 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */, 1147B978D9E72695E4457454 /* Moderation.swift */, + 32C2904D262C0859FD38A830 /* DirectMessage.swift */, + 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */, + BD05B653363E3A794B1B1EE7 /* ShareLink.swift */, + 5802D2FF0552D510BB51D636 /* GitHub.swift */, + 0FA0B9A4EE76356EA1EF5785 /* UserSession.swift */, + 88C2C3577AEE0C73D51308DA /* DocumentSync.swift */, + 236670B73F2B603E5EB318D7 /* ShareInvite.swift */, + B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */, + C470172F5310397C2E6195BC /* SharedResolution.swift */, ); path = Models; sourceTree = ""; @@ -356,6 +461,11 @@ 8B79D26B822DCA1188DF37E9 /* PushService.swift */, 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */, 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */, + 3CD5DCAF4C97D6B577F52527 /* DocumentSyncMerge.swift */, + 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */, + 5CF8FDEBF56E4CAB421FD811 /* DocumentSyncOutbox.swift */, + 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */, + BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */, ); path = Services; sourceTree = ""; @@ -404,6 +514,17 @@ 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */, 164718CC67967AC147E30465 /* CrossPostLinksView.swift */, 544F68F263670763D5E23C59 /* MessageDetailView.swift */, + 1D4AE47FA0D5B0B2F713F5AA /* MutedUsersView.swift */, + 8471B0516327616EC36DE917 /* MessagesInboxView.swift */, + 90E55EFCB42C25CBC10B3942 /* DMThreadView.swift */, + B7D812B42F75DBEA52611BE0 /* FindPeopleView.swift */, + 03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */, + 5110757D7694EC9FBF4366F7 /* DocumentCollaboratorsView.swift */, + F5569B56DBD6747BB9587870 /* SessionsView.swift */, + 4D4EA609D090D3BB9AB12228 /* MessageLinkView.swift */, + 06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */, + 08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */, + 03D6C33FD59101503195F14F /* SharedDocumentView.swift */, ); path = Views; sourceTree = ""; @@ -450,6 +571,19 @@ B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */, 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */, 09470364B5A0A559FDE3215F /* APIClientPushTests.swift */, + E6A3F2C860C6B16F84CFFE12 /* APIClientDirectMessagesTests.swift */, + 7EE93E83AA6CBE672E89C719 /* APIClientDocumentTemplatesTests.swift */, + 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */, + 0CC17A8A26D35CC755EE5988 /* APIClientLinkedInTargetsTests.swift */, + C8BE2B897589E1AEA55ACA75 /* APIClientSharingTests.swift */, + 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */, + 4E8D880C1D849E2E67C36116 /* APIClientSessionsTests.swift */, + 468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */, + 644B1B09F84332A794680ED7 /* APIClientMessageByIdTests.swift */, + 44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */, + 12F53DE7756042E68A3D1C33 /* APIClientTagsTests.swift */, + 907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */, + 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */, ); path = APIClientTests; sourceTree = ""; @@ -471,6 +605,13 @@ 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */, F78B096C366108083D174366 /* MarkdownBlockTests.swift */, 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */, + EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */, + A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */, + C337406C10876F331B1888E3 /* GitHubModelTests.swift */, + C5C861AD11BBD66C75EB3EEE /* DocumentSyncModelTests.swift */, + 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */, + 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */, + DCDCA7EDAA58C66663ACC010 /* ShareInviteModelTests.swift */, ); path = ModelTests; sourceTree = ""; @@ -482,6 +623,9 @@ 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */, 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */, D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */, + 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */, + D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */, + 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */, ); path = ServiceTests; sourceTree = ""; @@ -651,6 +795,31 @@ 79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */, B270170D3EEF336A45464D01 /* CrossPostLinksView.swift in Sources */, 3D042B3B4C9E2317F7A4C04C /* MessageDetailView.swift in Sources */, + 212793C16484654CC6264FDE /* MutedUsersView.swift in Sources */, + C8A35B3D8DDE085B8D445014 /* DirectMessage.swift in Sources */, + 5F19AD96D4278D5B8D87154E /* MessagesInboxView.swift in Sources */, + C60FC4296F72CEDEEF394848 /* DMThreadView.swift in Sources */, + 29AE2CB98C2CF115F997F74A /* FindPeopleView.swift in Sources */, + 58151C239F0FDD01BD1BAEBB /* LinkedInPostingTarget.swift in Sources */, + 015680D1BA27E22F59C2DCEB /* ShareLink.swift in Sources */, + 264F2B22B11DA2FF8D2AD68A /* ShareLinksSheet.swift in Sources */, + A057072E1611947A2415D4D9 /* DocumentCollaboratorsView.swift in Sources */, + 2BD179B7617ABFD7271B9FB8 /* GitHub.swift in Sources */, + C68E4AC129D6026DF9FF5A5B /* UserSession.swift in Sources */, + EC636FE56A7CC64D7D01F64B /* SessionsView.swift in Sources */, + C818B9571C62180579743014 /* DocumentSync.swift in Sources */, + DD652FF47D5509B976706914 /* DocumentSyncMerge.swift in Sources */, + EA48487E20A6BECF67D3C060 /* ILWebURL.swift in Sources */, + 0B87CBFA04966C5AB9F5932C /* MessageLinkView.swift in Sources */, + D00510C1491A00AD0A77370B /* DocumentSyncOutbox.swift in Sources */, + E64747D9845D6726CC1A1485 /* NetworkReachability.swift in Sources */, + 55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */, + 945250FBC238FFDDC4E526B7 /* ShareInvite.swift in Sources */, + C841BDE9BCCCB4D2517BBFEE /* ShareInvitesSheet.swift in Sources */, + 2B99F4DE40ABB197360D9650 /* TagDiscovery.swift in Sources */, + C83E43066F7C0ABF783444CA /* SharedResolution.swift in Sources */, + FA145F808445802781845261 /* DocumentLinkView.swift in Sources */, + 081D31E74FF9214FF1F7F185 /* SharedDocumentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -706,6 +875,29 @@ 09037C64E9D988371F29AD75 /* ComposeImageUploaderTests.swift in Sources */, 98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */, 6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */, + 362DEA03EA2CDD9E135B1411 /* APIClientDirectMessagesTests.swift in Sources */, + 874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */, + 00B1EF932509721ADCD9CA59 /* APIClientDocumentTemplatesTests.swift in Sources */, + ADE33267E8D212F2863B1ABB /* APIClientSearchUsersTests.swift in Sources */, + F211AD1D4EB93B9258F88CAC /* APIClientLinkedInTargetsTests.swift in Sources */, + E1642E8329DC145DA96584D4 /* LinkedInTargetModelTests.swift in Sources */, + 5B19E1BF3FA99B70C1F0153A /* APIClientSharingTests.swift in Sources */, + 73777B938FC2AF9D06CE5AD4 /* APIClientGitHubTests.swift in Sources */, + 6A89622E299B0D172D5B5556 /* GitHubModelTests.swift in Sources */, + C8AFF749A154E5F96FB77DE2 /* APIClientSessionsTests.swift in Sources */, + FC004747AE60A6796DAD36AB /* APIClientDocumentSyncTests.swift in Sources */, + 4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */, + 1C47F69DF0848EF0C45C51EE /* DocumentSyncModelTests.swift in Sources */, + 213A17C03007A464190DB5D6 /* APIClientMessageByIdTests.swift in Sources */, + C6197F8F1B52DAE1B1D4A2F1 /* AppDeepLinkParseTests.swift in Sources */, + 7CD66F2490C7D2D4A3227E6A /* ILWebURLTests.swift in Sources */, + E21121B51C15922CFBF92C43 /* DocumentSyncOutboxTests.swift in Sources */, + 05B7A32AAD6202D76C1CC975 /* DocumentSyncConflictTests.swift in Sources */, + DAC151BF93B6B223D43714F0 /* APIClientShareInvitesTests.swift in Sources */, + F4B74219CE0CB90DAE6B7516 /* ShareInviteModelTests.swift in Sources */, + DF75ECCB31B861F938E216A1 /* APIClientTagsTests.swift in Sources */, + 153437B4818A068C9ADE32DE /* APIClientLinkMetadataTests.swift in Sources */, + 56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/InterlinedList.entitlements b/InterlinedList/InterlinedList.entitlements index 903def2..309bfe6 100644 --- a/InterlinedList/InterlinedList.entitlements +++ b/InterlinedList/InterlinedList.entitlements @@ -4,5 +4,9 @@ aps-environment development + com.apple.developer.associated-domains + + applinks:interlinedlist.com + diff --git a/InterlinedList/InterlinedListApp.swift b/InterlinedList/InterlinedListApp.swift index 8217797..e845000 100644 --- a/InterlinedList/InterlinedListApp.swift +++ b/InterlinedList/InterlinedListApp.swift @@ -46,38 +46,50 @@ struct InterlinedListApp: App { switch link { case .resetPassword(let token): ResetPasswordView(token: token) + case .userProfile(let username): + NavigationStack { + UserProfileView(username: username) + } + .environmentObject(authState) + .environmentObject(store) + case .message(let id): + MessageLinkView(messageId: id) + .environmentObject(authState) + .environmentObject(store) + case .document(let id): + DocumentLinkView(documentId: id) + .environmentObject(authState) + case .sharedDocument(let token): + SharedDocumentView(token: token) + .environmentObject(authState) + case .verifyEmail, .verifyEmailChange: + // These never present a sheet — they run an async side effect in + // handleDeepLink and are never assigned to pendingDeepLink. + EmptyView() } } + // A2: Universal Links (a tapped https://interlinedlist.com link opening the app + // directly). The Associated Domains entitlement (applinks:interlinedlist.com) is now + // in place and `parse` already accepts https permalinks, so onOpenURL will route them + // once the backend publishes apple-app-site-association and the Associated Domains + // capability is enabled for the App ID / provisioning profile. private func handleDeepLink(_ url: URL) { - guard url.scheme == "interlinedlist" else { return } - // Token query items are read but never logged — they're sensitive bearer - // material handed off to KeychainService / OAuthCoordinator. - let host = url.host ?? "" - let path = url.path - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - let token = components?.queryItems?.first(where: { $0.name == "token" })?.value - - switch (host, path) { - case ("reset-password", _), ("", "/reset-password"): - if let token, !token.isEmpty { - router.pendingDeepLink = .resetPassword(token: token) - } - case ("verify-email", _), ("", "/verify-email"): - if let token, !token.isEmpty { - Task { await verifyEmail(token: token) } - } - case ("verify-email-change", _), ("", "/verify-email-change"): - if let token, !token.isEmpty { - Task { await verifyEmailChange(token: token) } - } - case ("oauth", _): - // ASWebAuthenticationSession captures the callback automatically; the - // app-level handler is a fallback for when the session has been torn - // down (rare; safe to ignore the token rather than re-exchange it). - break - default: - break + // OAuth callbacks are captured by ASWebAuthenticationSession itself; the + // app-level handler is a fallback for a torn-down session (safe to ignore). + if url.scheme == "interlinedlist" && (url.host == "oauth" || url.path.hasPrefix("/oauth")) { + return + } + // Token query items are read via AppDeepLink.parse but never logged — they're + // sensitive bearer material handed off to KeychainService / OAuthCoordinator. + guard let link = AppDeepLink.parse(url) else { return } + switch link { + case .verifyEmail(let token): + Task { await verifyEmail(token: token) } + case .verifyEmailChange(let token): + Task { await verifyEmailChange(token: token) } + case .resetPassword, .userProfile, .message, .document, .sharedDocument: + router.pendingDeepLink = link } } @@ -124,10 +136,88 @@ struct InterlinedListApp: App { enum AppDeepLink: Identifiable, Hashable { case resetPassword(token: String) + case verifyEmail(token: String) + case verifyEmailChange(token: String) + case userProfile(username: String) + case message(id: String) + case document(id: String) + case sharedDocument(token: String) var id: String { switch self { case .resetPassword(let token): return "reset:" + token + case .verifyEmail(let token): return "verify:" + token + case .verifyEmailChange(let token): return "verify-change:" + token + case .userProfile(let username): return "profile:" + username + case .message(let id): return "message:" + id + case .document(let id): return "document:" + id + case .sharedDocument(let token): return "shared-document:" + token + } + } + + /// Parses both the custom scheme (`interlinedlist://…`, where the target is the + /// URL host or first path segment) and canonical web permalinks + /// (`https://interlinedlist.com/…` / `https://www.interlinedlist.com/…`). Content + /// links map to `.userProfile` / `.message`; auth links preserve their `?token`. + /// Returns nil for unknown targets or web hosts other than interlinedlist.com. + static func parse(_ url: URL) -> AppDeepLink? { + let scheme = url.scheme?.lowercased() + let host = url.host?.lowercased() ?? "" + + let target: String + let segments: [String] + if scheme == "interlinedlist" { + let pathSegments = url.path.split(separator: "/").map(String.init) + // The custom scheme puts the target in either the host (interlinedlist://user/bob) + // or the first path segment (interlinedlist:///user/bob), so try the host first. + if host.isEmpty { + target = pathSegments.first ?? "" + segments = Array(pathSegments.dropFirst()) + } else { + target = host + segments = pathSegments + } + } else if scheme == "https" || scheme == "http" { + guard host == "interlinedlist.com" || host == "www.interlinedlist.com" else { return nil } + let pathSegments = url.path.split(separator: "/").map(String.init) + target = pathSegments.first ?? "" + segments = Array(pathSegments.dropFirst()) + } else { + return nil + } + + let token = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "token" })?.value + + switch target { + case "user": + guard let username = segments.first, !username.isEmpty else { return nil } + return .userProfile(username: username) + case "message": + guard let id = segments.first, !id.isEmpty else { return nil } + return .message(id: id) + case "documents": + // `/documents/shared/` resolves a share link; `/documents/` + // opens a document permalink. (List permalinks are not routed: a bare + // `/lists/` carries no owner username, which the list-detail endpoint + // requires — see the-gaps.md G10 follow-ons.) + if segments.first == "shared" { + guard segments.count >= 2, !segments[1].isEmpty else { return nil } + return .sharedDocument(token: segments[1]) + } + guard let id = segments.first, !id.isEmpty else { return nil } + return .document(id: id) + case "reset-password": + guard let token, !token.isEmpty else { return nil } + return .resetPassword(token: token) + case "verify-email": + guard let token, !token.isEmpty else { return nil } + return .verifyEmail(token: token) + case "verify-email-change": + guard let token, !token.isEmpty else { return nil } + return .verifyEmailChange(token: token) + default: + return nil } } } diff --git a/InterlinedList/Models/DirectMessage.swift b/InterlinedList/Models/DirectMessage.swift new file mode 100644 index 0000000..938c87a --- /dev/null +++ b/InterlinedList/Models/DirectMessage.swift @@ -0,0 +1,194 @@ +// +// DirectMessage.swift +// InterlinedList +// + +import Foundation + +/// A user you can direct-message (mutual-follow set). Same shape as `MessageUser` +/// but kept distinct so the DM surface can evolve independently. +struct DMUser: Codable, Identifiable, Hashable { + let id: String + let username: String + let displayName: String? + let avatar: String? + + var displayNameOrUsername: String { + displayName?.isEmpty == false ? (displayName ?? username) : username + } +} + +/// Pure recipient-search filtering, extracted so the picker's list logic can be +/// unit-tested without SwiftUI. A blank query returns everyone unchanged. +enum DMRecipientFilter { + static func matches(_ recipients: [DMUser], query: String) -> [DMUser] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return recipients } + let lower = trimmed.lowercased() + return recipients.filter { + $0.username.lowercased().contains(lower) || + ($0.displayName?.lowercased().contains(lower) ?? false) + } + } +} + +/// A single direct message. `sender`/`recipient` may be absent on some payloads +/// (e.g. thread updates), so both are optional and the view falls back to ids. +struct DMMessage: Codable, Identifiable, Hashable { + let id: String + let pairKey: String? + let senderId: String + let recipientId: String + let body: String + let imageUrls: [String] + let createdAt: String + let readAt: String? + let sender: DMUser? + let recipient: DMUser? + let preview: String? + + enum CodingKeys: String, CodingKey { + case id, pairKey, senderId, recipientId, body, imageUrls, createdAt, readAt, sender, recipient, preview + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + pairKey = try c.decodeIfPresent(String.self, forKey: .pairKey) + senderId = try c.decode(String.self, forKey: .senderId) + recipientId = try c.decode(String.self, forKey: .recipientId) + body = try c.decodeIfPresent(String.self, forKey: .body) ?? "" + imageUrls = try c.decodeIfPresent([String].self, forKey: .imageUrls) ?? [] + createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + readAt = try c.decodeIfPresent(String.self, forKey: .readAt) + sender = try c.decodeIfPresent(DMUser.self, forKey: .sender) + recipient = try c.decodeIfPresent(DMUser.self, forKey: .recipient) + preview = try c.decodeIfPresent(String.self, forKey: .preview) + } + + init( + id: String, + pairKey: String? = nil, + senderId: String, + recipientId: String, + body: String, + imageUrls: [String] = [], + createdAt: String, + readAt: String? = nil, + sender: DMUser? = nil, + recipient: DMUser? = nil, + preview: String? = nil + ) { + self.id = id + self.pairKey = pairKey + self.senderId = senderId + self.recipientId = recipientId + self.body = body + self.imageUrls = imageUrls + self.createdAt = createdAt + self.readAt = readAt + self.sender = sender + self.recipient = recipient + self.preview = preview + } + + var isRead: Bool { readAt != nil } + + /// The other party relative to `userId` (nil if that side isn't populated). + func otherParty(selfId: String?) -> DMUser? { + guard let selfId else { return sender ?? recipient } + return senderId == selfId ? recipient : sender + } +} + +/// A conversation with one other user, returned by `GET /api/dm/thread/:username`. +struct DMThread: Codable { + let items: [DMMessage] + let olderCursor: String? + let isMutual: Bool + let isBlocked: Bool + let otherUser: DMUser + + enum CodingKeys: String, CodingKey { + case items, olderCursor, isMutual, isBlocked, otherUser + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + items = try c.decodeIfPresent([DMMessage].self, forKey: .items) ?? [] + olderCursor = try c.decodeIfPresent(String.self, forKey: .olderCursor) + isMutual = try c.decodeIfPresent(Bool.self, forKey: .isMutual) ?? false + isBlocked = try c.decodeIfPresent(Bool.self, forKey: .isBlocked) ?? false + otherUser = try c.decode(DMUser.self, forKey: .otherUser) + } + + init(items: [DMMessage], olderCursor: String?, isMutual: Bool, isBlocked: Bool, otherUser: DMUser) { + self.items = items + self.olderCursor = olderCursor + self.isMutual = isMutual + self.isBlocked = isBlocked + self.otherUser = otherUser + } +} + +/// One inbox/sent/deleted folder in the DM surface. +enum DMFolder: String, CaseIterable, Identifiable { + case inbox + case sent + case deleted + + var id: String { rawValue } + + var title: String { + switch self { + case .inbox: return "Inbox" + case .sent: return "Sent" + case .deleted: return "Deleted" + } + } +} + +// MARK: - Response wrappers + +struct DMListResponse: Codable { + let items: [DMMessage] + let nextCursor: String? + + enum CodingKeys: String, CodingKey { case items, nextCursor } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + items = try c.decodeIfPresent([DMMessage].self, forKey: .items) ?? [] + nextCursor = try c.decodeIfPresent(String.self, forKey: .nextCursor) + } + + init(items: [DMMessage], nextCursor: String?) { + self.items = items + self.nextCursor = nextCursor + } +} + +struct DMMessageResponse: Codable { + let message: DMMessage +} + +struct DMRecipientsResponse: Codable { + let recipients: [DMUser] + + enum CodingKeys: String, CodingKey { case recipients } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + recipients = try c.decodeIfPresent([DMUser].self, forKey: .recipients) ?? [] + } + + init(recipients: [DMUser]) { self.recipients = recipients } +} + +struct DMUnreadCountResponse: Codable { + let count: Int +} + +struct DMUpdatedResponse: Codable { + let updated: Int +} diff --git a/InterlinedList/Models/Document.swift b/InterlinedList/Models/Document.swift index 9ed38c5..0aa21d2 100644 --- a/InterlinedList/Models/Document.swift +++ b/InterlinedList/Models/Document.swift @@ -5,7 +5,7 @@ import Foundation -struct Document: Codable, Identifiable { +struct Document: Codable, Identifiable, Hashable { let id: String let title: String let content: String? @@ -13,12 +13,52 @@ struct Document: Codable, Identifiable { let isPublic: Bool? let createdAt: String? let updatedAt: String? + /// Soft-delete tombstone from `GET /api/documents/sync`. `nil` on every other + /// endpoint. A non-nil value means the row was deleted server-side. + let deletedAt: String? + + init(id: String, title: String, content: String? = nil, folderId: String? = nil, + isPublic: Bool? = nil, createdAt: String? = nil, updatedAt: String? = nil, + deletedAt: String? = nil) { + self.id = id + self.title = title + self.content = content + self.folderId = folderId + self.isPublic = isPublic + self.createdAt = createdAt + self.updatedAt = updatedAt + self.deletedAt = deletedAt + } } struct DocumentFolder: Codable, Identifiable { let id: String let name: String let parentId: String? + let updatedAt: String? + /// Soft-delete tombstone from `GET /api/documents/sync`. See `Document.deletedAt`. + let deletedAt: String? + + init(id: String, name: String, parentId: String? = nil, + updatedAt: String? = nil, deletedAt: String? = nil) { + self.id = id + self.name = name + self.parentId = parentId + self.updatedAt = updatedAt + self.deletedAt = deletedAt + } +} + +/// A starter document a subscriber can copy into a new document. `id` is the +/// source document's id (`templateDocumentId` for `POST /api/documents/from-template`). +struct DocumentTemplate: Codable, Identifiable { + let id: String + let title: String + let relativePath: String? +} + +struct DocumentTemplatesResponse: Codable { + let templates: [DocumentTemplate] } struct DocumentsResponse: Codable { diff --git a/InterlinedList/Models/DocumentSync.swift b/InterlinedList/Models/DocumentSync.swift new file mode 100644 index 0000000..40ed015 --- /dev/null +++ b/InterlinedList/Models/DocumentSync.swift @@ -0,0 +1,167 @@ +// +// DocumentSync.swift +// InterlinedList +// + +import Foundation + +/// Response of `GET /api/documents/sync`. A **delta** since the supplied +/// `lastSyncAt` cursor, or **full state** when no cursor is sent. Rows with a +/// non-nil `deletedAt` are tombstones (deleted server-side). The response +/// `lastSyncAt` is the new cursor to persist for the next pull. +struct DocumentSyncResponse: Codable { + let folders: [DocumentFolder] + let documents: [Document] + let lastSyncAt: String? + + init(folders: [DocumentFolder] = [], documents: [Document] = [], lastSyncAt: String? = nil) { + self.folders = folders + self.documents = documents + self.lastSyncAt = lastSyncAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + folders = try container.decodeIfPresent([DocumentFolder].self, forKey: .folders) ?? [] + documents = try container.decodeIfPresent([Document].self, forKey: .documents) ?? [] + lastSyncAt = try container.decodeIfPresent(String.self, forKey: .lastSyncAt) + } +} + +/// One queued mutation for `POST /api/documents/sync`. The body is **camelCase** +/// (`op`/`type`/`path`/`data`) and only non-nil `data` keys are encoded, so a +/// delete carries just `{ "id": … }`. Ops accumulate in `DocumentSyncState.outbox` +/// and are drained on the next push cycle. +struct SyncOperation: Codable, Equatable { + enum SyncOp: String, Codable { + case create, update, delete + } + + enum SyncEntityType: String, Codable { + case document, folder + } + + let op: SyncOp + let type: SyncEntityType + let path: String? + let data: SyncOpData + + init(op: SyncOp, type: SyncEntityType, path: String? = nil, data: SyncOpData) { + self.op = op + self.type = type + self.path = path + self.data = data + } +} + +/// The `data` payload of a `SyncOperation`. Carries the document/folder fields the +/// server upserts by client-supplied `id`. Only non-nil keys are encoded so a +/// delete op (or a partial update) doesn't clobber unrelated fields server-side. +struct SyncOpData: Codable, Equatable { + let id: String + var folderId: String? + var parentId: String? + var name: String? + var title: String? + var content: String? + var relativePath: String? + var isPublic: Bool? + + init(id: String, folderId: String? = nil, parentId: String? = nil, name: String? = nil, + title: String? = nil, content: String? = nil, relativePath: String? = nil, + isPublic: Bool? = nil) { + self.id = id + self.folderId = folderId + self.parentId = parentId + self.name = name + self.title = title + self.content = content + self.relativePath = relativePath + self.isPublic = isPublic + } + + private enum CodingKeys: String, CodingKey { + case id, folderId, parentId, name, title, content, relativePath, isPublic + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(folderId, forKey: .folderId) + try container.encodeIfPresent(parentId, forKey: .parentId) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(content, forKey: .content) + try container.encodeIfPresent(relativePath, forKey: .relativePath) + try container.encodeIfPresent(isPublic, forKey: .isPublic) + } +} + +/// Local sync state of a single document/folder relative to the server. +enum LocalSyncState: String, Codable { + /// In sync with the server; no un-pushed edits. + case synced + /// Has un-pushed create/update edits queued in the outbox. + case dirty + /// Locally deleted; a delete op is queued (kept until the push confirms). + case deleted +} + +/// Persisted per-user offline document state (cached under `"_docsync"`): +/// the merged **non-deleted** folders/documents, the sync cursor, the pending +/// write `outbox`, and the per-id `localStates` map (which rows have un-pushed +/// edits). Persisted via the shared `DataCache`. +struct DocumentSyncState: Codable { + var folders: [DocumentFolder] + var documents: [Document] + var lastSyncAt: String? + var outbox: [SyncOperation] + var localStates: [String: LocalSyncState] + /// Per-doc server `updatedAt` recorded whenever a doc last became `.synced` + /// (a clean pull, or after a successful push). Slice-3 conflict detection + /// compares a delta row's `updatedAt` against this baseline: a locally + /// `.dirty` doc whose server `updatedAt` moved past its baseline was edited + /// elsewhere and is a conflict. + var baselines: [String: String] + + init(folders: [DocumentFolder] = [], documents: [Document] = [], lastSyncAt: String? = nil, + outbox: [SyncOperation] = [], localStates: [String: LocalSyncState] = [:], + baselines: [String: String] = [:]) { + self.folders = folders + self.documents = documents + self.lastSyncAt = lastSyncAt + self.outbox = outbox + self.localStates = localStates + self.baselines = baselines + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + folders = try container.decodeIfPresent([DocumentFolder].self, forKey: .folders) ?? [] + documents = try container.decodeIfPresent([Document].self, forKey: .documents) ?? [] + lastSyncAt = try container.decodeIfPresent(String.self, forKey: .lastSyncAt) + // Tolerate a Slice-1 cache written before these fields existed. + outbox = try container.decodeIfPresent([SyncOperation].self, forKey: .outbox) ?? [] + localStates = try container.decodeIfPresent([String: LocalSyncState].self, forKey: .localStates) ?? [:] + // Tolerate a Slice-1/2 cache written before baselines existed. + baselines = try container.decodeIfPresent([String: String].self, forKey: .baselines) ?? [:] + } + + /// Ids that have un-pushed create/update edits (helper for the UI's + /// "pending sync" affordance). + var dirtyIds: Set { + Set(localStates.filter { $0.value == .dirty }.keys) + } +} + +/// A user-facing record that a document was edited elsewhere while we held +/// un-pushed local edits, so the server version was preserved as a **conflict +/// copy** rather than being clobbered. Drives the dismissible banner. +struct SyncConflictNotice: Identifiable, Equatable { + /// The conflicting (still-live, locally edited) document's id. + let id: String + /// Title of the document the user kept editing (the live/local copy). + let originalTitle: String + /// Title of the newly created conflict copy holding the server's version. + let copyTitle: String +} diff --git a/InterlinedList/Models/FollowState.swift b/InterlinedList/Models/FollowState.swift index ee0e69b..e88a602 100644 --- a/InterlinedList/Models/FollowState.swift +++ b/InterlinedList/Models/FollowState.swift @@ -5,10 +5,54 @@ import Foundation -struct FollowStatus: Codable { +/// The caller's relationship toward a target user. +/// +/// Decoded from two different API shapes: +/// - `GET /api/follow/:id/status` → `{ status, isFollowing, isPending }` (no `followedBy`). +/// - `POST /api/follow/:id` → `{ follow: { status, ... } }` (nested; derive from `status`). +/// +/// `followedBy` (does the target follow us back) is not reported by either endpoint, +/// so it defaults to `false`; nothing in the UI depends on a real value from these calls. +struct FollowStatus: Codable, Equatable { let following: Bool let followedBy: Bool let pendingRequest: Bool + + init(following: Bool, followedBy: Bool = false, pendingRequest: Bool) { + self.following = following + self.followedBy = followedBy + self.pendingRequest = pendingRequest + } + + private enum CodingKeys: String, CodingKey { + case status, isFollowing, isPending, follow + } + + private struct NestedFollow: Decodable { + let status: String? + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let nested = try container.decodeIfPresent(NestedFollow.self, forKey: .follow) { + let status = nested.status + self.following = status == "approved" + self.pendingRequest = status == "pending" + } else { + let status = try container.decodeIfPresent(String.self, forKey: .status) + self.following = try container.decodeIfPresent(Bool.self, forKey: .isFollowing) ?? (status == "approved") + self.pendingRequest = try container.decodeIfPresent(Bool.self, forKey: .isPending) ?? (status == "pending") + } + self.followedBy = false + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(following, forKey: .isFollowing) + try container.encode(pendingRequest, forKey: .isPending) + try container.encode(following ? "approved" : (pendingRequest ? "pending" : nil), forKey: .status) + } } struct FollowCounts: Codable { diff --git a/InterlinedList/Models/GitHub.swift b/InterlinedList/Models/GitHub.swift new file mode 100644 index 0000000..b7914e5 --- /dev/null +++ b/InterlinedList/Models/GitHub.swift @@ -0,0 +1,100 @@ +// +// GitHub.swift +// InterlinedList +// + +import Foundation + +/// A repository returned by `GET /api/github/repos`, which forwards the raw +/// GitHub REST array. GitHub uses snake_case on the wire (`full_name`, +/// `html_url`) and a nested `owner.login`. These types are decoded through +/// `APIClient`'s `convertFromSnakeCase` decoder, which rewrites incoming keys +/// BEFORE `CodingKeys` are matched — so `full_name` arrives as `fullName`, +/// `html_url` as `htmlUrl`, while single-word keys like `private`/`owner` are +/// unchanged. The `CodingKeys` below therefore use the CONVERTED names. +/// Decode defensively: only `fullName` is required. +struct GitHubRepo: Codable, Identifiable, Hashable { + let fullName: String + let name: String? + let isPrivate: Bool? + let ownerLogin: String? + + var id: String { fullName } + + /// The `owner` portion ("octocat/Hello-World" → "octocat"). + var owner: String { + ownerLogin ?? String(fullName.prefix(while: { $0 != "/" })) + } + + /// The repo portion ("octocat/Hello-World" → "Hello-World"). + var repo: String { + if let name, !name.isEmpty { return name } + guard let slash = fullName.firstIndex(of: "/") else { return fullName } + return String(fullName[fullName.index(after: slash)...]) + } + + // convertFromSnakeCase already mapped full_name → fullName; `private` and + // `owner` are single words and pass through unchanged. + enum CodingKeys: String, CodingKey { + case fullName + case name + case isPrivate = "private" + case owner + } + + private enum OwnerKeys: String, CodingKey { + case login + } + + init(fullName: String, name: String?, isPrivate: Bool?, ownerLogin: String?) { + self.fullName = fullName + self.name = name + self.isPrivate = isPrivate + self.ownerLogin = ownerLogin + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + fullName = try container.decode(String.self, forKey: .fullName) + name = try container.decodeIfPresent(String.self, forKey: .name) + isPrivate = try container.decodeIfPresent(Bool.self, forKey: .isPrivate) + if let ownerContainer = try? container.nestedContainer(keyedBy: OwnerKeys.self, forKey: .owner) { + ownerLogin = try ownerContainer.decodeIfPresent(String.self, forKey: .login) + } else { + ownerLogin = nil + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(fullName, forKey: .fullName) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(isPrivate, forKey: .isPrivate) + if let ownerLogin { + var ownerContainer = container.nestedContainer(keyedBy: OwnerKeys.self, forKey: .owner) + try ownerContainer.encode(ownerLogin, forKey: .login) + } + } +} + +/// Derived metadata attached to a `source == "github"` list by `GET /api/lists`. +/// `refreshStatus` values seen from the backend: "idle", "pending", "failed" +/// (also tolerate "syncing"/"error" from other deployments). All fields optional +/// so a list serialized without this block still decodes. +struct GitHubListMeta: Codable, Hashable { + let lastRefreshedAt: String? + let refreshStatus: String? + let refreshError: String? +} + +/// A single GitHub issue from `GET /api/github/issues` (raw GitHub REST array). +/// Only the fields the read-only issues view needs; decode defensively. +/// `html_url` arrives as `htmlUrl` after the client's convertFromSnakeCase. +struct GitHubIssue: Codable, Identifiable, Hashable { + let number: Int + let title: String + let state: String? + let htmlUrl: String? + + var id: Int { number } +} diff --git a/InterlinedList/Models/LinkedInPostingTarget.swift b/InterlinedList/Models/LinkedInPostingTarget.swift new file mode 100644 index 0000000..2542d0a --- /dev/null +++ b/InterlinedList/Models/LinkedInPostingTarget.swift @@ -0,0 +1,41 @@ +// +// LinkedInPostingTarget.swift +// InterlinedList +// + +import Foundation + +/// One selectable LinkedIn destination returned by GET /api/linkedin/posting-targets. +/// Distinct from `LinkedInTarget` (the compact union sent in a post body): this is the +/// display-oriented option carrying a label/avatar and an `enabled` flag. +struct LinkedInPostingTarget: Codable, Identifiable, Equatable { + let kind: String + let label: String + let avatarUrl: String? + let pageId: String? + let personalPageId: String? + let linkedInPageId: String? + let enabled: Bool + + var id: String { pageId ?? personalPageId ?? kind } + + /// Maps this option to the compact posting union sent in a message body. + var asTarget: LinkedInTarget { + switch kind { + case "orgPage": + if let pageId { return .orgPage(pageId: pageId) } + return .personal() + case "personalPage": + if let personalPageId { return .personalPage(personalPageId: personalPageId) } + return .personal() + default: + return .personal() + } + } +} + +/// Wrapper for the posting-targets endpoint response. +struct LinkedInPostingTargetsResponse: Codable { + let targets: [LinkedInPostingTarget]? + let orgScopeMissing: Bool? +} diff --git a/InterlinedList/Models/List.swift b/InterlinedList/Models/List.swift index 6815e6d..812eb87 100644 --- a/InterlinedList/Models/List.swift +++ b/InterlinedList/Models/List.swift @@ -123,26 +123,51 @@ struct UserList: Identifiable, Codable, Hashable { let id: String let name: String let description: String? - let folderId: String? + /// Parent list id for list-in-list nesting (backend `parentId` column). + let parentId: String? let isPublic: Bool? let createdAt: String let updatedAt: String? let itemCount: Int? - - // Server sends "title" for name and "parentId" for the list-in-list hierarchy. - // convertFromSnakeCase is bypassed when CodingKeys are present, so use exact JSON keys. + /// "github" for GitHub-backed lists, "local" (or nil on older data) otherwise. + let source: String? + /// "owner/repo" for a GitHub-backed list; nil for local lists. + let githubRepo: String? + /// Refresh metadata for GitHub-backed lists (only present on `GET /api/lists`). + let githubMeta: GitHubListMeta? + + /// True when this list mirrors a GitHub repository's issues. + var isGitHubBacked: Bool { source == "github" } + + // Server sends "title" for name and "parentId" for list-in-list nesting. + // convertFromSnakeCase is bypassed when CodingKeys are present, so spell the + // exact JSON keys (the bare cases resolve to their own names, i.e. "parentId"). + // source/githubRepo/githubMeta arrive camelCase (backend `serialize` preserves keys). enum CodingKeys: String, CodingKey { case id, description, createdAt, updatedAt, itemCount, isPublic case name = "title" - case folderId = "parentId" + case parentId + case source, githubRepo, githubMeta } -} -struct ListFolder: Identifiable, Codable, Equatable { - let id: String - let name: String - let parentId: String? - let createdAt: String? + // Explicit memberwise init keeps the GitHub fields optional at call sites + // (previews, tests) without forcing every constructor to pass them. + init(id: String, name: String, description: String?, parentId: String? = nil, + isPublic: Bool?, createdAt: String, updatedAt: String?, + itemCount: Int?, source: String? = nil, githubRepo: String? = nil, + githubMeta: GitHubListMeta? = nil) { + self.id = id + self.name = name + self.description = description + self.parentId = parentId + self.isPublic = isPublic + self.createdAt = createdAt + self.updatedAt = updatedAt + self.itemCount = itemCount + self.source = source + self.githubRepo = githubRepo + self.githubMeta = githubMeta + } } struct ListItem: Identifiable, Codable { @@ -157,29 +182,18 @@ struct ListItem: Identifiable, Codable { struct ListTreeNode: Identifiable { let id: String let name: String - var children: [ListTreeNode]? // nil = list leaf, non-nil = folder + var children: [ListTreeNode]? // nil = leaf list, non-nil = parent with child lists let list: UserList? - static func buildTree(folders: [ListFolder], lists: [UserList]) -> [ListTreeNode] { - let knownFolderIds = Set(folders.map { $0.id }) + static func buildTree(lists: [UserList]) -> [ListTreeNode] { let knownListIds = Set(lists.map { $0.id }) - func folderNode(_ folder: ListFolder) -> ListTreeNode { - let childFolders = folders - .filter { !($0.parentId ?? "").isEmpty && $0.parentId == folder.id } - .map { folderNode($0) } - let childLists = lists - .filter { !($0.folderId ?? "").isEmpty && $0.folderId == folder.id } - .map { listNode($0) } - return ListTreeNode(id: folder.id, name: folder.name, children: childFolders + childLists, list: nil) - } - // Builds a node for a list, recursing into child lists (parentId → this list's id). // API data is assumed acyclic; guard against any circular edge by ignoring a child - // whose id equals the ancestor's id. + // whose id equals the ancestor's. func listNode(_ list: UserList) -> ListTreeNode { let children = lists.filter { child in - guard let pid = child.folderId, !pid.isEmpty else { return false } + guard let pid = child.parentId, !pid.isEmpty else { return false } return pid == list.id && child.id != list.id }.map { listNode($0) } return ListTreeNode(id: list.id, name: list.name, @@ -187,16 +201,13 @@ struct ListTreeNode: Identifiable { list: list) } - let rootFolders = folders.filter { ($0.parentId ?? "").isEmpty }.map { folderNode($0) } - // Root lists: no parentId, orphaned parent (parent not in this response), - // or parentId points to a folder (handled by folderNode above). - let rootLists = lists.filter { - let fid = $0.folderId ?? "" - if fid.isEmpty { return true } - if knownFolderIds.contains(fid) { return false } - return !knownListIds.contains(fid) + // A list shows at root unless it nests under a known parent list; an empty or + // orphaned parentId falls through to root (CLAUDE.md: parentId may arrive ""). + let rootLists = lists.filter { list in + if let pid = list.parentId, !pid.isEmpty, knownListIds.contains(pid) { return false } + return true }.map { listNode($0) } - return rootFolders + rootLists + return rootLists } } @@ -206,17 +217,34 @@ struct ListsResponse: Decodable { let lists: [UserList] } -struct FoldersResponse: Decodable { - let folders: [ListFolder] -} - // MARK: - List connections +/// A list's relationship to another list, from the perspective of one side of a +/// connection. The backend stores a directed edge `fromList → toList`; by +/// convention (matching the web ERD) the `fromList` is the parent of the `toList`. +enum ListRelationship: Equatable { + case parent + case child +} + struct ListConnection: Identifiable, Codable { let id: String - let sourceListId: String - let targetListId: String + let fromListId: String + let toListId: String + let label: String? let createdAt: String? + + /// The id of the list on the other end of this connection, relative to `listId`. + func otherListId(relativeTo listId: String) -> String { + fromListId == listId ? toListId : fromListId + } + + /// From `listId`'s perspective, is the other list its parent or its child? + /// `fromList` is the parent, so if `listId` is the `fromList` the other side + /// is its child; otherwise the other side is its parent. + func relationship(relativeTo listId: String) -> ListRelationship { + fromListId == listId ? .child : .parent + } } struct ConnectionsResponse: Decodable { diff --git a/InterlinedList/Models/ListWatcher.swift b/InterlinedList/Models/ListWatcher.swift index 432d196..9abc489 100644 --- a/InterlinedList/Models/ListWatcher.swift +++ b/InterlinedList/Models/ListWatcher.swift @@ -20,9 +20,13 @@ enum WatcherRole: String, Codable, CaseIterable, Comparable { } } - var detail: String { + var detail: String { detail(for: "list") } + + /// Role description scoped to the resource being shared (e.g. "list" or + /// "document"), so a document's share sheet doesn't say "list". + func detail(for resourceNoun: String) -> String { switch self { - case .watcher: return "Can view this list" + case .watcher: return "Can view this \(resourceNoun)" case .collaborator: return "Can add and edit rows" case .manager: return "Can edit the schema and manage access" } diff --git a/InterlinedList/Models/Message.swift b/InterlinedList/Models/Message.swift index 4603549..3ea8663 100644 --- a/InterlinedList/Models/Message.swift +++ b/InterlinedList/Models/Message.swift @@ -118,14 +118,38 @@ struct Pagination: Codable { let hasMore: Bool } -/// A single cross-post target on LinkedIn (personal profile or an organization page). +/// A single cross-post destination on LinkedIn, encoded as the backend's +/// discriminated union (`resolve-linkedin-target.ts`): +/// { kind: "personal" } +/// { kind: "orgPage", pageId } // pageId = OrgLinkedInPage.id (uuid) +/// { kind: "personalPage", personalPageId } // personalPageId = LinkedInPersonalPage.id (uuid) +/// Nil page ids are omitted from the encoded body so `personal` sends only `kind`. struct LinkedInTarget: Codable, Equatable { - let kind: String // "personal" | "organization" - let organizationId: String? + let kind: String + let pageId: String? + let personalPageId: String? - init(kind: String, organizationId: String? = nil) { + init(kind: String, pageId: String? = nil, personalPageId: String? = nil) { self.kind = kind - self.organizationId = organizationId + self.pageId = pageId + self.personalPageId = personalPageId + } + + static func personal() -> LinkedInTarget { LinkedInTarget(kind: "personal") } + static func orgPage(pageId: String) -> LinkedInTarget { LinkedInTarget(kind: "orgPage", pageId: pageId) } + static func personalPage(personalPageId: String) -> LinkedInTarget { + LinkedInTarget(kind: "personalPage", personalPageId: personalPageId) + } + + private enum CodingKeys: String, CodingKey { + case kind, pageId, personalPageId + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: .kind) + try container.encodeIfPresent(pageId, forKey: .pageId) + try container.encodeIfPresent(personalPageId, forKey: .personalPageId) } } diff --git a/InterlinedList/Models/ShareInvite.swift b/InterlinedList/Models/ShareInvite.swift new file mode 100644 index 0000000..3362ca0 --- /dev/null +++ b/InterlinedList/Models/ShareInvite.swift @@ -0,0 +1,39 @@ +// +// ShareInvite.swift +// InterlinedList +// + +import Foundation + +/// An email-bound invite to a list or document. The owner sends an invite to an +/// email address; the recipient claims it on the **web** (claim is session-only, +/// so iOS never handles the inbound side). iOS only sends, lists, and revokes +/// invites on behalf of the owner. `role` maps to `WatcherRole`, like `ShareLink`. +struct ShareInvite: Identifiable, Codable { + let token: String + let email: String + let role: String + let expiresAt: String? + let accepted: Bool? + let createdAt: String? + + var id: String { token } + + var shareRole: WatcherRole? { WatcherRole(rawValue: role) } +} + +struct ShareInvitesResponse: Decodable { + let invites: [ShareInvite] +} + +/// The 201 response from creating an invite: `{ email, role, expiresAt, url }`. +/// Distinct from `ShareInvite` because the create route returns a claim `url` +/// (to share out-of-band) but no `token`/`accepted`/`createdAt`. +struct CreateShareInviteResponse: Decodable { + let email: String + let role: String + let expiresAt: String? + let url: String + + var shareRole: WatcherRole? { WatcherRole(rawValue: role) } +} diff --git a/InterlinedList/Models/ShareLink.swift b/InterlinedList/Models/ShareLink.swift new file mode 100644 index 0000000..9ba5832 --- /dev/null +++ b/InterlinedList/Models/ShareLink.swift @@ -0,0 +1,65 @@ +// +// ShareLink.swift +// InterlinedList +// + +import Foundation + +/// Which resource a share-link or collaborator path targets. The raw value is +/// the API path segment (`/api/lists/...` vs `/api/documents/...`). +enum ShareResourceKind: String { + case lists + case documents + + var pathSegment: String { rawValue } + + var singularLabel: String { + switch self { + case .lists: return "list" + case .documents: return "document" + } + } +} + +/// A tokenized share-link for a list or document. Only the resource owner can +/// create, list, or revoke these. `role` maps to `WatcherRole`. +struct ShareLink: Identifiable, Codable { + let token: String + let url: String + let role: String + let expiresAt: String? + let createdAt: String? + let revokedAt: String? + + var id: String { token } + + var shareRole: WatcherRole? { WatcherRole(rawValue: role) } +} + +struct ShareLinksResponse: Decodable { + let shareLinks: [ShareLink] +} + +/// A per-person collaborator on a document. Mirrors `ListWatcher` for the +/// list side, but the collaborator identity fields are flattened here. +struct DocumentCollaborator: Identifiable, Codable { + let userId: String + let role: String + let username: String? + let displayName: String? + let avatar: String? + + var id: String { userId } + + var collaboratorRole: WatcherRole? { WatcherRole(rawValue: role) } + + var displayNameOrUsername: String { + if let displayName, !displayName.isEmpty { return displayName } + return username ?? "User" + } +} + +struct DocumentCollaboratorsResponse: Decodable { + let collaborators: [DocumentCollaborator] + let pagination: Pagination? +} diff --git a/InterlinedList/Models/SharedResolution.swift b/InterlinedList/Models/SharedResolution.swift new file mode 100644 index 0000000..ceecea3 --- /dev/null +++ b/InterlinedList/Models/SharedResolution.swift @@ -0,0 +1,22 @@ +// +// SharedResolution.swift +// InterlinedList +// + +import Foundation + +/// Result of resolving a document share-link token via +/// `GET /api/documents/shared/:token` (G10). The read view is available to anyone +/// with the link; `role` is `watcher`/`collaborator`/`manager`. Edit-capable links +/// (`collaborator`/`manager`) require a session-cookie **claim** (`needsAuth`) that +/// the Bearer-only iOS client can't perform — so on iOS these resolve to a +/// read-only reader with a note to claim on the web. +struct SharedDocumentResolution: Codable { + let role: String? + let canClaim: Bool? + let needsAuth: Bool? + let document: Document + + /// Whether the link grants edit rights (vs. a plain viewer link). + var isEditLink: Bool { role == "collaborator" || role == "manager" } +} diff --git a/InterlinedList/Models/TagDiscovery.swift b/InterlinedList/Models/TagDiscovery.swift new file mode 100644 index 0000000..7520994 --- /dev/null +++ b/InterlinedList/Models/TagDiscovery.swift @@ -0,0 +1,28 @@ +// +// TagDiscovery.swift +// InterlinedList +// + +import Foundation + +/// A trending tag from `GET /api/tags/trending` — a tag used on public messages, +/// with its occurrence count and most-recent use within the queried window. +/// Tags carry no leading `#` (matching the feed's `tag:` filter), so callers add +/// their own presentation prefix. +struct TrendingTag: Codable, Identifiable, Equatable { + let tag: String + let count: Int + /// ISO-8601 timestamp of the most recent public message carrying this tag. + /// Optional for decoding resilience; always present from the live endpoint. + let lastUsedAt: String? + + var id: String { tag } +} + +/// A single tag-autocomplete suggestion from `GET /api/tags/autocomplete`. +struct TagSuggestion: Codable, Identifiable, Equatable { + let tag: String + let count: Int + + var id: String { tag } +} diff --git a/InterlinedList/Models/User.swift b/InterlinedList/Models/User.swift index 1f01834..27fde94 100644 --- a/InterlinedList/Models/User.swift +++ b/InterlinedList/Models/User.swift @@ -26,6 +26,9 @@ struct User: Codable, Identifiable { /// grants subscriber access. Optional because older API deployments /// may omit the field. let customerStatus: String? + /// The user's default GitHub repo ("owner/repo") for GitHub-backed lists, + /// or nil if none is set. Serialized camelCase by the API. + let githubDefaultRepo: String? var displayNameOrUsername: String { displayName?.isEmpty == false ? (displayName ?? username) : username @@ -34,6 +37,29 @@ struct User: Codable, Identifiable { var isSubscriber: Bool { customerStatus?.hasPrefix("subscriber") == true } + + // Explicit memberwise init defaults `githubDefaultRepo` so existing call + // sites (previews, tests) compile without supplying it. + init(id: String, email: String, username: String, displayName: String?, + avatar: String?, bio: String?, theme: String?, emailVerified: Bool?, + createdAt: String?, maxMessageLength: Int?, showAdvancedPostSettings: Bool?, + defaultPubliclyVisible: Bool?, customerStatus: String?, + githubDefaultRepo: String? = nil) { + self.id = id + self.email = email + self.username = username + self.displayName = displayName + self.avatar = avatar + self.bio = bio + self.theme = theme + self.emailVerified = emailVerified + self.createdAt = createdAt + self.maxMessageLength = maxMessageLength + self.showAdvancedPostSettings = showAdvancedPostSettings + self.defaultPubliclyVisible = defaultPubliclyVisible + self.customerStatus = customerStatus + self.githubDefaultRepo = githubDefaultRepo + } } struct UserResponse: Codable { diff --git a/InterlinedList/Models/UserSession.swift b/InterlinedList/Models/UserSession.swift new file mode 100644 index 0000000..d9a2ccc --- /dev/null +++ b/InterlinedList/Models/UserSession.swift @@ -0,0 +1,39 @@ +// +// UserSession.swift +// InterlinedList +// + +import Foundation + +struct UserSession: Identifiable, Decodable { + let id: String + let deviceLabel: String? + let createdAt: String? + let lastUsedAt: String? + let isCurrent: Bool + + enum CodingKeys: String, CodingKey { + case id, deviceLabel, createdAt, lastUsedAt, isCurrent + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + deviceLabel = try container.decodeIfPresent(String.self, forKey: .deviceLabel) + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) + lastUsedAt = try container.decodeIfPresent(String.self, forKey: .lastUsedAt) + isCurrent = (try? container.decode(Bool.self, forKey: .isCurrent)) ?? false + } + + init(id: String, deviceLabel: String?, createdAt: String?, lastUsedAt: String?, isCurrent: Bool) { + self.id = id + self.deviceLabel = deviceLabel + self.createdAt = createdAt + self.lastUsedAt = lastUsedAt + self.isCurrent = isCurrent + } +} + +struct UserSessionsResponse: Decodable { + let sessions: [UserSession] +} diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index 1b447a3..2fd8075 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -18,10 +18,15 @@ enum APIError: Error { /// 409 — the request conflicts with existing data (e.g. deleting a list /// property that still has row values without `?force=true`). case conflict(String) + /// 429 — rate limited. Distinct from `.status(429)` so callers (e.g. the + /// document sync push) can back off and **retry** rather than surface a hard + /// failure. `retryAfter` is the `Retry-After` header in seconds when present. + case rateLimited(retryAfter: TimeInterval?) } enum ExportType: String, CaseIterable { case messages, lists, follows + case listDataRows = "list-data-rows" } final class APIClient { @@ -190,6 +195,15 @@ final class APIClient { return try await get("/api/auth/twitter/status") } + // MARK: - LinkedIn posting targets + + /// Available LinkedIn destinations (personal profile, org pages, personal company + /// pages) each flagged `enabled` per the user's saved preferences. + func linkedInPostingTargets() async throws -> [LinkedInPostingTarget] { + let response: LinkedInPostingTargetsResponse = try await get("/api/linkedin/posting-targets") + return response.targets ?? [] + } + // MARK: - Avatar upload (Phase 3 — sister agent dependency) func uploadAvatar(data: Data, mimeType: String) async throws -> User { @@ -266,6 +280,25 @@ final class APIClient { return (response.messages, response.pagination) } + /// Fetches a single message by id (for content deep links). The endpoint may + /// wrap the message under `message`/`data` or return it bare; tolerate all three. + func message(id: String) async throws -> Message { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + struct WrappedMessage: Decodable { let message: Message? } + struct WrappedData: Decodable { let data: Message? } + let data = try await getRawData("/api/messages/\(encoded)") + if let wrapped = try? decoder.decode(WrappedMessage.self, from: data), let msg = wrapped.message { + return msg + } + if let wrapped = try? decoder.decode(WrappedData.self, from: data), let msg = wrapped.data { + return msg + } + if let msg = try? decoder.decode(Message.self, from: data) { + return msg + } + throw APIError.noData + } + /// Result of creating a message — the created message plus any cross-post /// outcomes the server reported (empty when cross-posting wasn't requested or /// the deployment doesn't echo results). @@ -355,7 +388,7 @@ final class APIClient { struct Body: Encodable { let content: String; let publiclyVisible: Bool? } struct Response: Decodable { let data: Message? } let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - let response: Response = try await put("/api/messages/\(encoded)", body: Body(content: content, publiclyVisible: publiclyVisible)) + let response: Response = try await patchCamel("/api/messages/\(encoded)", body: Body(content: content, publiclyVisible: publiclyVisible)) guard let message = response.data else { throw APIError.noData } return message } @@ -393,10 +426,9 @@ final class APIClient { // MARK: - Lists - func listsAndFolders() async throws -> (folders: [ListFolder], lists: [UserList]) { - let foldersResponse: FoldersResponse = try await get("/api/folders") + func lists() async throws -> [UserList] { let listsResponse: ListsResponse = try await get("/api/lists") - return (foldersResponse.folders, listsResponse.lists) + return listsResponse.lists } func listItems(listId: String) async throws -> [ListItem] { @@ -415,14 +447,45 @@ final class APIClient { return response.data.properties } + /// The structured GitHub source for a github-backed list create + /// (`githubSource: { owner, repo }`, camelCase). The backend also accepts an + /// optional `path`/`ref`, omitted here. + struct GitHubSource: Encodable { + let owner: String + let repo: String + } + /// Creates a list. `schema` is the DSL object the create endpoint requires - /// (see `ListSchemaDSL`); pass nil to omit it. A list needs at least one column - /// to be usable, so the create UI always supplies one. The endpoint returns the - /// created list under `data`, not `list`. - func createList(title: String, description: String?, isPublic: Bool, schema: ListSchemaDSL? = nil) async throws -> UserList { - struct Body: Encodable { let title: String; let description: String?; let isPublic: Bool; let schema: ListSchemaDSL? } + /// for local lists (see `ListSchemaDSL`); pass nil to omit it. A local list + /// needs at least one column to be usable, so the create UI always supplies one. + /// For a GitHub-backed list pass `githubSource`; the backend ignores `schema` + /// in that case (issues drive the columns), so the caller should pass `schema: nil`. + /// The endpoint returns the created list under `data`, not `list`. + func createList(title: String, description: String?, isPublic: Bool, schema: ListSchemaDSL? = nil, githubSource: GitHubSource? = nil) async throws -> UserList { + struct Body: Encodable { + let title: String + let description: String? + let isPublic: Bool + let schema: ListSchemaDSL? + let githubSource: GitHubSource? + + enum CodingKeys: String, CodingKey { + case title, description, isPublic, schema, githubSource + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(title, forKey: .title) + try c.encode(description, forKey: .description) + try c.encode(isPublic, forKey: .isPublic) + try c.encodeIfPresent(schema, forKey: .schema) + try c.encodeIfPresent(githubSource, forKey: .githubSource) + } + } struct Response: Decodable { let data: UserList? } - let response: Response = try await postCamel("/api/lists", body: Body(title: title, description: description, isPublic: isPublic, schema: schema)) + let body = Body(title: title, description: description, isPublic: isPublic, + schema: githubSource == nil ? schema : nil, githubSource: githubSource) + let response: Response = try await postCamel("/api/lists", body: body) guard let list = response.data else { throw APIError.noData } return list } @@ -496,6 +559,47 @@ final class APIClient { return response.documents } + /// Offline sync pull. With `lastSyncAt` it returns a **delta** since that + /// cursor; without it, the **full** folder/document state. Rows may carry a + /// non-nil `deletedAt` tombstone. The response `lastSyncAt` is the next cursor. + func documentSync(lastSyncAt: String? = nil) async throws -> DocumentSyncResponse { + var path = "/api/documents/sync" + if let cursor = lastSyncAt, !cursor.isEmpty { + var components = URLComponents() + components.queryItems = [URLQueryItem(name: "lastSyncAt", value: cursor)] + if let query = components.percentEncodedQuery { + path += "?" + query + } + } + return try await get(path) + } + + /// Offline sync push. Sends the queued `operations` (camelCase body + /// `{ "operations": [...] }`) and returns the response `lastSyncAt` cursor. + /// Per-op errors are swallowed server-side (the response only echoes the + /// cursor), so callers must PULL afterwards to reconcile. Maps 429 to + /// `APIError.rateLimited` so the caller can back off and keep the outbox. + func pushDocumentSync(operations: [SyncOperation]) async throws -> String { + struct Body: Encodable { let operations: [SyncOperation] } + struct Response: Decodable { let lastSyncAt: String? } + guard let url = URL(string: baseURL + "/api/documents/sync") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + request.httpBody = try camelCaseEncoder.encode(Body(operations: operations)) + let (data, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse, http.statusCode == 429 { + let retryAfter = (http.value(forHTTPHeaderField: "Retry-After")).flatMap(TimeInterval.init) + throw APIError.rateLimited(retryAfter: retryAfter) + } + try checkResponse(data: data, response: response) + let decoded = try decoder.decode(Response.self, from: data) + guard let cursor = decoded.lastSyncAt else { throw APIError.noData } + return cursor + } + func createDocument(title: String, content: String?, isPublic: Bool, folderId: String?) async throws -> Document { // The folder is chosen by the *path*, not a body field: `POST /api/documents` always // creates at root (it has no folderId field), so a document "created in a folder" via @@ -575,11 +679,54 @@ final class APIClient { return (response.documents, response.pagination) } + // MARK: - Document templates (G3) + + /// Starter templates a subscriber can copy from. Free for any authenticated + /// user to read; only creating from one is subscriber-gated. + func documentTemplates() async throws -> [DocumentTemplate] { + let response: DocumentTemplatesResponse = try await get("/api/documents/templates") + return response.templates + } + + /// Copies a template into a new document (subscriber-only). Body is camelCase + /// (`templateDocumentId`, `targetFolderId`) — pass nil to create at root. The + /// endpoint may wrap the document or return it bare; tolerate both like createDocument. + func createDocumentFromTemplate(templateDocumentId: String, targetFolderId: String?) async throws -> Document { + struct Body: Encodable { let templateDocumentId: String; let targetFolderId: String? } + struct Response: Decodable { let document: Document? } + let body = Body(templateDocumentId: templateDocumentId, targetFolderId: targetFolderId) + let data = try await postCamelRawData("/api/documents/from-template", body: body) + if let wrapped = try? decoder.decode(Response.self, from: data), let doc = wrapped.document { + return doc + } + if let doc = try? decoder.decode(Document.self, from: data) { + return doc + } + throw APIError.noData + } + + /// The backend reads camelCase (`isPublic`) directly from the body, so this uses + /// the camelCase encoder. Nil fields are omitted (left unchanged). func updateList(id: String, title: String?, description: String?, isPublic: Bool?) async throws -> UserList { - struct Body: Encodable { let title: String?; let description: String?; let isPublic: Bool? } + struct Body: Encodable { + let title: String? + let description: String? + let isPublic: Bool? + + enum CodingKeys: String, CodingKey { + case title, description, isPublic + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encodeIfPresent(title, forKey: .title) + try c.encodeIfPresent(description, forKey: .description) + try c.encodeIfPresent(isPublic, forKey: .isPublic) + } + } struct Response: Decodable { let list: UserList? } let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - let response: Response = try await put("/api/lists/\(encoded)", body: Body(title: title, description: description, isPublic: isPublic)) + let response: Response = try await putCamel("/api/lists/\(encoded)", body: Body(title: title, description: description, isPublic: isPublic)) guard let list = response.list else { throw APIError.noData } return list } @@ -594,39 +741,42 @@ final class APIClient { return response.properties ?? [] } - func createListFolder(name: String, parentId: String?) async throws -> ListFolder { - struct Body: Encodable { let name: String; let parentId: String? } - struct Response: Decodable { let folder: ListFolder? } - let response: Response = try await post("/api/folders", body: Body(name: name, parentId: parentId)) - guard let folder = response.folder else { throw APIError.noData } - return folder + func searchLists(q: String, limit: Int = 20, offset: Int = 0) async throws -> ([UserList], Pagination?) { + let qEncoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q + struct Response: Decodable { let lists: [UserList]; let pagination: Pagination? } + let response: Response = try await get("/api/lists/search?q=\(qEncoded)&limit=\(limit)&offset=\(offset)") + return (response.lists, response.pagination) } - func updateListFolder(id: String, name: String?, parentId: String?) async throws -> ListFolder { - struct Body: Encodable { let name: String?; let parentId: String? } - struct Response: Decodable { let folder: ListFolder? } - let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - let response: Response = try await put("/api/folders/\(encoded)", body: Body(name: name, parentId: parentId)) - guard let folder = response.folder else { throw APIError.noData } - return folder + // MARK: - GitHub-backed lists (G4) + + /// Repositories the linked GitHub account can access (`GET /api/github/repos`, + /// Bearer). The endpoint forwards the raw GitHub REST array (not wrapped). + /// Returns 400 "GitHub account not linked" when no GitHub identity is linked. + func githubRepos() async throws -> [GitHubRepo] { + return try await get("/api/github/repos") } - func deleteListFolder(id: String) async throws { - let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - guard let url = URL(string: baseURL + "/api/folders/\(encoded)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + /// Open (or `state`) issues for a repo (`GET /api/github/issues?repo=owner/repo`, + /// Bearer). Raw GitHub REST array; decode defensively. + func githubIssues(repo: String, state: String = "open") async throws -> [GitHubIssue] { + var components = URLComponents(string: baseURL + "/api/github/issues") + components?.queryItems = [ + URLQueryItem(name: "repo", value: repo), + URLQueryItem(name: "state", value: state), + ] + let query = components?.percentEncodedQuery.map { "?" + $0 } ?? "" + return try await get("/api/github/issues" + query) } - func searchLists(q: String, limit: Int = 20, offset: Int = 0) async throws -> ([UserList], Pagination?) { - let qEncoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q - struct Response: Decodable { let lists: [UserList]; let pagination: Pagination? } - let response: Response = try await get("/api/lists/search?q=\(qEncoded)&limit=\(limit)&offset=\(offset)") - return (response.lists, response.pagination) + /// Re-syncs a GitHub-backed list's cached rows from GitHub issues + /// (`POST /api/lists/:id/refresh`, Bearer). 400 if the list isn't github-backed + /// or its repo is missing. + func refreshList(id: String) async throws { + struct Empty: Encodable {} + struct Response: Decodable { let message: String?; let count: Int? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let _: Response = try await postCamel("/api/lists/\(encoded)/refresh", body: Empty()) } // MARK: - Image upload @@ -713,6 +863,82 @@ final class APIClient { return response.lists } + /// Prefix search over usernames/display names (G6). The backend rejects a + /// blank `q` with 400 `missing_query`, so callers must not pass an empty query. + /// Reuses `FollowUser` — the response rows share its id/username/displayName/avatar shape. + func searchUsers(query: String, limit: Int = 20) async throws -> [FollowUser] { + struct Response: Decodable { let users: [FollowUser] } + var components = URLComponents(string: baseURL + "/api/users/search") + components?.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "limit", value: String(limit)), + ] + // percentEncodedQuery escapes reserved characters (e.g. `&` in the query + // string) so a query like "a&b" doesn't split into spurious parameters. + let encodedQuery = components?.percentEncodedQuery.map { "?" + $0 } ?? "" + let response: Response = try await get("/api/users/search" + encodedQuery) + return response.users + } + + // MARK: - Tag discovery (G13) + + /// Most-used tags across public messages within a trailing window. + /// `window` is one of `day`/`week`/`month` (backend defaults unknown values to + /// `week`); `limit` is clamped server-side to ≤100. + func trendingTags(window: String = "week", limit: Int = 20) async throws -> [TrendingTag] { + struct Response: Decodable { let tags: [TrendingTag] } + var components = URLComponents(string: baseURL + "/api/tags/trending") + components?.queryItems = [ + URLQueryItem(name: "window", value: window), + URLQueryItem(name: "limit", value: String(limit)), + ] + let encodedQuery = components?.percentEncodedQuery.map { "?" + $0 } ?? "" + let response: Response = try await get("/api/tags/trending" + encodedQuery) + return response.tags + } + + /// Tags on public messages matching a case-insensitive literal prefix. A leading + /// `#` is stripped server-side; a blank prefix is rejected with 400, so callers + /// should guard against empty input. `limit` is clamped server-side to ≤50. + func tagAutocomplete(query: String, limit: Int = 10) async throws -> [TagSuggestion] { + struct Response: Decodable { let tags: [TagSuggestion] } + var components = URLComponents(string: baseURL + "/api/tags/autocomplete") + components?.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "limit", value: String(limit)), + ] + let encodedQuery = components?.percentEncodedQuery.map { "?" + $0 } ?? "" + let response: Response = try await get("/api/tags/autocomplete" + encodedQuery) + return response.tags + } + + // MARK: - Link preview (G14) + + /// Fetches Open Graph / link metadata for a single URL so the composer can show a + /// live preview before a post is created. The endpoint always returns a terminal + /// item (`fetchStatus` `success`|`failed`, never pending) and is rate-limited + /// (429). Callers should treat a 429 or a `failed` status as "no preview" rather + /// than a hard error. + func linkMetadata(url: String) async throws -> LinkMetadataItem { + struct Response: Decodable { let link: LinkMetadataItem } + var components = URLComponents(string: baseURL + "/api/link-metadata") + components?.queryItems = [URLQueryItem(name: "url", value: url)] + let encodedQuery = components?.percentEncodedQuery.map { "?" + $0 } ?? "" + let response: Response = try await get("/api/link-metadata" + encodedQuery) + return response.link + } + + // MARK: - Share-link resolvers (G10) + + /// Resolves a document share-link token to a read-only view. Auth is optional on + /// this endpoint (anonymous read works); a Bearer token, if present, is ignored + /// for the read. Throws `.server`/`.status(404)` for an unknown/expired/revoked + /// token and `.status(429)` when rate-limited. + func resolveSharedDocument(token: String) async throws -> SharedDocumentResolution { + let t = token.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? token + return try await get("/api/documents/shared/\(t)") + } + // MARK: - Notifications func notifications() async throws -> NotificationsResponse { @@ -723,7 +949,7 @@ final class APIClient { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id struct Empty: Encodable {} struct OkResponse: Decodable { let ok: Bool } - let _: OkResponse = try await put("/api/notifications/\(encoded)/read", body: Empty()) + let _: OkResponse = try await patch("/api/notifications/\(encoded)/read", body: Empty()) } func markAllNotificationsRead() async throws { @@ -786,7 +1012,7 @@ final class APIClient { struct Body: Encodable { let displayName: String?; let bio: String?; let defaultVisibility: Bool? } struct WrappedResponse: Decodable { let user: User? } let body = Body(displayName: displayName, bio: bio, defaultVisibility: defaultVisibility) - let wrapped: WrappedResponse = try await post("/api/user/update", body: body) + let wrapped: WrappedResponse = try await patchCamel("/api/user/update", body: body) if let user = wrapped.user { return user } return try await currentUser() } @@ -801,7 +1027,7 @@ final class APIClient { } struct WrappedResponse: Decodable { let user: User? } let body = Body(theme: theme, defaultVisibility: defaultVisibility, showAdvancedPostSettings: showAdvancedPostSettings) - let wrapped: WrappedResponse = try await post("/api/user/update", body: body) + let wrapped: WrappedResponse = try await patchCamel("/api/user/update", body: body) if let user = wrapped.user { return user } return try await currentUser() } @@ -839,12 +1065,13 @@ final class APIClient { return response.connections } - func createListConnection(sourceListId: String, targetListId: String) async throws -> ListConnection { - struct Body: Encodable { let sourceListId: String; let targetListId: String } - struct R: Decodable { let connection: ListConnection? } - let r: R = try await postCamel("/api/lists/connections", - body: Body(sourceListId: sourceListId, targetListId: targetListId)) - guard let conn = r.connection else { throw APIError.noData } + /// Creates a directed connection `fromList → toList`. By convention the + /// `fromList` is the parent of the `toList`. The backend responds with the + /// raw connection object (not wrapped). + func createListConnection(fromListId: String, toListId: String) async throws -> ListConnection { + struct Body: Encodable { let fromListId: String; let toListId: String } + let conn: ListConnection = try await postCamel("/api/lists/connections", + body: Body(fromListId: fromListId, toListId: toListId)) return conn } @@ -937,9 +1164,14 @@ final class APIClient { let _: Response = try await postCamel("/api/lists/\(encoded)/watchers", body: Empty()) } - func searchWatcherCandidates(listId: String, limit: Int = 20, offset: Int = 0) async throws -> [WatcherCandidate] { + func searchWatcherCandidates(listId: String, limit: Int = 20, offset: Int = 0, search: String? = nil) async throws -> [WatcherCandidate] { let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId - let response: WatcherCandidatesResponse = try await get("/api/lists/\(encoded)/watchers/users?limit=\(limit)&offset=\(offset)") + var path = "/api/lists/\(encoded)/watchers/users?limit=\(limit)&offset=\(offset)" + if let search, !search.isEmpty { + let q = search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? search + path += "&search=\(q)" + } + let response: WatcherCandidatesResponse = try await get(path) return response.users } @@ -974,6 +1206,109 @@ final class APIClient { try checkResponse(data: data, response: response) } + // MARK: - Sharing (G2): share-links & document collaborators + + func shareLinks(kind: ShareResourceKind, id: String) async throws -> [ShareLink] { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response: ShareLinksResponse = try await get("/api/\(kind.pathSegment)/\(encoded)/share-links") + return response.shareLinks + } + + func createShareLink(kind: ShareResourceKind, id: String, role: WatcherRole = .watcher, expiresAt: String? = nil) async throws -> ShareLink { + struct Body: Encodable { let role: String; let expiresAt: String? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return try await postCamel("/api/\(kind.pathSegment)/\(encoded)/share-links", body: Body(role: role.rawValue, expiresAt: expiresAt)) + } + + func revokeShareLink(kind: ShareResourceKind, id: String, token: String) async throws { + let encodedId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? token + guard let url = URL(string: baseURL + "/api/\(kind.pathSegment)/\(encodedId)/share-links/\(encodedToken)") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + + // MARK: - Sharing: email share-invites (owner-only send/list/revoke) + + /// Owner-side invites for a list or document. The recipient claims the invite + /// on the web (session-only), so iOS only ever sends, lists, and revokes. + func shareInvites(kind: ShareResourceKind, id: String) async throws -> [ShareInvite] { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response: ShareInvitesResponse = try await get("/api/\(kind.pathSegment)/\(encoded)/invites") + return response.invites + } + + /// Sends an email invite (subscriber-gated, rate-limited server-side). Body is + /// camelCase (`email`, `role`, `expiresAt`); `expiresAt` is omitted when nil. + func createShareInvite(kind: ShareResourceKind, id: String, email: String, role: WatcherRole = .watcher, expiresAt: String? = nil) async throws -> CreateShareInviteResponse { + struct Body: Encodable { let email: String; let role: String; let expiresAt: String? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return try await postCamel("/api/\(kind.pathSegment)/\(encoded)/invites", body: Body(email: email, role: role.rawValue, expiresAt: expiresAt)) + } + + func revokeShareInvite(kind: ShareResourceKind, id: String, token: String) async throws { + let encodedId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? token + guard let url = URL(string: baseURL + "/api/\(kind.pathSegment)/\(encodedId)/invites/\(encodedToken)") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + + func documentCollaborators(id: String) async throws -> [DocumentCollaborator] { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response: DocumentCollaboratorsResponse = try await get("/api/documents/\(encoded)/collaborators") + return response.collaborators + } + + @discardableResult + func addDocumentCollaborator(id: String, userId: String, role: WatcherRole = .watcher, notify: Bool = false) async throws -> DocumentCollaborator { + struct Body: Encodable { let userId: String; let role: String; let notify: Bool } + struct Response: Decodable { let collaborator: DocumentCollaborator? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response: Response = try await postCamel("/api/documents/\(encoded)/collaborators", body: Body(userId: userId, role: role.rawValue, notify: notify)) + guard let collaborator = response.collaborator else { + return DocumentCollaborator(userId: userId, role: role.rawValue, username: nil, displayName: nil, avatar: nil) + } + return collaborator + } + + @discardableResult + func setDocumentCollaboratorRole(id: String, userId: String, role: WatcherRole, notify: Bool = false) async throws -> String { + struct Body: Encodable { let role: String; let notify: Bool } + struct Response: Decodable { let role: String? } + let encodedId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + let response: Response = try await putCamel("/api/documents/\(encodedId)/collaborators/\(encodedUser)", body: Body(role: role.rawValue, notify: notify)) + return response.role ?? role.rawValue + } + + func removeDocumentCollaborator(id: String, userId: String) async throws { + let encodedId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + guard let url = URL(string: baseURL + "/api/documents/\(encodedId)/collaborators/\(encodedUser)") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + + func searchDocumentCollaboratorCandidates(id: String, query: String) async throws -> [WatcherCandidate] { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let q = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + let response: WatcherCandidatesResponse = try await get("/api/documents/\(encoded)/collaborators/users?q=\(q)") + return response.users + } + // MARK: - Public browse (Phase 7) func publicListDetail(username: String, listId: String) async throws -> PublicListDetail { @@ -1162,6 +1497,24 @@ final class APIClient { return try await get("/api/user/mutes?limit=\(limit)&offset=\(offset)") } + // MARK: - Active sessions (G12) + + func userSessions() async throws -> [UserSession] { + let response: UserSessionsResponse = try await get("/api/user/sessions") + return response.sessions + } + + func revokeSession(id: String) async throws { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + guard let url = URL(string: baseURL + "/api/user/sessions/\(encoded)") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + // MARK: - Push notifications (Phase 9) func registerPushDevice(token: String) async throws { @@ -1183,6 +1536,101 @@ final class APIClient { try checkResponse(data: data, response: response) } + // MARK: - Direct messages + + /// Lists messages in a DM folder. `nextCursor` paginates further into that folder. + func directMessages(folder: DMFolder, cursor: String? = nil) async throws -> DMListResponse { + var components = URLComponents(string: baseURL + "/api/dm") + components?.queryItems = [URLQueryItem(name: "folder", value: folder.rawValue)] + if let cursor { components?.queryItems?.append(URLQueryItem(name: "cursor", value: cursor)) } + let query = components?.percentEncodedQuery.map { "?" + $0 } ?? "" + return try await get("/api/dm" + query) + } + + /// Sends a direct message. Body is 1–10000 chars; camelCase keys (`recipientId`, + /// `imageUrls`) — snake_case would be dropped server-side. + @discardableResult + func sendDirectMessage(recipientId: String, body: String, imageUrls: [String] = []) async throws -> DMMessage { + struct Body: Encodable { let recipientId: String; let body: String; let imageUrls: [String] } + let response: DMMessageResponse = try await postCamel("/api/dm", body: Body(recipientId: recipientId, body: body, imageUrls: imageUrls)) + return response.message + } + + func directMessage(id: String) async throws -> DMMessage { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response: DMMessageResponse = try await get("/api/dm/\(encoded)") + return response.message + } + + @discardableResult + func markDMRead(id: String) async throws -> Int { + struct Empty: Encodable {} + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response: DMUpdatedResponse = try await post("/api/dm/\(encoded)/read", body: Empty()) + return response.updated + } + + func trashDM(id: String) async throws { + struct Empty: Encodable {} + struct Response: Decodable { let ok: Bool? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let _: Response = try await post("/api/dm/\(encoded)/trash", body: Empty()) + } + + func restoreDM(id: String) async throws { + struct Empty: Encodable {} + struct Response: Decodable { let ok: Bool? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let _: Response = try await post("/api/dm/\(encoded)/restore", body: Empty()) + } + + /// Users you may DM (mutual-follow set). + func dmRecipients() async throws -> [DMUser] { + let response: DMRecipientsResponse = try await get("/api/dm/recipients") + return response.recipients + } + + /// The conversation with `username`. Opening a thread auto-marks received messages read. + func dmThread(username: String) async throws -> DMThread { + let encoded = username.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? username + return try await get("/api/dm/thread/\(encoded)") + } + + /// New messages in the thread since `after` (the client's last known message id). + /// Auto-marks received messages read. + func dmThreadUpdates(username: String, after: String) async throws -> DMThread { + let encodedUser = username.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? username + let encodedAfter = after.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? after + return try await get("/api/dm/thread/\(encodedUser)/updates?after=\(encodedAfter)") + } + + func dmUnreadCount() async throws -> Int { + let response: DMUnreadCountResponse = try await get("/api/dm/unread-count") + return response.count + } + + /// Uploads a DM image (multipart field `file`). Requires a verified email — not a subscription. + func uploadDMImage(data: Data, mimeType: String) async throws -> String { + guard let url = URL(string: baseURL + "/api/dm/images/upload") else { throw APIError.invalidURL } + let boundary = UUID().uuidString + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let ext = mimeType == "image/png" ? "png" : "jpg" + var body = Data() + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"upload.\(ext)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + body.append(data) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + let (responseData, response) = try await session.data(for: request) + try checkResponse(data: responseData, response: response) + struct UploadResponse: Decodable { let url: String } + return try decoder.decode(UploadResponse.self, from: responseData).url + } + // MARK: - Private helpers private func getRawData(_ path: String) async throws -> Data { @@ -1195,6 +1643,19 @@ final class APIClient { return data } + private func postCamelRawData(_ path: String, body: B) async throws -> Data { + guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + request.httpBody = try camelCaseEncoder.encode(body) + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + return data + } + private func perform(_ request: URLRequest) async throws -> T { let method = request.httpMethod ?? "GET" let path = request.url?.path ?? "" diff --git a/InterlinedList/Services/AppDataStore.swift b/InterlinedList/Services/AppDataStore.swift index b90685a..7332a81 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -11,7 +11,6 @@ final class AppDataStore: ObservableObject { @Published private(set) var feedLoading = true @Published private(set) var feedError: String? - @Published private(set) var listFolders: [ListFolder] = [] @Published private(set) var userLists: [UserList] = [] @Published private(set) var listsLoading = true @Published private(set) var listsError: String? @@ -23,15 +22,68 @@ final class AppDataStore: ObservableObject { @Published private(set) var unreadCount = 0 @Published private(set) var pendingRequestCount = 0 + @Published private(set) var dmUnreadCount = 0 private let cache = DataCache() private var userId: String? + /// Narrow API surface for the offline document sync cycle (push + pull), so + /// the store can be unit-tested with a mock without touching the singleton's + /// feed/lists/counts paths. Defaults to `APIClient.shared`. + private let syncAPI: DocumentSyncAPI + + init(syncAPI: DocumentSyncAPI = APIClient.shared) { + self.syncAPI = syncAPI + } + + /// G9 Slice 1. When true, documents load from a persisted `DocumentSyncState` + /// and refresh via `GET /api/documents/sync` (delta pull + tombstones) instead + /// of `GET /api/documents` + `documentFolders()`. Defaults true — one call + /// returns the whole tree vs. the root-only documents endpoint. Overridable + /// via Info.plist key `ILOfflineDocSync` (set to NO to keep the online path). + private let offlineDocSyncEnabled: Bool = { + if let flag = Bundle.main.infoDictionary?["ILOfflineDocSync"] as? Bool { + return flag + } + if let str = Bundle.main.infoDictionary?["ILOfflineDocSync"] as? String { + return (str as NSString).boolValue + } + return true + }() + + private var docSyncCursor: String? + + /// Slice 2 offline write path. Pending create/update/delete ops (coalesced + /// one-per-id) and the per-id local sync state, persisted alongside the + /// documents so edits survive an app restart while offline. + private var docSyncOutbox: [SyncOperation] = [] + private var docLocalStates: [String: LocalSyncState] = [:] + + /// Slice 3 baselines: per-doc server `updatedAt` at the last point the doc was + /// `.synced`. Conflict detection compares a pull row's `updatedAt` to this. + private var docBaselines: [String: String] = [:] + + /// Ids of documents with un-pushed edits — drives the optional "pending sync" + /// affordance in the UI. + @Published private(set) var pendingSyncDocIds: Set = [] + + /// Slice 3. Documents edited elsewhere while we held un-pushed local edits; + /// their server version was kept as a conflict copy. Drives a dismissible + /// banner in `DocumentsView`. Empty when there are no unresolved conflicts. + @Published private(set) var syncConflicts: [SyncConflictNotice] = [] + + func dismissSyncConflicts() { syncConflicts = [] } + + private var reachability: NetworkReachability? + private var pushInFlight = false + private var pushRequested = false + func prefetchAll(userId: String?) async { if let uid = userId, self.userId != uid { self.userId = uid await loadFromCache(userId: uid) } + startReachabilityIfNeeded() await withTaskGroup(of: Void.self) { group in group.addTask { await self.refreshFeed() } group.addTask { await self.refreshLists() } @@ -40,6 +92,15 @@ final class AppDataStore: ObservableObject { } } + private func startReachabilityIfNeeded() { + guard offlineDocSyncEnabled, reachability == nil else { return } + let monitor = NetworkReachability() + monitor.start { [weak self] in + Task { @MainActor [weak self] in await self?.pushOutbox() } + } + reachability = monitor + } + func onUserIdAvailable(_ id: String) { guard userId != id else { return } userId = id @@ -69,9 +130,7 @@ final class AppDataStore: ObservableObject { listsError = nil defer { listsLoading = false } do { - let result = try await APIClient.shared.listsAndFolders() - listFolders = result.folders - userLists = result.lists + userLists = try await APIClient.shared.lists() saveListsCache() } catch APIError.status(401) { } catch APIError.server(let msg) { @@ -82,6 +141,10 @@ final class AppDataStore: ObservableObject { } func refreshDocuments() async { + if offlineDocSyncEnabled { + await refreshDocumentsViaSync() + return + } documentsLoading = documents.isEmpty documentsError = nil defer { documentsLoading = false } @@ -101,6 +164,154 @@ final class AppDataStore: ObservableObject { } } + private func refreshDocumentsViaSync() async { + documentsLoading = documents.isEmpty + documentsError = nil + defer { documentsLoading = false } + await syncCycle() + } + + /// One sync cycle (Slice 3, **pull-first**): pull the delta, detect and + /// resolve conflicts (conflict-copy), merge while protecting the conflicting + /// dirty docs, **then** push the outbox (local edits + new conflict copies). + /// Pull-first is required so the server's newer version is seen and preserved + /// *before* a local push (which is last-writer-wins) could clobber it. + /// Serialized via `pushInFlight` so overlapping triggers (foreground + + /// reconnect + edit) don't overlap. + private func syncCycle() async { + guard offlineDocSyncEnabled else { return } + if pushInFlight { + pushRequested = true + return + } + pushInFlight = true + defer { + pushInFlight = false + if pushRequested { + pushRequested = false + Task { await syncCycle() } + } + } + await pullDocuments() + await drainOutbox() + } + + /// Trigger point exposed to the app (foreground / reconnect / after a local + /// edit). Runs a full pull-then-push cycle. + func pushOutbox() async { + await syncCycle() + } + + private func pullDocuments() async { + do { + let delta = try await syncAPI.documentSync(lastSyncAt: docSyncCursor) + var state = currentSyncState() + + let conflicts = DocumentSyncConflict.detectConflicts( + delta: delta, dirtyIds: state.dirtyIds, baselines: state.baselines) + resolveConflicts(conflicts, into: &state) + + let protectedIds = Set(conflicts.map { $0.id }) + let merged = DocumentSyncMerge.apply(delta: delta, to: state, protectingIds: protectedIds) + state.folders = merged.folders + state.documents = merged.documents + state.lastSyncAt = merged.lastSyncAt + + recordBaselines(from: delta, protectedIds: protectedIds, into: &state) + + applySyncState(state) + saveDocsSyncCache() + } catch APIError.status(401) { + } catch APIError.status(429) { + // Rate limited — keep cached state and skip this cycle. + } catch APIError.rateLimited { + // Rate limited — keep cached state and skip this cycle. + } catch { + if documents.isEmpty && documentFolders.isEmpty { + documentsError = "Failed to load documents." + } + } + } + + /// For each conflict, keep the local (dirty) doc live and preserve the SERVER + /// version as a new conflict-copy document: insert it, enqueue its `create` + /// op, and record a banner notice. Real `Date()`/`UUID()` live here; the pure + /// shape is built by `DocumentSyncConflict`. + private func resolveConflicts(_ conflicts: [ConflictInfo], into state: inout DocumentSyncState) { + guard !conflicts.isEmpty else { return } + let now = Date() + var notices: [SyncConflictNotice] = [] + for conflict in conflicts { + let copy = DocumentSyncConflict.makeConflictCopy( + server: conflict.serverDocument, date: now, newId: UUID().uuidString) + if !state.documents.contains(where: { $0.id == copy.document.id }) { + state.documents.insert(copy.document, at: 0) + } + DocumentSyncOutbox.enqueue(copy.operation, into: &state) + let liveTitle = state.documents.first { $0.id == conflict.id }?.title + ?? conflict.serverDocument.title + notices.append(SyncConflictNotice(id: conflict.id, + originalTitle: liveTitle, + copyTitle: copy.document.title)) + } + syncConflicts.append(contentsOf: notices) + } + + /// After a pull, baseline every delta document (conflicting or not) at the + /// server `updatedAt` we just saw. Non-conflicting rows were merged in; + /// conflicting rows kept their local copy live but we advance their baseline + /// to the now-seen server version so the *same* server edit isn't detected as + /// a conflict twice (only a genuinely newer server edit re-triggers). Deletes + /// drop the baseline. + private func recordBaselines(from delta: DocumentSyncResponse, + protectedIds: Set, + into state: inout DocumentSyncState) { + for document in delta.documents { + if document.deletedAt != nil { + if !protectedIds.contains(document.id) { + state.baselines[document.id] = nil + } + } else if let updatedAt = document.updatedAt { + state.baselines[document.id] = updatedAt + } + } + } + + private func drainOutbox() async { + guard !docSyncOutbox.isEmpty else { return } + let payload = docSyncOutbox + do { + let cursor = try await syncAPI.pushDocumentSync(operations: payload) + var state = currentSyncState() + DocumentSyncOutbox.clearOutbox(payload, from: &state) + state.lastSyncAt = cursor + recordPushBaselines(payload, cursor: cursor, into: &state) + applySyncState(state) + saveDocsSyncCache() + } catch APIError.status(401) { + // Auth is handled elsewhere; keep the outbox for replay. + } catch APIError.rateLimited { + // Back off: keep the outbox and skip the pull this cycle. + } catch { + // Offline / server error: keep the outbox for the next trigger. + } + } + + /// After a successful push, baseline each pushed create/update at the push + /// cursor (the server's post-push `updatedAt` for those rows) and drop the + /// baseline for deletes. Keeps conflict detection accurate on the next pull. + private func recordPushBaselines(_ pushed: [SyncOperation], cursor: String, + into state: inout DocumentSyncState) { + for op in pushed { + switch op.op { + case .create, .update: + state.baselines[op.data.id] = cursor + case .delete: + state.baselines[op.data.id] = nil + } + } + } + func refreshCounts() async { await withTaskGroup(of: Void.self) { group in group.addTask { @@ -113,9 +324,132 @@ final class AppDataStore: ObservableObject { await MainActor.run { self.pendingRequestCount = requests.count } } } + group.addTask { + if let count = try? await APIClient.shared.dmUnreadCount() { + await MainActor.run { self.dmUnreadCount = count } + } + } } } + func refreshDMUnread() async { + if let count = try? await APIClient.shared.dmUnreadCount() { + dmUnreadCount = count + } + } + + // MARK: - Offline-aware document mutations (Slice 2) + + /// True when writes should route through the outbox rather than the online + /// document endpoints. Views branch on this to keep the flag-OFF path byte-for-byte. + var offlineDocumentWritesEnabled: Bool { offlineDocSyncEnabled } + + /// Applies a create optimistically to `documents`, enqueues a `create` op, and + /// triggers a push. Returns the client-synthesized `Document` (with a + /// client-generated `id`) for the UI to navigate to. Flag-ON only. + func createDocumentOffline(title: String, content: String?, isPublic: Bool, folderId: String?) -> Document { + let normalizedFolderId = (folderId?.isEmpty == true) ? nil : folderId + let now = ISO8601DateFormatter().string(from: Date()) + let doc = Document(id: UUID().uuidString, title: title, content: content, + folderId: normalizedFolderId, isPublic: isPublic, + createdAt: now, updatedAt: now) + documents.insert(doc, at: 0) + enqueue(SyncOperation(op: .create, type: .document, + data: SyncOpData(id: doc.id, folderId: normalizedFolderId, + title: title, content: content, isPublic: isPublic))) + return doc + } + + /// Applies an edit optimistically to `documents` and enqueues an `update` op. + func updateDocumentOffline(id: String, title: String, content: String?, isPublic: Bool, folderId: String?) -> Document { + let normalizedFolderId = (folderId?.isEmpty == true) ? nil : folderId + let now = ISO8601DateFormatter().string(from: Date()) + let existing = documents.first { $0.id == id } + let updated = Document(id: id, title: title, content: content, + folderId: normalizedFolderId, isPublic: isPublic, + createdAt: existing?.createdAt, updatedAt: now) + if let idx = documents.firstIndex(where: { $0.id == id }) { + documents[idx] = updated + } else { + documents.insert(updated, at: 0) + } + enqueue(SyncOperation(op: .update, type: .document, + data: SyncOpData(id: id, folderId: normalizedFolderId, + title: title, content: content, isPublic: isPublic))) + return updated + } + + func deleteDocumentOffline(id: String) { + documents.removeAll { $0.id == id } + enqueue(SyncOperation(op: .delete, type: .document, data: SyncOpData(id: id))) + } + + func createDocumentFolderOffline(name: String, parentId: String?) -> DocumentFolder { + let normalizedParentId = (parentId?.isEmpty == true) ? nil : parentId + let now = ISO8601DateFormatter().string(from: Date()) + let folder = DocumentFolder(id: UUID().uuidString, name: name, + parentId: normalizedParentId, updatedAt: now) + documentFolders.append(folder) + enqueue(SyncOperation(op: .create, type: .folder, + data: SyncOpData(id: folder.id, parentId: normalizedParentId, name: name))) + return folder + } + + func deleteDocumentFolderOffline(id: String) { + // Cascade locally the way the server would (subfolders + docs inside). + let removedFolderIds = descendantFolderIds(of: id) + documentFolders.removeAll { removedFolderIds.contains($0.id) } + documents.removeAll { doc in + guard let fid = doc.folderId, !fid.isEmpty else { return false } + return removedFolderIds.contains(fid) + } + enqueue(SyncOperation(op: .delete, type: .folder, data: SyncOpData(id: id))) + } + + private func descendantFolderIds(of rootId: String) -> Set { + var result: Set = [rootId] + var changed = true + while changed { + changed = false + for folder in documentFolders { + if let parent = folder.parentId, result.contains(parent), !result.contains(folder.id) { + result.insert(folder.id) + changed = true + } + } + } + return result + } + + private func enqueue(_ op: SyncOperation) { + var state = currentSyncState() + DocumentSyncOutbox.enqueue(op, into: &state) + applySyncState(state) + saveDocsSyncCache() + Task { await debouncedPush() } + } + + private func debouncedPush() async { + try? await Task.sleep(nanoseconds: 400_000_000) + await pushOutbox() + } + + private func currentSyncState() -> DocumentSyncState { + DocumentSyncState(folders: documentFolders, documents: documents, + lastSyncAt: docSyncCursor, outbox: docSyncOutbox, + localStates: docLocalStates, baselines: docBaselines) + } + + private func applySyncState(_ state: DocumentSyncState) { + documentFolders = state.folders + documents = state.documents + docSyncCursor = state.lastSyncAt + docSyncOutbox = state.outbox + docLocalStates = state.localStates + docBaselines = state.baselines + pendingSyncDocIds = state.dirtyIds + } + // MARK: - Optimistic mutations func insertFeedMessage(_ message: Message) { @@ -124,20 +458,38 @@ final class AppDataStore: ObservableObject { } func removeList(id: String) { userLists.removeAll { $0.id == id }; saveListsCache() } - func removeListFolder(id: String) { listFolders.removeAll { $0.id == id }; saveListsCache() } - func insertDocument(_ doc: Document) { documents.insert(doc, at: 0); saveDocsCache() } + /// Idempotent upsert by id — safe to call after `createDocumentOffline` + /// (which already inserted the row) so the flag-ON callbacks don't duplicate. + func insertDocument(_ doc: Document) { + if let idx = documents.firstIndex(where: { $0.id == doc.id }) { + documents[idx] = doc + } else { + documents.insert(doc, at: 0) + } + persistDocs() + } func updateDocument(_ doc: Document) { if let idx = documents.firstIndex(where: { $0.id == doc.id }) { documents[idx] = doc } - saveDocsCache() + persistDocs() + } + func removeDocument(id: String) { documents.removeAll { $0.id == id }; persistDocs() } + func insertDocumentFolder(_ folder: DocumentFolder) { + if !documentFolders.contains(where: { $0.id == folder.id }) { documentFolders.append(folder) } + persistDocs() + } + func removeDocumentFolder(id: String) { documentFolders.removeAll { $0.id == id }; persistDocs() } + + private func persistDocs() { + if offlineDocSyncEnabled { + saveDocsSyncCache() + } else { + saveDocsCache() + } } - func removeDocument(id: String) { documents.removeAll { $0.id == id }; saveDocsCache() } - func insertDocumentFolder(_ folder: DocumentFolder) { documentFolders.append(folder); saveDocsCache() } - func removeDocumentFolder(id: String) { documentFolders.removeAll { $0.id == id }; saveDocsCache() } func reset() { feedMessages = [] - listFolders = [] userLists = [] documentFolders = [] documents = [] @@ -149,6 +501,15 @@ final class AppDataStore: ObservableObject { documentsError = nil unreadCount = 0 pendingRequestCount = 0 + dmUnreadCount = 0 + docSyncCursor = nil + docSyncOutbox = [] + docLocalStates = [:] + docBaselines = [:] + pendingSyncDocIds = [] + syncConflicts = [] + reachability?.stop() + reachability = nil userId = nil } @@ -157,10 +518,19 @@ final class AppDataStore: ObservableObject { private func loadFromCache(userId: String) async { if let msgs: [Message] = await cache.load(key: "\(userId)_feed") { feedMessages = msgs } if let cached: ListsCache = await cache.load(key: "\(userId)_lists") { - listFolders = cached.folders userLists = cached.lists } - if let cached: DocsCache = await cache.load(key: "\(userId)_docs") { + if offlineDocSyncEnabled { + if let state: DocumentSyncState = await cache.load(key: "\(userId)_docsync") { + documentFolders = state.folders + documents = state.documents + docSyncCursor = state.lastSyncAt + docSyncOutbox = state.outbox + docLocalStates = state.localStates + docBaselines = state.baselines + pendingSyncDocIds = state.dirtyIds + } + } else if let cached: DocsCache = await cache.load(key: "\(userId)_docs") { documentFolders = cached.folders documents = cached.documents } @@ -174,7 +544,7 @@ final class AppDataStore: ObservableObject { private func saveListsCache() { guard let uid = userId else { return } - let snapshot = ListsCache(folders: listFolders, lists: userLists) + let snapshot = ListsCache(lists: userLists) Task { await cache.save(snapshot, key: "\(uid)_lists") } } @@ -183,10 +553,24 @@ final class AppDataStore: ObservableObject { let snapshot = DocsCache(folders: documentFolders, documents: documents) Task { await cache.save(snapshot, key: "\(uid)_docs") } } + + private func saveDocsSyncCache() { + guard let uid = userId else { return } + let snapshot = currentSyncState() + Task { await cache.save(snapshot, key: "\(uid)_docsync") } + } } +/// The two `/api/documents/sync` calls the offline cycle needs. Kept narrow (ISP) +/// so `AppDataStore` can be tested against a mock without the full `APIClient`. +protocol DocumentSyncAPI { + func documentSync(lastSyncAt: String?) async throws -> DocumentSyncResponse + func pushDocumentSync(operations: [SyncOperation]) async throws -> String +} + +extension APIClient: DocumentSyncAPI {} + private struct ListsCache: Codable { - let folders: [ListFolder] let lists: [UserList] } diff --git a/InterlinedList/Services/AuthState.swift b/InterlinedList/Services/AuthState.swift index 4d6df20..a96d970 100644 --- a/InterlinedList/Services/AuthState.swift +++ b/InterlinedList/Services/AuthState.swift @@ -10,6 +10,9 @@ final class AuthState: ObservableObject { @Published private(set) var user: User? @Published private(set) var isRestoring = true @Published private(set) var hasToken: Bool = false + /// Provider types (e.g. "github", "mastodon") linked to this account, or nil + /// until first loaded. Populated lazily via `loadLinkedProvidersIfNeeded()`. + @Published private(set) var linkedProviders: Set? private let api = APIClient.shared @@ -90,11 +93,30 @@ final class AuthState: ObservableObject { } } + /// True once linked providers have been loaded and include GitHub. Returns + /// false while unloaded, so callers should trigger `loadLinkedProvidersIfNeeded()`. + var hasGitHubIdentity: Bool { + linkedProviders?.contains("github") == true + } + + /// Loads the account's linked provider types once (cached until logout). + /// Failures leave `linkedProviders` unset so a later call can retry. + func loadLinkedProvidersIfNeeded() async { + guard linkedProviders == nil else { return } + do { + let identities = try await api.linkedIdentities() + linkedProviders = Set(identities.map { $0.providerType }) + } catch { + // Leave nil to allow retry; callers treat absence as "no known GitHub". + } + } + func logout() { _ = KeychainService.deleteToken() api.setBearerToken(nil) user = nil hasToken = false + linkedProviders = nil } func updateUser(_ updated: User) { diff --git a/InterlinedList/Services/DocumentSyncConflict.swift b/InterlinedList/Services/DocumentSyncConflict.swift new file mode 100644 index 0000000..b0c62c7 --- /dev/null +++ b/InterlinedList/Services/DocumentSyncConflict.swift @@ -0,0 +1,89 @@ +// +// DocumentSyncConflict.swift +// InterlinedList +// + +import Foundation + +/// One detected conflict: a locally `.dirty` document whose server `updatedAt` +/// (carried in a fresh pull delta) is newer than the baseline we last synced it +/// at — i.e. someone edited it while we held un-pushed local changes. The +/// `serverDocument` is the still-intact server version we preserve as a copy. +struct ConflictInfo: Equatable { + let id: String + let serverDocument: Document +} + +/// The conflict copy plus the `create` op that enqueues it, produced together so +/// the caller inserts and enqueues one consistent unit. +struct ConflictCopy: Equatable { + let document: Document + let operation: SyncOperation +} + +/// Pure, deterministic conflict logic for the offline document sync cycle. No +/// I/O and no ambient `Date.now()`/`UUID()` — the caller injects the copy's date +/// and id so this is fully testable. Policy: **conflict-copy** — keep the local +/// edit live, and preserve the server version as a new document so nothing is lost. +enum DocumentSyncConflict { + + /// Detects conflicts in a pull `delta`: each delta document that is currently + /// `dirty` locally AND whose server `updatedAt` is strictly newer than the + /// baseline recorded for that id. Tombstoned rows (`deletedAt != nil`) and + /// rows missing `updatedAt` are not conflicts. A dirty id with no recorded + /// baseline (e.g. a purely local create the server hasn't acknowledged) is + /// not a conflict — there is no shared history to diverge from. + static func detectConflicts(delta: DocumentSyncResponse, + dirtyIds: Set, + baselines: [String: String]) -> [ConflictInfo] { + var conflicts: [ConflictInfo] = [] + for document in delta.documents { + guard document.deletedAt == nil else { continue } + guard dirtyIds.contains(document.id) else { continue } + guard let baseline = baselines[document.id] else { continue } + guard let serverUpdatedAt = document.updatedAt else { continue } + if serverUpdatedAt > baseline { + conflicts.append(ConflictInfo(id: document.id, serverDocument: document)) + } + } + return conflicts + } + + /// Builds a conflict copy of a **server** document: a NEW document carrying a + /// caller-supplied `newId`, a suffixed title, and the server's + /// `content`/`folderId`/`isPublic`. Emits the paired `create` `SyncOperation` + /// so the copy pushes to the server on the next drain. + static func makeConflictCopy(server: Document, date: Date, newId: String) -> ConflictCopy { + let title = conflictCopyTitle(original: server.title, date: date) + let now = ISO8601DateFormatter().string(from: date) + let document = Document(id: newId, + title: title, + content: server.content, + folderId: server.folderId, + isPublic: server.isPublic, + createdAt: now, + updatedAt: now) + let operation = SyncOperation(op: .create, type: .document, + data: SyncOpData(id: newId, + folderId: server.folderId, + title: title, + content: server.content, + isPublic: server.isPublic)) + return ConflictCopy(document: document, operation: operation) + } + + /// `" (conflicted copy <yyyy-MM-dd>)"`. + static func conflictCopyTitle(original: String, date: Date) -> String { + "\(original) (conflicted copy \(conflictDateString(date)))" + } + + private static func conflictDateString(_ date: Date) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone.current + let c = calendar.dateComponents([.year, .month, .day], from: date) + let year = c.year ?? 0 + let month = c.month ?? 0 + let day = c.day ?? 0 + return String(format: "%04d-%02d-%02d", year, month, day) + } +} diff --git a/InterlinedList/Services/DocumentSyncMerge.swift b/InterlinedList/Services/DocumentSyncMerge.swift new file mode 100644 index 0000000..a53e8ec --- /dev/null +++ b/InterlinedList/Services/DocumentSyncMerge.swift @@ -0,0 +1,67 @@ +// +// DocumentSyncMerge.swift +// InterlinedList +// + +import Foundation + +/// Pure, deterministic merge of a `GET /api/documents/sync` delta into a cached +/// `DocumentSyncState`. No I/O. For each delta row: a non-nil `deletedAt` +/// removes the row by `id` (tombstone); otherwise the row is upserted by `id`. +/// The delta's `lastSyncAt` becomes the new cursor. An empty delta is a no-op +/// except that a non-nil cursor still advances. +enum DocumentSyncMerge { + /// Merges a pull `delta` into `state`. Document rows whose id is in + /// `protectingIds` (Slice-3 conflicting dirty docs whose local edit must stay + /// live) are left untouched — neither upserted nor tombstoned — so the server + /// version never clobbers the local one. Folders are never protected. The + /// default empty set preserves the pre-Slice-3 last-writer-wins behavior. + static func apply(delta: DocumentSyncResponse, + to state: DocumentSyncState, + protectingIds: Set<String> = []) -> DocumentSyncState { + var folders = state.folders + var documents = state.documents + + for folder in delta.folders { + if folder.deletedAt != nil { + folders.removeAll { $0.id == folder.id } + } else { + upsert(folder, into: &folders) + } + } + + for document in delta.documents { + if protectingIds.contains(document.id) { continue } + if document.deletedAt != nil { + documents.removeAll { $0.id == document.id } + } else { + upsert(document, into: &documents) + } + } + + return DocumentSyncState( + folders: folders, + documents: documents, + lastSyncAt: delta.lastSyncAt ?? state.lastSyncAt, + outbox: state.outbox, + localStates: state.localStates, + baselines: state.baselines + ) + } + + private static func upsert(_ folder: DocumentFolder, into folders: inout [DocumentFolder]) { + if let idx = folders.firstIndex(where: { $0.id == folder.id }) { + folders[idx] = folder + } else { + folders.append(folder) + } + } + + private static func upsert(_ document: Document, into documents: inout [Document]) { + if let idx = documents.firstIndex(where: { $0.id == document.id }) { + documents[idx] = document + } else { + documents.append(document) + } + } +} diff --git a/InterlinedList/Services/DocumentSyncOutbox.swift b/InterlinedList/Services/DocumentSyncOutbox.swift new file mode 100644 index 0000000..438b748 --- /dev/null +++ b/InterlinedList/Services/DocumentSyncOutbox.swift @@ -0,0 +1,123 @@ +// +// DocumentSyncOutbox.swift +// InterlinedList +// + +import Foundation + +/// Pure, deterministic queue logic for the offline document write path. No I/O. +/// Enqueues create/update/delete ops into a `DocumentSyncState.outbox`, coalescing +/// repeated edits to the same id so the outbox stays a minimal per-id intent: +/// +/// - **create → update…** collapse to a single **create** carrying the latest fields +/// (the row was never on the server, so it should arrive as one create). +/// - **update → update…** collapse to a single **update** carrying the latest fields. +/// - **delete of a not-yet-synced create** cancels **both** (the row never reached +/// the server, so nothing needs to be pushed). +/// - **delete of a synced/updated row** collapses any queued create/update for that +/// id into a single **delete**. +/// +/// `localStates` tracks whether each id is `.dirty` (un-pushed create/update) or +/// `.deleted`. `clearOutbox` marks pushed dirties `.synced`, drops deletes, and +/// empties the queue after a successful `POST /sync`. +enum DocumentSyncOutbox { + + // MARK: - Enqueue + + static func enqueue(_ op: SyncOperation, into state: inout DocumentSyncState) { + let id = op.data.id + switch op.op { + case .create: + replaceOrAppend(op, id: id, into: &state) + state.localStates[id] = .dirty + case .update: + enqueueUpdate(op, id: id, into: &state) + case .delete: + enqueueDelete(op, id: id, into: &state) + } + } + + private static func enqueueUpdate(_ op: SyncOperation, id: String, into state: inout DocumentSyncState) { + if let existing = firstOp(for: id, in: state), existing.op == .create { + // A pending create absorbs the update: push it as a single create with + // the merged (latest) fields. + let merged = SyncOperation(op: .create, type: op.type, path: op.path ?? existing.path, + data: merge(existing.data, into: op.data)) + replaceOrAppend(merged, id: id, into: &state) + } else if let existing = firstOp(for: id, in: state), existing.op == .update { + let merged = SyncOperation(op: .update, type: op.type, path: op.path ?? existing.path, + data: merge(existing.data, into: op.data)) + replaceOrAppend(merged, id: id, into: &state) + } else { + replaceOrAppend(op, id: id, into: &state) + } + state.localStates[id] = .dirty + } + + private static func enqueueDelete(_ op: SyncOperation, id: String, into state: inout DocumentSyncState) { + if let existing = firstOp(for: id, in: state), existing.op == .create { + // The row never reached the server — cancel both create and delete. + state.outbox.removeAll { $0.data.id == id } + state.localStates[id] = nil + return + } + // Drop any queued create/update; the delete supersedes them. + replaceOrAppend(op, id: id, into: &state) + state.localStates[id] = .deleted + } + + // MARK: - Push payload + + /// The `[SyncOperation]` to POST. This is just the current outbox — ops are + /// already coalesced to one-per-id on enqueue. + static func pushPayload(from state: DocumentSyncState) -> [SyncOperation] { + state.outbox + } + + // MARK: - Clear after a successful push + + /// Called after a `200` from `POST /sync`. Drops the pushed ops and settles + /// local states: dirties become `.synced`, deletes are forgotten (the row is + /// already gone locally). + static func clearOutbox(_ pushed: [SyncOperation], from state: inout DocumentSyncState) { + let pushedIds = Set(pushed.map { $0.data.id }) + for op in pushed { + switch op.op { + case .create, .update: + state.localStates[op.data.id] = .synced + case .delete: + state.localStates[op.data.id] = nil + } + } + state.outbox.removeAll { pushedIds.contains($0.data.id) } + } + + // MARK: - Helpers + + private static func firstOp(for id: String, in state: DocumentSyncState) -> SyncOperation? { + state.outbox.first { $0.data.id == id } + } + + private static func replaceOrAppend(_ op: SyncOperation, id: String, into state: inout DocumentSyncState) { + if let idx = state.outbox.firstIndex(where: { $0.data.id == id }) { + state.outbox[idx] = op + } else { + state.outbox.append(op) + } + } + + /// Overlays `newer`'s non-nil fields onto `older`, so a coalesced op carries + /// the latest value of each field the user has touched. + private static func merge(_ older: SyncOpData, into newer: SyncOpData) -> SyncOpData { + SyncOpData( + id: newer.id, + folderId: newer.folderId ?? older.folderId, + parentId: newer.parentId ?? older.parentId, + name: newer.name ?? older.name, + title: newer.title ?? older.title, + content: newer.content ?? older.content, + relativePath: newer.relativePath ?? older.relativePath, + isPublic: newer.isPublic ?? older.isPublic + ) + } +} diff --git a/InterlinedList/Services/ILWebURL.swift b/InterlinedList/Services/ILWebURL.swift new file mode 100644 index 0000000..4ac1761 --- /dev/null +++ b/InterlinedList/Services/ILWebURL.swift @@ -0,0 +1,39 @@ +// +// ILWebURL.swift +// InterlinedList +// + +import Foundation + +/// Builds canonical `interlinedlist.com` web permalinks for shareable content. +/// These are the public web URLs a user can paste anywhere; they are distinct from +/// the app's internal share-link tokens. Path shapes mirror the backend routes: +/// profile `/user/<username>`, message `/message/<id>`, list `/lists/<id>`, +/// document `/documents/<id>`. +enum ILWebURL { + static let base = "https://interlinedlist.com" + + static func profile(_ username: String) -> URL? { + make("/user", username) + } + + static func message(_ id: String) -> URL? { + make("/message", id) + } + + static func list(_ id: String) -> URL? { + make("/lists", id) + } + + static func document(_ id: String) -> URL? { + make("/documents", id) + } + + private static func make(_ prefix: String, _ segment: String) -> URL? { + guard !segment.isEmpty, + let encoded = segment.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + return nil + } + return URL(string: base + prefix + "/" + encoded) + } +} diff --git a/InterlinedList/Services/NetworkReachability.swift b/InterlinedList/Services/NetworkReachability.swift new file mode 100644 index 0000000..55f0268 --- /dev/null +++ b/InterlinedList/Services/NetworkReachability.swift @@ -0,0 +1,51 @@ +// +// NetworkReachability.swift +// InterlinedList +// + +import Foundation +import Network + +/// Lightweight connectivity signal over `NWPathMonitor`. Fires `onReconnect` when +/// connectivity transitions from unsatisfied → satisfied, so the caller can replay +/// a queued outbox. The callback is delivered on the main actor. +@MainActor +final class NetworkReachability { + private let monitor: NWPathMonitor + private let queue = DispatchQueue(label: "com.interlinedlist.reachability") + private var lastSatisfied: Bool? + private var onReconnect: (() -> Void)? + + private(set) var isConnected = true + + init(monitor: NWPathMonitor = NWPathMonitor()) { + self.monitor = monitor + } + + /// Starts monitoring. `onReconnect` fires on each unsatisfied → satisfied edge + /// (not on the initial reading, which just seeds the baseline). + func start(onReconnect: @escaping () -> Void) { + self.onReconnect = onReconnect + monitor.pathUpdateHandler = { [weak self] path in + let satisfied = path.status == .satisfied + Task { @MainActor [weak self] in + self?.handle(satisfied: satisfied) + } + } + monitor.start(queue: queue) + } + + func stop() { + monitor.cancel() + onReconnect = nil + } + + private func handle(satisfied: Bool) { + isConnected = satisfied + defer { lastSatisfied = satisfied } + guard let previous = lastSatisfied else { return } + if satisfied && !previous { + onReconnect?() + } + } +} diff --git a/InterlinedList/Services/OAuthCoordinator.swift b/InterlinedList/Services/OAuthCoordinator.swift index efeff9b..819ab7b 100644 --- a/InterlinedList/Services/OAuthCoordinator.swift +++ b/InterlinedList/Services/OAuthCoordinator.swift @@ -35,12 +35,11 @@ enum OAuthProvider: String, CaseIterable { } /// Whether this provider's OAuth callback supports the native custom-scheme - /// token handoff. GitHub's callback has no mobile branch — it sets a web - /// session cookie and redirects to /dashboard, so it never returns - /// `interlinedlist://oauth/callback?token=…` and can't complete inside - /// `ASWebAuthenticationSession`. Hidden until the backend adds that branch. - /// (Backend auth contract — Open Dependency #1.) - var supportsNativeAuth: Bool { self != .github } + /// token handoff. All providers now return `interlinedlist://oauth/callback?token=…` + /// and complete inside `ASWebAuthenticationSession`. GitHub is enabled pending + /// the backend A1 deploy that adds its mobile branch; until that ships the + /// GitHub button will 401. (Backend auth contract — A1.) + var supportsNativeAuth: Bool { true } } enum OAuthError: Error { diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index f37a369..1c7dab1 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -22,6 +22,7 @@ struct ComposeView: View { var repostOf: Message? = nil @State private var content = "" @State private var tags = "" + @State private var linkPreview: LinkMetadataItem? @State private var publiclyVisible = true @State private var isLoading = false @State private var errorMessage: String? @@ -41,6 +42,11 @@ struct ComposeView: View { @State private var allIdentities: [APIClient.LinkedIdentity] = [] @State private var selectedMastodonIds: Set<String> = [] @State private var identitiesLoaded = false + // LinkedIn posting-target picker (subscriber + LinkedIn identity only) + @State private var linkedInTargets: [LinkedInPostingTarget] = [] + @State private var selectedLinkedInTargetIds: Set<String> = [] + @State private var linkedInTargetsLoaded = false + @State private var linkedInLinkAsFirstComment = false @State private var lastCrossPostResults: [CrossPostResult] = [] /// Destinations the server reported the message actually reached (`crossPostUrls`). /// Used for the post-publish confirmation when the deployment doesn't return the @@ -107,6 +113,17 @@ struct ComposeView: View { } } + if let preview = linkPreview, + preview.fetchStatus == "success", + let meta = preview.metadata { + Section { + LinkPreviewBlock(link: preview, meta: meta) + .accessibilityLabel("Link preview: \(meta.title ?? preview.url)") + } header: { + Text("Link preview") + } + } + if !isReply && !isRepost && !postableOrgs.isEmpty { Section("Post Message As") { Picker("Author", selection: $selectedOrgId) { @@ -166,6 +183,7 @@ struct ComposeView: View { .alert(successTitle, isPresented: $showSuccess) { Button("OK") { content = "" + linkPreview = nil imageUploader.reset() photoSelection = [] uploadedVideoURL = nil @@ -178,6 +196,8 @@ struct ComposeView: View { crossPostLinkedIn = false crossPostTwitter = false selectedMastodonIds = [] + selectedLinkedInTargetIds = Set(enabledLinkedInTargets.map { $0.id }) + linkedInLinkAsFirstComment = false selectedOrgId = nil applyUserDefaults() } @@ -190,6 +210,49 @@ struct ComposeView: View { photoSelection = [] Task { await imageUploader.add(picked) } } + .onChange(of: crossPostLinkedIn) { _, isOn in + guard isOn else { return } + Task { await loadLinkedInTargetsIfNeeded() } + } + .task(id: firstDetectedURL) { + guard let url = firstDetectedURL else { + linkPreview = nil + return + } + // Debounce keystrokes; the endpoint is rate-limited (30/60s). + try? await Task.sleep(nanoseconds: 400_000_000) + guard !Task.isCancelled else { return } + await loadLinkPreview(url) + } + } + } + + // MARK: - Link preview (G14) + + /// The first `http(s)` URL in the draft, used to drive a live preview card. + /// Recomputed on each content change; cheap enough for per-keystroke use. + private var firstDetectedURL: String? { + guard !content.isEmpty, + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + else { return nil } + let range = NSRange(content.startIndex..<content.endIndex, in: content) + guard let match = detector.firstMatch(in: content, options: [], range: range), + let url = match.url, + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { return nil } + return url.absoluteString + } + + private func loadLinkPreview(_ url: String) async { + do { + let item = try await APIClient.shared.linkMetadata(url: url) + guard !Task.isCancelled else { return } + linkPreview = item + } catch { + // Rate-limited (429), fetch failure, or offline → show no card rather + // than surfacing an error; the preview is a non-essential nicety. + linkPreview = nil } } @@ -393,6 +456,9 @@ struct ComposeView: View { Toggle(isOn: $crossPostLinkedIn) { Label("LinkedIn", systemImage: "briefcase") } + if crossPostLinkedIn { + linkedInTargetPicker + } } if hasTwitter { Toggle(isOn: $crossPostTwitter) { @@ -432,6 +498,93 @@ struct ComposeView: View { } } + /// Enabled targets are the only ones the user can post to; disabled options are + /// hidden entirely (they were turned off in the user's web preferences). + private var enabledLinkedInTargets: [LinkedInPostingTarget] { + linkedInTargets.filter { $0.enabled } + } + + @ViewBuilder + private var linkedInTargetPicker: some View { + if !enabledLinkedInTargets.isEmpty { + ForEach(enabledLinkedInTargets) { target in + Button { + toggleLinkedInTarget(target.id) + } label: { + HStack(spacing: 10) { + linkedInAvatar(target.avatarUrl) + Text(target.label) + .font(.ilBody(15)) + .foregroundStyle(.primary) + Spacer() + if selectedLinkedInTargetIds.contains(target.id) { + Image(systemName: "checkmark") + .foregroundStyle(ILColor.primary) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("\(target.label)\(selectedLinkedInTargetIds.contains(target.id) ? ", selected" : "")") + } + Toggle(isOn: $linkedInLinkAsFirstComment) { + Text("Post link as first comment") + .font(.ilBody(15)) + } + .accessibilityLabel("Post link as first comment on LinkedIn") + } else if linkedInTargetsLoaded { + Text("Posting to your personal LinkedIn profile.") + .font(.ilMono()) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func linkedInAvatar(_ url: String?) -> some View { + if let url, let parsed = URL(string: url) { + AsyncImage(url: parsed) { image in + image.resizable().scaledToFill() + } placeholder: { + Image(systemName: "person.crop.circle.fill").foregroundStyle(.secondary) + } + .frame(width: 24, height: 24) + .clipShape(Circle()) + } else { + Image(systemName: "briefcase.circle.fill") + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle(.secondary) + } + } + + private func toggleLinkedInTarget(_ id: String) { + if selectedLinkedInTargetIds.contains(id) { + selectedLinkedInTargetIds.remove(id) + } else { + selectedLinkedInTargetIds.insert(id) + } + } + + /// Selected posting union to send in the body. Falls back to personal when + /// nothing is selected (matches web's post-to-personal-by-default behavior). + private var resolvedLinkedInTargets: [LinkedInTarget] { + let selected = enabledLinkedInTargets.filter { selectedLinkedInTargetIds.contains($0.id) } + if selected.isEmpty { return [.personal()] } + return selected.map { $0.asTarget } + } + + private func loadLinkedInTargetsIfNeeded() async { + guard !linkedInTargetsLoaded, canUseSubscriberFeatures, !isReply, hasLinkedIn else { return } + linkedInTargetsLoaded = true + do { + linkedInTargets = try await APIClient.shared.linkedInPostingTargets() + selectedLinkedInTargetIds = Set(enabledLinkedInTargets.map { $0.id }) + } catch { + // Never block posting — fall back to personal-only at post time. + composeLog.error("linkedInPostingTargets failed: \(error)") + linkedInTargets = [] + } + } + private func mastodonLabel(_ identity: APIClient.LinkedIdentity) -> String { identity.providerUsername ?? "Mastodon account" } @@ -533,6 +686,7 @@ struct ComposeView: View { // Cross-post params only when the user is a subscriber and not replying. let crossPostEnabled = canUseSubscriberFeatures && !isReply let mastodonIds = crossPostEnabled && !selectedMastodonIds.isEmpty ? Array(selectedMastodonIds) : nil + let linkedInOn = crossPostEnabled && crossPostLinkedIn && hasLinkedIn do { let result = try await APIClient.shared.postMessage( content: text, @@ -545,7 +699,9 @@ struct ComposeView: View { pushedMessageId: repostOf?.id, mastodonProviderIds: mastodonIds, crossPostToBluesky: crossPostEnabled && crossPostBluesky ? true : nil, - crossPostToLinkedIn: crossPostEnabled && crossPostLinkedIn ? true : nil, + crossPostToLinkedIn: linkedInOn ? true : nil, + linkedInTargets: linkedInOn ? resolvedLinkedInTargets : nil, + linkedInLinkAsFirstComment: linkedInOn && linkedInLinkAsFirstComment ? true : nil, crossPostToTwitter: crossPostEnabled && crossPostTwitter ? true : nil, organizationId: selectedOrgId ) diff --git a/InterlinedList/Views/CreateListView.swift b/InterlinedList/Views/CreateListView.swift index 4a073b1..e16c01b 100644 --- a/InterlinedList/Views/CreateListView.swift +++ b/InterlinedList/Views/CreateListView.swift @@ -8,6 +8,7 @@ import SwiftUI struct CreateListView: View { let onCreate: (UserList) -> Void + @EnvironmentObject private var authState: AuthState @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var description = "" @@ -16,14 +17,27 @@ struct CreateListView: View { @State private var isLoading = false @State private var errorMessage: String? + @State private var githubBacked = false + @State private var repos: [GitHubRepo] = [] + @State private var selectedRepo: String? + @State private var reposLoading = false + @State private var reposError: String? + @State private var reposLoaded = false + + private var isSubscriber: Bool { authState.user?.isSubscriber == true } + private var hasGitHubIdentity: Bool { authState.hasGitHubIdentity } + private var canOfferGitHub: Bool { isSubscriber && hasGitHubIdentity } + private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) } private var canCreate: Bool { - !isLoading - && !trimmedName.isEmpty - && ListSchemaDraft.hasCreatableColumns(columns) + guard !isLoading, !trimmedName.isEmpty else { return false } + if githubBacked { + return selectedRepo?.contains("/") == true + } + return ListSchemaDraft.hasCreatableColumns(columns) } var body: some View { @@ -35,32 +49,10 @@ struct CreateListView: View { Toggle("Public", isOn: $isPublic) } - Section { - ForEach($columns) { $column in - ColumnRow(column: $column, onDelete: { - columns.removeAll { $0.id == column.id } - }) - } - .onMove { from, to in - columns.move(fromOffsets: from, toOffset: to) - } - Button { - columns.append(DraftProperty.newBlank()) - } label: { - Label("Add Column", systemImage: "plus") - } - .accessibilityLabel("Add column") - } header: { - HStack { - Text("Columns") - Spacer() - if columns.count > 1 { - EditButton() - .font(.ilMono()) - } - } - } footer: { - Text("Lists need at least one named column.") + githubSection + + if !githubBacked { + columnsSection } if let error = errorMessage { @@ -94,31 +86,165 @@ struct CreateListView: View { Button("Cancel") { dismiss() } } } + .task { + if isSubscriber { await authState.loadLinkedProvidersIfNeeded() } + } + } + } + + // MARK: - GitHub section + + @ViewBuilder + private var githubSection: some View { + if canOfferGitHub { + Section { + Toggle("GitHub-backed list", isOn: $githubBacked) + .accessibilityLabel("Create a GitHub-backed list") + if githubBacked { + if reposLoading { + HStack { ProgressView(); Text("Loading repositories…").foregroundStyle(.secondary) } + } else if let reposError { + Text(reposError) + .foregroundStyle(.red) + .font(.ilMono()) + } else if repos.isEmpty { + Text("No repositories found for your linked GitHub account.") + .foregroundStyle(.secondary) + .font(.ilMono()) + } else { + Picker("Repository", selection: $selectedRepo) { + Text("Select a repository").tag(String?.none) + ForEach(repos) { repo in + Text(repo.fullName).tag(String?.some(repo.fullName)) + } + } + .accessibilityLabel("GitHub repository") + } + } + } header: { + Text("GitHub") + } footer: { + if githubBacked { + Text("Issues from the selected repository become this list's rows.") + } + } + .onChange(of: githubBacked) { _, on in + if on { Task { await loadReposIfNeeded() } } + } + } else if isSubscriber && !hasGitHubIdentity { + Section { + Text("Connect GitHub on the web to create GitHub-backed lists.") + .foregroundStyle(.secondary) + .font(.ilMono()) + } header: { + Text("GitHub") + } + } + } + + @ViewBuilder + private var columnsSection: some View { + Section { + ForEach($columns) { $column in + ColumnRow(column: $column, onDelete: { + columns.removeAll { $0.id == column.id } + }) + } + .onMove { from, to in + columns.move(fromOffsets: from, toOffset: to) + } + Button { + columns.append(DraftProperty.newBlank()) + } label: { + Label("Add Column", systemImage: "plus") + } + .accessibilityLabel("Add column") + } header: { + HStack { + Text("Columns") + Spacer() + if columns.count > 1 { + EditButton() + .font(.ilMono()) + } + } + } footer: { + Text("Lists need at least one named column.") + } + } + + private func loadReposIfNeeded() async { + guard !reposLoaded, !reposLoading else { return } + reposLoading = true + reposError = nil + defer { reposLoading = false } + do { + let fetched = try await APIClient.shared.githubRepos() + repos = fetched + reposLoaded = true + if selectedRepo == nil, + let preferred = authState.user?.githubDefaultRepo, + fetched.contains(where: { $0.fullName == preferred }) { + selectedRepo = preferred + } + } catch APIError.status(401) { + authState.handleUnauthorized() + reposError = "Session expired. Please try again." + } catch APIError.server(let msg) { + reposError = msg + } catch { + reposError = "Couldn't load your GitHub repositories." } } private func create() async { errorMessage = nil - guard !trimmedName.isEmpty, ListSchemaDraft.hasCreatableColumns(columns) else { return } + guard !trimmedName.isEmpty else { return } isLoading = true defer { isLoading = false } let trimmedDesc = description.trimmingCharacters(in: .whitespacesAndNewlines) - let schema = ListSchemaDraft.dslSchema(name: trimmedName, columns) do { - let list = try await APIClient.shared.createList( - title: trimmedName, - description: trimmedDesc.isEmpty ? nil : trimmedDesc, - isPublic: isPublic, - schema: schema.fields.isEmpty ? nil : schema - ) + let list: UserList + if githubBacked { + guard let repo = selectedRepo, let source = Self.gitHubSource(from: repo) else { + errorMessage = "Select a repository." + return + } + list = try await APIClient.shared.createList( + title: trimmedName, + description: trimmedDesc.isEmpty ? nil : trimmedDesc, + isPublic: isPublic, + githubSource: source + ) + } else { + guard ListSchemaDraft.hasCreatableColumns(columns) else { return } + let schema = ListSchemaDraft.dslSchema(name: trimmedName, columns) + list = try await APIClient.shared.createList( + title: trimmedName, + description: trimmedDesc.isEmpty ? nil : trimmedDesc, + isPublic: isPublic, + schema: schema.fields.isEmpty ? nil : schema + ) + } onCreate(list) dismiss() + } catch APIError.status(401) { + authState.handleUnauthorized() + errorMessage = "Session expired. Please try again." } catch APIError.server(let msg) { errorMessage = msg } catch { errorMessage = "Failed to create list." } } + + private static func gitHubSource(from fullName: String) -> APIClient.GitHubSource? { + guard let slash = fullName.firstIndex(of: "/") else { return nil } + let owner = String(fullName[..<slash]) + let repo = String(fullName[fullName.index(after: slash)...]) + guard !owner.isEmpty, !repo.isEmpty else { return nil } + return APIClient.GitHubSource(owner: owner, repo: repo) + } } // MARK: - Column row (name + type) @@ -152,4 +278,6 @@ private struct ColumnRow: View { #Preview { CreateListView(onCreate: { _ in }) + .environmentObject(AuthState()) + .environmentObject(AppDataStore()) } diff --git a/InterlinedList/Views/DMThreadView.swift b/InterlinedList/Views/DMThreadView.swift new file mode 100644 index 0000000..cddfc73 --- /dev/null +++ b/InterlinedList/Views/DMThreadView.swift @@ -0,0 +1,345 @@ +// +// DMThreadView.swift +// InterlinedList +// + +import PhotosUI +import SwiftUI + +struct DMThreadView: View { + let username: String + /// Pre-known user (from the inbox row / recipient picker) so the header can render + /// before the thread loads. Optional because deep entry points may only have a username. + var initialUser: DMUser? + + @EnvironmentObject private var authState: AuthState + + @State private var messages: [DMMessage] = [] + @State private var otherUser: DMUser? + @State private var isMutual = true + @State private var isBlocked = false + @State private var isLoading = true + @State private var loadError: String? + + @State private var draft = "" + @State private var isSending = false + @State private var sendError: String? + + @State private var pickerItem: PhotosPickerItem? + @State private var attachmentURL: String? + @State private var isUploadingImage = false + + @State private var pollTask: Task<Void, Never>? + + private var selfId: String? { authState.user?.id } + private var recipientId: String? { otherUser?.id ?? initialUser?.id } + private var canCompose: Bool { isMutual && !isBlocked } + private var canAttach: Bool { authState.user?.emailVerified == true } + + var body: some View { + VStack(spacing: 0) { + messageList + composer + } + .navigationTitle(headerTitle) + .navigationBarTitleDisplayMode(.inline) + .task { await initialLoad() } + .onDisappear { pollTask?.cancel() } + } + + private var headerTitle: String { + (otherUser ?? initialUser)?.displayNameOrUsername ?? "@\(username)" + } + + @ViewBuilder + private var messageList: some View { + if isLoading && messages.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let loadError, messages.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(loadError) + } actions: { + Button("Retry") { Task { await initialLoad() } } + } + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 10) { + ForEach(messages) { message in + DMBubble(message: message, isOutgoing: message.senderId == selfId) + .id(message.id) + } + } + .padding() + } + .onChange(of: messages.count) { _, _ in + if let last = messages.last { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + .onAppear { + if let last = messages.last { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + } + + @ViewBuilder + private var composer: some View { + Divider() + if !canCompose { + Text(disabledReason) + .font(.ilMono()) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding() + } else { + VStack(spacing: 6) { + if let attachmentURL, let url = URL(string: attachmentURL) { + HStack { + AsyncImage(url: url) { phase in + if let image = phase.image { + image.resizable().scaledToFill() + } else { + Color.clear + } + } + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: ILMetric.radiusMd)) + Text("Image attached") + .font(.ilMono()) + .foregroundStyle(.secondary) + Spacer() + Button { + self.attachmentURL = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .accessibilityLabel("Remove attached image") + } + } + if let sendError { + Text(sendError) + .font(.ilMono()) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + HStack(spacing: 10) { + if canAttach { + PhotosPicker(selection: $pickerItem, matching: .images) { + if isUploadingImage { + ProgressView().frame(width: 24, height: 24) + } else { + Image(systemName: "photo") + .font(.system(size: 20)) + .foregroundStyle(ILColor.primary) + } + } + .disabled(isUploadingImage) + .accessibilityLabel("Attach image") + } + TextField("Message", text: $draft, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...5) + .accessibilityLabel("Message text") + Button { + Task { await send() } + } label: { + if isSending { + ProgressView().frame(width: 24, height: 24) + } else { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 26)) + .foregroundStyle(canSend ? ILColor.primary : Color.secondary) + } + } + .disabled(!canSend || isSending) + .accessibilityLabel("Send message") + } + } + .padding() + .onChange(of: pickerItem) { _, item in + if let item { Task { await uploadAttachment(item) } } + } + } + } + + private var canSend: Bool { + !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || attachmentURL != nil + } + + private var disabledReason: String { + if isBlocked { + return "You can't message this person." + } + return "You can only message people who follow you back." + } + + // MARK: - Loading + + private func initialLoad() async { + isLoading = true + loadError = nil + defer { isLoading = false } + do { + let thread = try await APIClient.shared.dmThread(username: username) + messages = thread.items + otherUser = thread.otherUser + isMutual = thread.isMutual + isBlocked = thread.isBlocked + startPolling() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + loadError = msg + } catch { + loadError = "Could not load this conversation." + } + } + + private func startPolling() { + pollTask?.cancel() + pollTask = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 4_000_000_000) + if Task.isCancelled { return } + await pollUpdates() + } + } + } + + private func pollUpdates() async { + guard let lastId = messages.last?.id else { return } + do { + let update = try await APIClient.shared.dmThreadUpdates(username: username, after: lastId) + let known = Set(messages.map(\.id)) + let newOnes = update.items.filter { !known.contains($0.id) } + if !newOnes.isEmpty { + messages.append(contentsOf: newOnes) + } + isMutual = update.isMutual + isBlocked = update.isBlocked + } catch { + // Polling is best-effort; ignore transient errors. + } + } + + // MARK: - Sending + + private func send() async { + guard let recipientId else { + sendError = "Could not resolve recipient." + return + } + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + let attachments = attachmentURL.map { [$0] } ?? [] + guard !text.isEmpty || !attachments.isEmpty else { return } + + isSending = true + sendError = nil + defer { isSending = false } + do { + let sent = try await APIClient.shared.sendDirectMessage(recipientId: recipientId, body: text, imageUrls: attachments) + messages.append(sent) + draft = "" + attachmentURL = nil + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let code) { + sendError = mappedSendError(code) + } catch { + sendError = "Message failed to send." + } + } + + private func mappedSendError(_ code: String) -> String { + switch code { + case "self_message": return "You can't message yourself." + case "invalid_body": return "Message must be 1–10000 characters." + case "invalid_images": return "One of the attached images is invalid." + case "recipient_not_found": return "That person could not be found." + case "blocked": return "You can't message this person." + case "not_mutual": return "You can only message people who follow you back." + default: return code + } + } + + // MARK: - Attachments + + private func uploadAttachment(_ item: PhotosPickerItem) async { + isUploadingImage = true + sendError = nil + defer { + isUploadingImage = false + pickerItem = nil + } + do { + guard let raw = try await item.loadTransferable(type: Data.self) else { + sendError = "Could not read that image." + return + } + guard let processed = ImageUploadProcessor.process(raw) else { + sendError = "Could not process that image." + return + } + attachmentURL = try await APIClient.shared.uploadDMImage(data: processed.data, mimeType: processed.mimeType) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + sendError = msg + } catch { + sendError = "Image upload failed." + } + } +} + +/// A single chat bubble. Outgoing messages trail and tint; incoming lead and gray. +private struct DMBubble: View { + let message: DMMessage + let isOutgoing: Bool + + var body: some View { + HStack { + if isOutgoing { Spacer(minLength: 40) } + VStack(alignment: .leading, spacing: 6) { + if !message.body.isEmpty { + MarkdownView(content: message.body) + } + ForEach(message.imageUrls, id: \.self) { urlString in + if let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: ILMetric.radiusMd)) + case .empty: + ProgressView().frame(height: 120) + default: + Image(systemName: "photo") + .foregroundStyle(.secondary) + } + } + .accessibilityLabel("Attached image") + } + } + } + .padding(10) + .background(isOutgoing ? ILColor.primary.opacity(0.18) : ILColor.surface2) + .clipShape(RoundedRectangle(cornerRadius: ILMetric.radiusLg)) + if !isOutgoing { Spacer(minLength: 40) } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(isOutgoing ? "You" : "Them"): \(message.body)") + } +} + +#Preview { + NavigationStack { + DMThreadView(username: "testuser") + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/DocumentCollaboratorsView.swift b/InterlinedList/Views/DocumentCollaboratorsView.swift new file mode 100644 index 0000000..a389c86 --- /dev/null +++ b/InterlinedList/Views/DocumentCollaboratorsView.swift @@ -0,0 +1,323 @@ +// +// DocumentCollaboratorsView.swift +// InterlinedList +// + +import SwiftUI + +/// Owner view of a document's per-person collaborators: see roles, change them, +/// remove people, and add new ones by search. Adding and role changes are +/// subscriber-gated (hidden for free users); listing and removing are always +/// available to the owner. +struct DocumentCollaboratorsView: View { + let documentId: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var collaborators: [DocumentCollaborator] = [] + @State private var isLoading = true + @State private var error: String? + @State private var actionError: String? + @State private var showAdd = false + + private var canManage: Bool { authState.user?.isSubscriber == true } + + var body: some View { + NavigationStack { + Group { + if isLoading && collaborators.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, collaborators.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else { + collaboratorsList + } + } + .navigationTitle("Collaborators") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + if canManage { + ToolbarItem(placement: .topBarTrailing) { + Button { showAdd = true } label: { Image(systemName: "person.badge.plus") } + .accessibilityLabel("Add collaborator") + } + } + } + .task { await load() } + .sheet(isPresented: $showAdd, onDismiss: { Task { await load() } }) { + AddDocumentCollaboratorView(documentId: documentId) + .environmentObject(authState) + } + } + } + + @ViewBuilder + private var collaboratorsList: some View { + List { + if let actionError { + Section { Text(actionError).font(.ilMono()).foregroundStyle(.red) } + } + if collaborators.isEmpty { + ContentUnavailableView { + Label("No collaborators yet", systemImage: "person.2") + } description: { + Text("Add people to collaborate on this document.") + } + } else { + ForEach(collaborators) { collaborator in + DocumentCollaboratorRow( + collaborator: collaborator, + canManage: canManage, + onChangeRole: { role in Task { await changeRole(collaborator, to: role) } } + ) + .swipeActions(edge: .trailing) { + if canManage { + Button(role: .destructive) { + Task { await remove(collaborator) } + } label: { + Label("Remove", systemImage: "person.fill.xmark") + } + } + } + } + } + } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + collaborators = try await APIClient.shared.documentCollaborators(id: documentId) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load collaborators." + } + } + + private func changeRole(_ collaborator: DocumentCollaborator, to role: WatcherRole) async { + guard collaborator.collaboratorRole != role else { return } + actionError = nil + do { + _ = try await APIClient.shared.setDocumentCollaboratorRole(id: documentId, userId: collaborator.userId, role: role) + await load() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not change role." + } + } + + private func remove(_ collaborator: DocumentCollaborator) async { + actionError = nil + do { + try await APIClient.shared.removeDocumentCollaborator(id: documentId, userId: collaborator.userId) + collaborators.removeAll { $0.userId == collaborator.userId } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not remove this person." + } + } +} + +private struct DocumentCollaboratorRow: View { + let collaborator: DocumentCollaborator + let canManage: Bool + let onChangeRole: (WatcherRole) -> Void + + var body: some View { + HStack(spacing: 12) { + avatar + VStack(alignment: .leading, spacing: 2) { + Text(collaborator.displayNameOrUsername) + .font(.ilBody()) + if let username = collaborator.username { + Text("@\(username)").font(.ilMono()).foregroundStyle(.secondary) + } + } + Spacer() + if canManage { + Menu { + ForEach(WatcherRole.allCases, id: \.self) { role in + Button { + onChangeRole(role) + } label: { + if collaborator.collaboratorRole == role { + Label(role.label, systemImage: "checkmark") + } else { + Text(role.label) + } + } + } + } label: { + roleBadge + } + } else { + roleBadge + } + } + .padding(.vertical, 2) + } + + private var roleBadge: some View { + Text((collaborator.collaboratorRole ?? .watcher).label) + .font(.ilMono()) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(ILColor.surface2) + .clipShape(Capsule()) + } + + @ViewBuilder + private var avatar: some View { + if let url = collaborator.avatar.flatMap({ URL(string: $0) }) { + AsyncImage(url: url) { phase in + if let image = phase.image { image.resizable().scaledToFill() } + else { Image(systemName: "person.circle.fill").resizable().scaledToFit().foregroundStyle(.secondary) } + } + .frame(width: 36, height: 36) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle.fill") + .resizable().scaledToFit().frame(width: 36, height: 36) + .foregroundStyle(.secondary) + } + } +} + +/// Search for and add a new collaborator with a chosen role. +private struct AddDocumentCollaboratorView: View { + let documentId: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var role: WatcherRole = .watcher + @State private var query = "" + @State private var candidates: [WatcherCandidate] = [] + @State private var isSearching = false + @State private var error: String? + @State private var addingId: String? + + var body: some View { + NavigationStack { + Form { + Section("Role") { + Picker("Role", selection: $role) { + ForEach(WatcherRole.allCases, id: \.self) { r in + Text(r.label).tag(r) + } + } + .pickerStyle(.segmented) + Text(role.detail(for: "document")).font(.ilMono()).foregroundStyle(.secondary) + } + + Section("People") { + TextField("Search by name or username", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel("Search people to add") + .onSubmit { Task { await search() } } + + if isSearching { + ProgressView() + } else if let error { + Text(error).font(.ilMono()).foregroundStyle(.red) + } else if candidates.isEmpty { + Text(query.isEmpty ? "Type to search for people." : "No matching people.") + .font(.ilBody(15)).foregroundStyle(.secondary) + } else { + ForEach(candidates) { candidate in + Button { + Task { await add(candidate) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(candidate.displayNameOrUsername).foregroundStyle(.primary) + Text("@\(candidate.username)").font(.ilMono()).foregroundStyle(.secondary) + } + Spacer() + if addingId == candidate.id { + ProgressView() + } else { + Image(systemName: "plus.circle").foregroundStyle(ILColor.primary) + } + } + } + .disabled(addingId != nil) + } + } + } + } + .navigationTitle("Add Collaborator") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .onChange(of: query) { _, newValue in + Task { await debouncedSearch(for: newValue) } + } + } + } + + private func debouncedSearch(for value: String) async { + try? await Task.sleep(nanoseconds: 300_000_000) + guard value == query else { return } + await search() + } + + private func search() async { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + candidates = [] + return + } + isSearching = true + error = nil + defer { isSearching = false } + do { + candidates = try await APIClient.shared.searchDocumentCollaboratorCandidates(id: documentId, query: trimmed) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not search people." + } + } + + private func add(_ candidate: WatcherCandidate) async { + addingId = candidate.id + defer { addingId = nil } + do { + _ = try await APIClient.shared.addDocumentCollaborator(id: documentId, userId: candidate.id, role: role) + dismiss() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + self.error = msg + } catch { + self.error = "Could not add this person." + } + } +} + +#Preview { + DocumentCollaboratorsView(documentId: "doc-1") + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/DocumentLinkView.swift b/InterlinedList/Views/DocumentLinkView.swift new file mode 100644 index 0000000..f61b88f --- /dev/null +++ b/InterlinedList/Views/DocumentLinkView.swift @@ -0,0 +1,84 @@ +// +// DocumentLinkView.swift +// InterlinedList +// + +import SwiftUI + +/// Loader shown when a `interlinedlist://documents/<id>` (or web permalink) deep +/// link opens (G10). A permalink only carries the document id, so this fetches the +/// document and presents loading / error / loaded states, mirroring `MessageLinkView`. +struct DocumentLinkView: View { + let documentId: String + + @EnvironmentObject private var authState: AuthState + @Environment(\.dismiss) private var dismiss + @State private var document: Document? + @State private var errorMessage: String? + @State private var isLoading = true + + var body: some View { + NavigationStack { + Group { + if let document { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(document.title) + .font(.ilDisplay(20)) + if let content = document.content, !content.isEmpty { + MarkdownView(content: content) + .textSelection(.enabled) + } else { + Text("This document is empty.") + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } else if isLoading { + ProgressView("Loading document…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView { + Label("Unable to open document", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage ?? "This document could not be loaded.") + } actions: { + Button("Retry") { Task { await load() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle(document?.title ?? "Document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + .task { await load() } + } + + private func load() async { + errorMessage = nil + isLoading = true + defer { isLoading = false } + do { + document = try await APIClient.shared.publicDocument(id: documentId) + } catch APIError.status(401) { + authState.handleUnauthorized() + errorMessage = "You need to be signed in to view this document." + } catch APIError.server(let msg) { + errorMessage = msg + } catch { + errorMessage = "This document could not be loaded." + } + } +} + +#Preview { + DocumentLinkView(documentId: "preview-id") + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index b6774ae..2fff57c 100644 --- a/InterlinedList/Views/DocumentsView.swift +++ b/InterlinedList/Views/DocumentsView.swift @@ -10,6 +10,8 @@ struct DocumentsView: View { @EnvironmentObject var store: AppDataStore @State private var showCreate = false @State private var showCreateFolder = false + @State private var showTemplatePicker = false + @State private var createdFromTemplate: Document? @State private var folderToDelete: DocumentFolder? @State private var showDeleteFolderConfirm = false @State private var searchText = "" @@ -46,27 +48,42 @@ struct DocumentsView: View { : allDocuments } + private var showConflictBanner: Bool { + store.offlineDocumentWritesEnabled && !store.syncConflicts.isEmpty + } + + private var conflictBannerText: String { + let count = store.syncConflicts.count + let noun = count == 1 ? "document" : "documents" + return "\(count) \(noun) were edited elsewhere — a conflicted copy was kept." + } + var body: some View { NavigationStack { - Group { - if !searchText.isEmpty { - searchResultsList - } else if store.documentsLoading && allFolders.isEmpty && allDocuments.isEmpty { - DocumentSkeletonView() - } else if let error = store.documentsError { - ContentUnavailableView { - Label("Unavailable", systemImage: "exclamationmark.triangle") - } description: { - Text(error) - } - } else if rootFolders.isEmpty && rootDocuments.isEmpty { - ContentUnavailableView { - Label("No Documents", systemImage: "doc.text") - } description: { - Text("Tap + to create your first document.") + VStack(spacing: 0) { + if showConflictBanner { + conflictBanner + } + Group { + if !searchText.isEmpty { + searchResultsList + } else if store.documentsLoading && allFolders.isEmpty && allDocuments.isEmpty { + DocumentSkeletonView() + } else if let error = store.documentsError { + ContentUnavailableView { + Label("Unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } + } else if rootFolders.isEmpty && rootDocuments.isEmpty { + ContentUnavailableView { + Label("No Documents", systemImage: "doc.text") + } description: { + Text("Tap + to create your first document.") + } + } else { + documentList } - } else { - documentList } } .navigationTitle("Documents") @@ -99,6 +116,11 @@ struct DocumentsView: View { Label("New Document", systemImage: "doc.badge.plus") } if canCreateFolders { + Button { + showTemplatePicker = true + } label: { + Label("Start from Template", systemImage: "doc.on.doc") + } Button { showCreateFolder = true } label: { @@ -123,6 +145,19 @@ struct DocumentsView: View { store.insertDocument(newDoc) } } + .sheet(isPresented: $showTemplatePicker) { + TemplatePickerView(targetFolderId: nil) { newDoc in + store.insertDocument(newDoc) + createdFromTemplate = newDoc + } + } + .navigationDestination(item: $createdFromTemplate) { doc in + DocumentDetailView(document: doc, onUpdate: { updated in + store.updateDocument(updated) + }, onDelete: { id in + store.removeDocument(id: id) + }) + } .sheet(isPresented: $showCreateFolder) { CreateDocumentFolderView(parentId: nil) { newFolder in store.insertDocumentFolder(newFolder) @@ -144,6 +179,10 @@ struct DocumentsView: View { } private func deleteFolder(_ folder: DocumentFolder) async { + if store.offlineDocumentWritesEnabled { + store.deleteDocumentFolderOffline(id: folder.id) + return + } do { try await APIClient.shared.deleteDocumentFolder(id: folder.id) store.removeDocumentFolder(id: folder.id) @@ -155,6 +194,34 @@ struct DocumentsView: View { } } + private var conflictBanner: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + Text(conflictBannerText) + .font(.footnote) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + Button { + store.dismissSyncConflicts() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss conflict notice") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(ILColor.primary).opacity(0.10)) + .accessibilityElement(children: .combine) + .accessibilityLabel(conflictBannerText) + } + @ViewBuilder private var searchResultsList: some View { if let error = searchError, searchResults.isEmpty { @@ -281,7 +348,7 @@ struct DocumentsView: View { }, onDelete: { id in store.removeDocument(id: id) })) { - DocumentRow(document: doc) + DocumentRow(document: doc, pendingSync: store.pendingSyncDocIds.contains(doc.id)) } } .onDelete { offsets in @@ -294,6 +361,10 @@ struct DocumentsView: View { private func deleteDocuments(at offsets: IndexSet, from list: [Document]) async { let toDelete = offsets.map { list[$0] } + if store.offlineDocumentWritesEnabled { + toDelete.forEach { store.deleteDocumentOffline(id: $0.id) } + return + } toDelete.forEach { store.removeDocument(id: $0.id) } for doc in toDelete { try? await APIClient.shared.deleteDocument(id: doc.id) @@ -305,11 +376,14 @@ private struct DocumentFolderView: View { let folder: DocumentFolder @EnvironmentObject var authState: AuthState + @EnvironmentObject var store: AppDataStore @State private var subfolders: [DocumentFolder] = [] @State private var documents: [Document] = [] @State private var isLoading = false @State private var showCreate = false @State private var showCreateFolder = false + @State private var showTemplatePicker = false + @State private var createdFromTemplate: Document? @State private var folderToDelete: DocumentFolder? @State private var showDeleteFolderConfirm = false @@ -332,6 +406,11 @@ private struct DocumentFolderView: View { Label("New Document", systemImage: "doc.badge.plus") } if authState.user?.isSubscriber == true { + Button { + showTemplatePicker = true + } label: { + Label("Start from Template", systemImage: "doc.on.doc") + } Button { showCreateFolder = true } label: { @@ -357,6 +436,21 @@ private struct DocumentFolderView: View { documents.insert(newDoc, at: 0) } } + .sheet(isPresented: $showTemplatePicker) { + TemplatePickerView(targetFolderId: folder.id) { newDoc in + documents.insert(newDoc, at: 0) + createdFromTemplate = newDoc + } + } + .navigationDestination(item: $createdFromTemplate) { doc in + DocumentDetailView(document: doc, onUpdate: { updated in + if let idx = documents.firstIndex(where: { $0.id == updated.id }) { + documents[idx] = updated + } + }, onDelete: { id in + documents.removeAll { $0.id == id } + }) + } .sheet(isPresented: $showCreateFolder) { CreateDocumentFolderView(parentId: folder.id) { newFolder in subfolders.append(newFolder) @@ -412,7 +506,7 @@ private struct DocumentFolderView: View { }, onDelete: { id in documents.removeAll { $0.id == id } })) { - DocumentRow(document: doc) + DocumentRow(document: doc, pendingSync: store.pendingSyncDocIds.contains(doc.id)) } } .onDelete { offsets in @@ -428,6 +522,13 @@ private struct DocumentFolderView: View { } private func load() async { + if store.offlineDocumentWritesEnabled { + // The sync path keeps the store's full tree current; derive this + // folder's contents from it rather than the online endpoints. + subfolders = store.documentFolders.filter { $0.parentId == folder.id } + documents = store.documents.filter { $0.folderId == folder.id } + return + } isLoading = true defer { isLoading = false } do { @@ -444,12 +545,21 @@ private struct DocumentFolderView: View { private func deleteDocuments(at offsets: IndexSet) async { let toDelete = offsets.map { documents[$0] } documents.remove(atOffsets: offsets) + if store.offlineDocumentWritesEnabled { + toDelete.forEach { store.deleteDocumentOffline(id: $0.id) } + return + } for doc in toDelete { try? await APIClient.shared.deleteDocument(id: doc.id) } } private func deleteFolder(_ sub: DocumentFolder) async { + if store.offlineDocumentWritesEnabled { + store.deleteDocumentFolderOffline(id: sub.id) + subfolders.removeAll { $0.id == sub.id } + return + } do { try await APIClient.shared.deleteDocumentFolder(id: sub.id) subfolders.removeAll { $0.id == sub.id } @@ -464,11 +574,20 @@ private struct DocumentFolderView: View { private struct DocumentRow: View { let document: Document + var pendingSync: Bool = false var body: some View { VStack(alignment: .leading, spacing: 3) { - Text(document.title) - .font(.ilBody()) + HStack(spacing: 6) { + Text(document.title) + .font(.ilBody()) + if pendingSync { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .accessibilityLabel("Pending sync") + } + } if let updatedAt = document.updatedAt, let date = parseISODate(updatedAt) { Text(date, style: .relative) .font(.ilMono()) @@ -512,9 +631,14 @@ private struct DocumentDetailView: View { var onDelete: (String) -> Void @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @EnvironmentObject private var store: AppDataStore @State private var current: Document @State private var showEdit = false @State private var showDeleteConfirm = false + @State private var showShare = false + @State private var showInvites = false + @State private var showCollaborators = false init(document: Document, onUpdate: @escaping (Document) -> Void, onDelete: @escaping (String) -> Void) { self.document = document @@ -542,6 +666,15 @@ private struct DocumentDetailView: View { ToolbarItem(placement: .topBarTrailing) { Menu { Button("Edit") { showEdit = true } + Button("Share") { showShare = true } + Button("Invite by email") { showInvites = true } + if let url = ILWebURL.document(current.id) { + SwiftUI.ShareLink(item: url) { + Label("Share link", systemImage: "square.and.arrow.up") + } + .accessibilityLabel("Share link") + } + Button("Collaborators") { showCollaborators = true } Button("Delete", role: .destructive) { showDeleteConfirm = true } } label: { Image(systemName: "ellipsis.circle") @@ -561,10 +694,26 @@ private struct DocumentDetailView: View { onUpdate(updated) } } + .sheet(isPresented: $showShare) { + ShareLinksSheet(kind: .documents, resourceId: current.id, title: current.title) + .environmentObject(authState) + } + .sheet(isPresented: $showInvites) { + ShareInvitesSheet(kind: .documents, resourceId: current.id, title: current.title) + .environmentObject(authState) + } + .sheet(isPresented: $showCollaborators) { + DocumentCollaboratorsView(documentId: current.id) + .environmentObject(authState) + } .confirmationDialog("Delete \"\(current.title)\"?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { Button("Delete", role: .destructive) { Task { - try? await APIClient.shared.deleteDocument(id: current.id) + if store.offlineDocumentWritesEnabled { + store.deleteDocumentOffline(id: current.id) + } else { + try? await APIClient.shared.deleteDocument(id: current.id) + } onDelete(current.id) dismiss() } @@ -579,6 +728,7 @@ private struct CreateDocumentView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var authState: AuthState + @EnvironmentObject private var store: AppDataStore @State private var title = "" @State private var content = "" @State private var isPublic = false @@ -656,6 +806,19 @@ private struct CreateDocumentView: View { isLoading = true defer { isLoading = false } errorMessage = nil + // A `draftId` means an image was auto-saved online (network was required), + // so keep that document server-coherent via the online update. Otherwise a + // plain text create can go through the offline outbox when the flag is on. + if store.offlineDocumentWritesEnabled && draftId == nil { + let saved = store.createDocumentOffline( + title: trimmedTitle, + content: content.isEmpty ? nil : content, + isPublic: isPublic, + folderId: folderId) + onSave(saved) + dismiss() + return + } do { let saved: Document if let draftId { @@ -702,6 +865,7 @@ private struct EditDocumentView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var authState: AuthState + @EnvironmentObject private var store: AppDataStore @State private var title: String @State private var content: String @State private var isPublic: Bool @@ -769,7 +933,9 @@ private struct EditDocumentView: View { } } .task { - if let folders = try? await APIClient.shared.documentFolders() { + if store.offlineDocumentWritesEnabled { + availableFolders = store.documentFolders + } else if let folders = try? await APIClient.shared.documentFolders() { availableFolders = folders } } @@ -780,11 +946,20 @@ private struct EditDocumentView: View { isLoading = true defer { isLoading = false } errorMessage = nil + let folderIdToSend: String? = selectedFolderId.flatMap { $0.isEmpty ? nil : $0 } + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + if store.offlineDocumentWritesEnabled { + let updated = store.updateDocumentOffline( + id: document.id, title: trimmedTitle, + content: content.isEmpty ? nil : content, isPublic: isPublic, folderId: folderIdToSend) + onSave(updated) + dismiss() + return + } do { - let folderIdToSend: String? = selectedFolderId.flatMap { $0.isEmpty ? nil : $0 } let updated = try await APIClient.shared.updateDocument( id: document.id, - title: title.trimmingCharacters(in: .whitespacesAndNewlines), + title: trimmedTitle, content: content.isEmpty ? nil : content, isPublic: isPublic, folderId: folderIdToSend @@ -806,6 +981,7 @@ private struct CreateDocumentFolderView: View { var onSave: (DocumentFolder) -> Void @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var store: AppDataStore @State private var name = "" @State private var isLoading = false @State private var errorMessage: String? @@ -840,9 +1016,16 @@ private struct CreateDocumentFolderView: View { isLoading = true defer { isLoading = false } errorMessage = nil + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if store.offlineDocumentWritesEnabled { + let folder = store.createDocumentFolderOffline(name: trimmed, parentId: parentId) + onSave(folder) + dismiss() + return + } do { let folder = try await APIClient.shared.createDocumentFolder( - name: name.trimmingCharacters(in: .whitespacesAndNewlines), + name: trimmed, parentId: parentId ) onSave(folder) @@ -855,6 +1038,123 @@ private struct CreateDocumentFolderView: View { } } +/// Lists document templates and, on selection, copies the chosen one into a new +/// document under `targetFolderId` (nil = root). Subscriber-gated; the caller only +/// presents this when the user is a subscriber. +private struct TemplatePickerView: View { + let targetFolderId: String? + var onCreate: (Document) -> Void + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var templates: [DocumentTemplate] = [] + @State private var isLoading = true + @State private var creatingId: String? + @State private var loadError: String? + @State private var createError: String? + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Loading templates…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let loadError { + ContentUnavailableView { + Label("Unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text(loadError) + } actions: { + Button("Retry") { Task { await load() } } + } + } else if templates.isEmpty { + ContentUnavailableView { + Label("No Templates", systemImage: "doc.on.doc") + } description: { + Text("You have no document templates yet.") + } + } else { + List(templates) { template in + Button { + Task { await create(from: template) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(template.title).font(.ilBody()) + if let path = template.relativePath, !path.isEmpty { + Text(path) + .font(.ilMono()) + .foregroundStyle(.secondary) + } + } + Spacer() + if creatingId == template.id { + ProgressView() + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(creatingId != nil) + .accessibilityLabel("Use template \(template.title)") + } + } + } + .navigationTitle("Templates") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + } + .alert("Couldn't create document", isPresented: Binding( + get: { createError != nil }, + set: { if !$0 { createError = nil } } + )) { + Button("OK", role: .cancel) { createError = nil } + } message: { + Text(createError ?? "") + } + .task { await load() } + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + loadError = nil + do { + templates = try await APIClient.shared.documentTemplates() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + loadError = msg + } catch { + loadError = "Failed to load templates." + } + } + + private func create(from template: DocumentTemplate) async { + guard creatingId == nil else { return } + creatingId = template.id + defer { creatingId = nil } + do { + let doc = try await APIClient.shared.createDocumentFromTemplate( + templateDocumentId: template.id, + targetFolderId: targetFolderId + ) + onCreate(doc) + dismiss() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + createError = msg + } catch { + createError = "Failed to create document from template." + } + } +} + private func parseISODate(_ string: String) -> Date? { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/InterlinedList/Views/FeedView.swift b/InterlinedList/Views/FeedView.swift index 1fc3463..0551bae 100644 --- a/InterlinedList/Views/FeedView.swift +++ b/InterlinedList/Views/FeedView.swift @@ -33,16 +33,42 @@ struct FeedView: View { @State private var reportTarget: ReportTarget? = nil @State private var blockedUserIds: Set<String> = [] @State private var detailMessage: Message? + @State private var trendingTags: [TrendingTag] = [] + @State private var tagSuggestions: [TagSuggestion] = [] private var distinctTags: [String] { var seen = Set<String>() return messages.compactMap { $0.tags }.flatMap { $0 }.filter { seen.insert($0).inserted } } + /// Tags shown in the discovery strip: server-wide **trending** when available + /// (G13), else the tags present on the current feed page. The active filter is + /// always included so it can be toggled off even if it isn't currently trending. + private var feedTags: [String] { + let base = trendingTags.isEmpty ? distinctTags : trendingTags.map(\.tag) + guard let active = tagFilter, !base.contains(active) else { return base } + return [active] + base + } + + private var trimmedSearch: String { + searchText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// A `#`-prefixed search term switches the search field into tag-autocomplete + /// mode (G13) instead of full-text message search. + private var isTagSearch: Bool { trimmedSearch.hasPrefix("#") } + private var tagSearchPrefix: String { + String(trimmedSearch.dropFirst()).trimmingCharacters(in: .whitespaces) + } + @ViewBuilder private var feedContent: some View { if !searchText.isEmpty { - searchResultsList + if isTagSearch { + tagSuggestionsList + } else { + searchResultsList + } } else if isLoading && messages.isEmpty { FeedSkeletonView() } else if let error = errorMessage, messages.isEmpty { @@ -87,17 +113,49 @@ struct FeedView: View { } } + @ViewBuilder + private var tagSuggestionsList: some View { + if tagSearchPrefix.isEmpty { + ContentUnavailableView { + Label("Search tags", systemImage: "number") + } description: { + Text("Type a tag after # to find posts by topic.") + } + } else if tagSuggestions.isEmpty { + ContentUnavailableView.search(text: trimmedSearch) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(tagSuggestions) { suggestion in + Button { + applyTagFilter(suggestion.tag) + } label: { + HStack(spacing: 8) { + Text("#\(suggestion.tag)").font(.ilBody()) + Spacer() + Text("\(suggestion.count)") + .font(.ilMono(12)) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Tag \(suggestion.tag), \(suggestion.count) posts") + } + .listStyle(.plain) + } + } + private var messageList: some View { List { Section { Toggle("Show previews", isOn: $showPreviews) Toggle("My Posts", isOn: $showOnlyMine) } - if !distinctTags.isEmpty { + if !feedTags.isEmpty { Section { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(distinctTags, id: \.self) { tag in + ForEach(feedTags, id: \.self) { tag in let isActive = tagFilter == tag Button { tagFilter = isActive ? nil : tag @@ -115,6 +173,11 @@ struct FeedView: View { } .padding(.vertical, 2) } + } header: { + if !trendingTags.isEmpty { + Label("Trending", systemImage: "chart.line.uptrend.xyaxis") + .font(.ilMono(11)) + } } } ForEach(messages.filter { !blockedUserIds.contains($0.userId) }) { message in @@ -218,13 +281,23 @@ struct FeedView: View { } .navigationTitle("InterlinedList") .navigationBarTitleDisplayMode(.inline) - .searchable(text: $searchText, prompt: "Search posts") + .searchable(text: $searchText, prompt: "Search posts (or #tag)") .onSubmit(of: .search) { Task { await runSearch() } } .onChange(of: searchText) { _, newValue in if newValue.isEmpty { searchResults = [] searchPerformed = false + tagSuggestions = [] + } + } + .task(id: searchText) { + guard isTagSearch, !tagSearchPrefix.isEmpty else { + tagSuggestions = [] + return } + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + await loadTagSuggestions() } .toolbar { feedToolbar } } @@ -315,6 +388,38 @@ struct FeedView: View { } else { isLoading = store.feedLoading } + await loadTrendingTags() + } + + /// Server-wide trending tags for the discovery strip (G13). Non-critical: on any + /// failure the strip silently falls back to `distinctTags` from the current page. + private func loadTrendingTags() async { + do { + trendingTags = try await APIClient.shared.trendingTags() + } catch { + // Ignore — the feed still works without discovery tags. + } + } + + private func loadTagSuggestions() async { + let prefix = tagSearchPrefix + guard !prefix.isEmpty else { tagSuggestions = []; return } + do { + let suggestions = try await APIClient.shared.tagAutocomplete(query: prefix) + guard !Task.isCancelled else { return } + tagSuggestions = suggestions + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + tagSuggestions = [] + } + } + + private func applyTagFilter(_ tag: String) { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + searchText = "" + tagSuggestions = [] + tagFilter = tag } private func toggleDig(for message: Message) async { @@ -352,6 +457,8 @@ struct FeedView: View { } private func runSearch() async { + // `#tag` queries are handled live by tag autocomplete, not full-text search. + guard !isTagSearch else { return } let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty else { return } isSearching = true diff --git a/InterlinedList/Views/FindPeopleView.swift b/InterlinedList/Views/FindPeopleView.swift new file mode 100644 index 0000000..d759832 --- /dev/null +++ b/InterlinedList/Views/FindPeopleView.swift @@ -0,0 +1,146 @@ +// +// FindPeopleView.swift +// InterlinedList +// + +import SwiftUI + +/// Prefix search over people (G6). Debounced `.searchable` list; tapping a row +/// opens that user's profile. An empty/blank query shows a neutral prompt rather +/// than firing a request (the backend rejects a blank `q` with 400). +struct FindPeopleView: View { + @EnvironmentObject private var authState: AuthState + @State private var query = "" + @State private var results: [FollowUser] = [] + @State private var isSearching = false + @State private var error: String? + @State private var profileTarget: ProfileTarget? + + private var trimmedQuery: String { + query.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + Group { + if trimmedQuery.isEmpty { + ContentUnavailableView { + Label("Find people", systemImage: "magnifyingglass") + } description: { + Text("Search by name or @username to find people to follow.") + } + } else if let error, results.isEmpty { + ContentUnavailableView { + Label("Search failed", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await runSearch() } } + } + } else if isSearching && results.isEmpty { + ProgressView("Searching…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if results.isEmpty { + ContentUnavailableView.search(text: trimmedQuery) + } else { + List(results) { user in + Button { + profileTarget = ProfileTarget(username: user.username) + } label: { + PersonSearchRow(user: user) + } + .buttonStyle(.plain) + } + .listStyle(.plain) + } + } + .navigationTitle("Find People") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, prompt: "Name or @username") + .onChange(of: query) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + results = [] + error = nil + } + } + .task(id: query) { + guard !trimmedQuery.isEmpty else { return } + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + await runSearch() + } + .sheet(item: $profileTarget) { target in + UserProfileView(username: target.username) + .environmentObject(authState) + } + } + + private func runSearch() async { + let q = trimmedQuery + guard !q.isEmpty else { return } + isSearching = true + defer { isSearching = false } + do { + let users = try await APIClient.shared.searchUsers(query: q) + guard !Task.isCancelled else { return } + results = users + error = nil + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + error = msg + } catch { + self.error = "Search failed. Please try again." + } + } +} + +private struct PersonSearchRow: View { + let user: FollowUser + + var body: some View { + HStack(spacing: 12) { + avatar + VStack(alignment: .leading, spacing: 2) { + Text(user.displayNameOrUsername) + .font(.ilBody()) + .foregroundStyle(.primary) + Text("@\(user.username)") + .font(.ilMono()) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(user.displayNameOrUsername), @\(user.username)") + } + + @ViewBuilder + private var avatar: some View { + if let avatarURL = user.avatar.flatMap({ URL(string: $0) }) { + AsyncImage(url: avatarURL) { phase in + if let image = phase.image { + image.resizable().scaledToFill() + } else { + Image(systemName: "person.circle.fill").resizable().scaledToFit() + .foregroundStyle(.secondary) + } + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle.fill") + .resizable().scaledToFit() + .frame(width: 40, height: 40) + .foregroundStyle(.secondary) + } + } +} + +#Preview { + NavigationStack { + FindPeopleView() + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/ListItemFormView.swift b/InterlinedList/Views/ListItemFormView.swift index 56f2b07..8c3be18 100644 --- a/InterlinedList/Views/ListItemFormView.swift +++ b/InterlinedList/Views/ListItemFormView.swift @@ -113,11 +113,15 @@ struct ListItemFormView: View { )) case "textarea": - TextEditor(text: Binding( - get: { fieldValues[prop.propertyKey] ?? "" }, - set: { fieldValues[prop.propertyKey] = $0 } - )) - .frame(minHeight: 80) + MarkdownFieldEditor( + text: Binding( + get: { fieldValues[prop.propertyKey] ?? "" }, + set: { fieldValues[prop.propertyKey] = $0 } + ), + minHeight: 120 + ) + .listRowInsets(EdgeInsets()) + .padding(.vertical, 4) case "email": TextField(prop.placeholder ?? prop.propertyName, text: Binding( diff --git a/InterlinedList/Views/ListSchemaEditorView.swift b/InterlinedList/Views/ListSchemaEditorView.swift index 8dd4c4d..5015fe5 100644 --- a/InterlinedList/Views/ListSchemaEditorView.swift +++ b/InterlinedList/Views/ListSchemaEditorView.swift @@ -291,7 +291,6 @@ private struct PropertyRow: View { id: "list-1", name: "Books to Read", description: "Personal reading queue", - folderId: nil, isPublic: false, createdAt: "2026-01-01T00:00:00Z", updatedAt: nil, diff --git a/InterlinedList/Views/ListsView.swift b/InterlinedList/Views/ListsView.swift index 26a510c..2642b7e 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -9,23 +9,14 @@ struct ListsView: View { @EnvironmentObject var authState: AuthState @EnvironmentObject var store: AppDataStore @State private var showCreateList = false - @State private var showCreateFolder = false @State private var createError: String? @State private var searchText = "" @State private var searchResults: [UserList] = [] @State private var isSearching = false @State private var treeNodes: [ListTreeNode] = [] - private var canCreateFolders: Bool { - authState.user?.isSubscriber == true - } - private func rebuildTree() -> [ListTreeNode] { - // Folders are a subscriber-only feature. For free users we pass an empty - // folder array so any lists that were nested under folders (e.g. from when - // the user was a subscriber) surface at root via buildTree's orphan rule. - let visibleFolders = canCreateFolders ? store.listFolders : [] - return ListTreeNode.buildTree(folders: visibleFolders, lists: store.userLists) + ListTreeNode.buildTree(lists: store.userLists) } var body: some View { @@ -53,18 +44,14 @@ struct ListsView: View { CreateListView { _ in Task { await store.refreshLists() } } - } - .sheet(isPresented: $showCreateFolder) { - CreateListFolderView(parentId: nil) { - Task { await store.refreshLists() } - } + .environmentObject(authState) + .environmentObject(store) } .refreshable { await store.refreshLists() } .onAppear { treeNodes = rebuildTree() } .onChange(of: store.userLists) { _, _ in treeNodes = rebuildTree() } - .onChange(of: store.listFolders) { _, _ in treeNodes = rebuildTree() } } } @@ -96,7 +83,6 @@ struct ListsView: View { ListTreeNodeRow( node: node, onDeleteList: { list in Task { await deleteList(list) } }, - onDeleteFolder: { folder in Task { await deleteFolder(folder) } }, onUpdateList: { _ in Task { await store.refreshLists() } } ) } @@ -111,11 +97,6 @@ struct ListsView: View { Button { showCreateList = true } label: { Label("New List", systemImage: "plus.rectangle") } - if canCreateFolders { - Button { showCreateFolder = true } label: { - Label("New Folder", systemImage: "folder.badge.plus") - } - } } label: { Image(systemName: "plus") .font(.system(size: 15, weight: .semibold)) @@ -170,77 +151,6 @@ struct ListsView: View { await store.refreshLists() } } - - private func deleteFolder(_ folder: ListFolder) async { - do { - try await APIClient.shared.deleteListFolder(id: folder.id) - store.removeListFolder(id: folder.id) - } catch APIError.status(401) { - authState.handleUnauthorized() - } catch { - await store.refreshLists() - } - } -} - -// MARK: - Create list folder sheet - -private struct CreateListFolderView: View { - let parentId: String? - let onSave: () -> Void - - @Environment(\.dismiss) private var dismiss - @State private var name = "" - @State private var isLoading = false - @State private var errorMessage: String? - - var body: some View { - NavigationStack { - Form { - Section("Folder Name") { - TextField("Name", text: $name) - } - if let error = errorMessage { - Section { - Text(error).foregroundStyle(.red).font(.ilMono()) - } - } - } - .navigationTitle("New Folder") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .topBarTrailing) { - Button("Create") { Task { await save() } } - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) - } - } - } - } - - private func save() async { - isLoading = true - defer { isLoading = false } - errorMessage = nil - do { - _ = try await APIClient.shared.createListFolder( - name: name.trimmingCharacters(in: .whitespacesAndNewlines), - parentId: parentId - ) - onSave() - dismiss() - } catch APIError.server(let msg) { - errorMessage = msg - } catch { - // 403 falls through here — the New Folder button is hidden for - // non-subscribers, so this catch should only trigger on transient - // errors. Per the iOS-free-app direction, no subscription copy is - // ever surfaced. - errorMessage = "Failed to create folder." - } - } } // MARK: - Rename list sheet @@ -249,6 +159,7 @@ private struct RenameListView: View { let list: UserList let onSave: (UserList) -> Void + @EnvironmentObject private var authState: AuthState @Environment(\.dismiss) private var dismiss @State private var title: String @State private var isPublic: Bool @@ -305,6 +216,9 @@ private struct RenameListView: View { ) onSave(updated) dismiss() + } catch APIError.status(401) { + authState.handleUnauthorized() + errorMessage = "Session expired. Please try again." } catch APIError.server(let msg) { errorMessage = msg } catch { @@ -318,7 +232,6 @@ private struct RenameListView: View { struct ListTreeNodeRow: View { let node: ListTreeNode let onDeleteList: (UserList) -> Void - let onDeleteFolder: (ListFolder) -> Void let onUpdateList: (UserList) -> Void @State private var isExpanded = true @State private var showRename = false @@ -330,7 +243,7 @@ struct ListTreeNodeRow: View { if let children = node.children, let list = node.list { DisclosureGroup(isExpanded: $isExpanded) { ForEach(children) { child in - ListTreeNodeRow(node: child, onDeleteList: onDeleteList, onDeleteFolder: onDeleteFolder, onUpdateList: onUpdateList) + ListTreeNodeRow(node: child, onDeleteList: onDeleteList, onUpdateList: onUpdateList) } } label: { NavigationLink(value: list) { @@ -355,24 +268,6 @@ struct ListTreeNodeRow: View { .sheet(item: $schemaEditorList) { editing in ListSchemaEditorView(list: editing, schema: schemaEditorSchema) { _ in onUpdateList(editing) } } - } else if let children = node.children { - DisclosureGroup(isExpanded: $isExpanded) { - ForEach(children) { child in - ListTreeNodeRow(node: child, onDeleteList: onDeleteList, onDeleteFolder: onDeleteFolder, onUpdateList: onUpdateList) - } - } label: { - Label(node.name, systemImage: "folder.fill") - .foregroundStyle(.primary) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - if let folder = folderFromNode(node) { - onDeleteFolder(folder) - } - } label: { - Label("Delete", systemImage: "trash") - } - } } else if let list = node.list { NavigationLink(value: list) { ListNameWithVisibility(name: node.name, isPublic: list.isPublic) @@ -412,11 +307,6 @@ struct ListTreeNodeRow: View { schemaEditorSchema = schema schemaEditorList = list } - - private func folderFromNode(_ node: ListTreeNode) -> ListFolder? { - guard node.list == nil, node.children != nil else { return nil } - return ListFolder(id: node.id, name: node.name, parentId: nil, createdAt: nil) - } } private struct ListNameWithVisibility: View { @@ -454,11 +344,16 @@ struct ListDetailView: View { @State private var connections: [ListConnection] = [] @State private var allLists: [UserList] = [] @State private var showAddConnection = false + @State private var addRelationshipRole: ListRelationship = .parent @State private var showAddItem = false @State private var editingItem: ListItem? = nil @State private var deletingItem: ListItem? = nil @State private var showDeleteConfirm = false @State private var showWatchers = false + @State private var showShare = false + @State private var showInvites = false + @State private var isRefreshingGitHub = false + @State private var gitHubRefreshError: String? var body: some View { Group { @@ -478,11 +373,14 @@ struct ListDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List { + if list.isGitHubBacked { + gitHubStatusSection + } if items.isEmpty && !isLoading { ContentUnavailableView { - Label("Empty List", systemImage: "list.bullet") + Label(list.isGitHubBacked ? "No Issues" : "Empty List", systemImage: "list.bullet") } description: { - Text("This list has no items yet.") + Text(list.isGitHubBacked ? "No issues synced from GitHub yet. Pull to refresh." : "This list has no items yet.") } } else { ForEach(items) { item in @@ -505,18 +403,26 @@ struct ListDetailView: View { } Section { if connections.isEmpty { - Text("No connections yet") + Text("No parent or child lists yet") .foregroundStyle(.secondary) .font(.ilBody(15)) } else { ForEach(connections) { conn in - let otherListId = conn.sourceListId == list.id ? conn.targetListId : conn.sourceListId + let role = conn.relationship(relativeTo: list.id) + let otherListId = conn.otherListId(relativeTo: list.id) let otherList = allLists.first { $0.id == otherListId } + let roleLabel = role == .parent ? "Parent" : "Child" HStack { - Image(systemName: "link") + Image(systemName: role == .parent ? "arrow.up.forward.circle" : "arrow.down.forward.circle") .foregroundStyle(.secondary) - Text(otherList?.name ?? otherListId) + VStack(alignment: .leading, spacing: 2) { + Text(otherList?.name ?? otherListId) + Text(roleLabel) + .font(.ilBody(13)) + .foregroundStyle(.secondary) + } } + .accessibilityLabel("\(roleLabel) list: \(otherList?.name ?? otherListId)") } .onDelete { indexSet in Task { @@ -530,15 +436,26 @@ struct ListDetailView: View { } } header: { HStack { - Text("Connections") + Text("Parents & Children") Spacer() - Button { - showAddConnection = true + Menu { + Button { + addRelationshipRole = .parent + showAddConnection = true + } label: { + Label("Add Parent…", systemImage: "arrow.up") + } + Button { + addRelationshipRole = .child + showAddConnection = true + } label: { + Label("Add Child…", systemImage: "arrow.down") + } } label: { Image(systemName: "plus") } .buttonStyle(.borderless) - .accessibilityLabel("Add connection") + .accessibilityLabel("Add a parent or child list") } } } @@ -547,14 +464,30 @@ struct ListDetailView: View { .navigationTitle(list.name) .navigationBarTitleDisplayMode(.large) .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showAddItem = true - } label: { - Image(systemName: "plus") + if list.isGitHubBacked { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await refreshGitHub() } + } label: { + if isRefreshingGitHub { + ProgressView() + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(isRefreshingGitHub) + .accessibilityLabel("Refresh from GitHub") + } + } else { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAddItem = true + } label: { + Image(systemName: "plus") + } + .disabled(schema.isEmpty) + .accessibilityLabel("Add item to list") } - .disabled(schema.isEmpty) - .accessibilityLabel("Add item to list") } ToolbarItem(placement: .topBarTrailing) { Button { @@ -564,6 +497,30 @@ struct ListDetailView: View { } .accessibilityLabel("Manage watchers") } + ToolbarItem(placement: .topBarTrailing) { + Button { + showShare = true + } label: { + Image(systemName: "link") + } + .accessibilityLabel("Share list") + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showInvites = true + } label: { + Image(systemName: "envelope") + } + .accessibilityLabel("Invite by email") + } + ToolbarItem(placement: .topBarTrailing) { + if let url = ILWebURL.list(list.id) { + SwiftUI.ShareLink(item: url) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share link") + } + } } .task { await loadData() @@ -575,24 +532,43 @@ struct ListDetailView: View { WatchersListView(listId: list.id) .environmentObject(authState) } + .sheet(isPresented: $showShare) { + ShareLinksSheet(kind: .lists, resourceId: list.id, title: list.name) + .environmentObject(authState) + } + .sheet(isPresented: $showInvites) { + ShareInvitesSheet(kind: .lists, resourceId: list.id, title: list.name) + .environmentObject(authState) + } .sheet(isPresented: $showAddConnection) { + let role = addRelationshipRole + let connectedIds = Set(connections.map { $0.otherListId(relativeTo: list.id) }) + let candidates = allLists.filter { $0.id != list.id && !connectedIds.contains($0.id) } NavigationStack { List { - ForEach(allLists.filter { $0.id != list.id }) { candidate in - Button(candidate.name) { - Task { - if let conn = try? await APIClient.shared.createListConnection( - sourceListId: list.id, - targetListId: candidate.id - ) { - connections.append(conn) + if candidates.isEmpty { + Text("No other lists available to link.") + .foregroundStyle(.secondary) + .font(.ilBody(15)) + } else { + ForEach(candidates) { candidate in + Button(candidate.name) { + Task { + let fromId = role == .parent ? candidate.id : list.id + let toId = role == .parent ? list.id : candidate.id + if let conn = try? await APIClient.shared.createListConnection( + fromListId: fromId, + toListId: toId + ) { + connections.append(conn) + } + showAddConnection = false } - showAddConnection = false } } } } - .navigationTitle("Connect to List") + .navigationTitle(role == .parent ? "Choose a Parent List" : "Choose a Child List") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -619,6 +595,84 @@ struct ListDetailView: View { } } + @ViewBuilder + private var gitHubStatusSection: some View { + Section { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.branch") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + if let repo = list.githubRepo { + Text(repo) + .font(.ilMono()) + } + Text(gitHubStatusText) + .font(.ilBody(13)) + .foregroundStyle(.secondary) + } + Spacer() + if isRefreshingGitHub { + ProgressView() + } + } + if let gitHubRefreshError { + Text(gitHubRefreshError) + .font(.ilMono()) + .foregroundStyle(.red) + } else if let metaError = list.githubMeta?.refreshError, !metaError.isEmpty { + Text(metaError) + .font(.ilMono()) + .foregroundStyle(.red) + } + } header: { + Text("GitHub") + } + } + + private var gitHubStatusText: String { + let status = (list.githubMeta?.refreshStatus ?? "").lowercased() + switch status { + case "pending", "syncing": + return "Syncing…" + case "failed", "error": + return "Last sync failed" + default: + if let refreshed = list.githubMeta?.lastRefreshedAt { + return "Last refreshed \(Self.relativeDate(refreshed))" + } + return "Not yet refreshed" + } + } + + private static func relativeDate(_ iso: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = formatter.date(from: iso) + if date == nil { + formatter.formatOptions = [.withInternetDateTime] + date = formatter.date(from: iso) + } + guard let date else { return iso } + return RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date()) + } + + private func refreshGitHub() async { + guard !isRefreshingGitHub else { return } + isRefreshingGitHub = true + gitHubRefreshError = nil + defer { isRefreshingGitHub = false } + do { + try await APIClient.shared.refreshList(id: list.id) + await loadData() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + gitHubRefreshError = msg + } catch { + gitHubRefreshError = "Couldn't refresh from GitHub." + } + } + private func addItem(rowData: [String: JSONValue]) async { do { let item = try await APIClient.shared.addListItem(listId: list.id, rowData: rowData) @@ -677,15 +731,15 @@ struct ListDetailView: View { async let schemaTask = APIClient.shared.listSchema(listId: list.id) async let itemsTask = APIClient.shared.listItems(listId: list.id) async let connectionsTask = APIClient.shared.listConnections() - async let allListsTask = APIClient.shared.listsAndFolders() + async let allListsTask = APIClient.shared.lists() let (fetchedSchema, fetchedItems) = try await (schemaTask, itemsTask) schema = fetchedSchema items = fetchedItems pendingUpdates = [:] let listId = list.id connections = (try? await connectionsTask)? - .filter { $0.sourceListId == listId || $0.targetListId == listId } ?? [] - allLists = (try? await allListsTask)?.lists ?? [] + .filter { $0.fromListId == listId || $0.toListId == listId } ?? [] + allLists = (try? await allListsTask) ?? [] } catch APIError.status(401) { authState.handleUnauthorized() errorMessage = "Session expired or not authorized." @@ -879,6 +933,7 @@ struct FieldValueView: View { #Preview("Lists view") { ListsView() .environmentObject(AuthState()) + .environmentObject(AppDataStore()) } #Preview("Dynamic row — multi-column") { diff --git a/InterlinedList/Views/MainTabView.swift b/InterlinedList/Views/MainTabView.swift index c1b5401..d5d6f50 100644 --- a/InterlinedList/Views/MainTabView.swift +++ b/InterlinedList/Views/MainTabView.swift @@ -16,8 +16,10 @@ private enum MainSection: Int, CaseIterable { struct MainTabView: View { @EnvironmentObject var authState: AuthState @EnvironmentObject var store: AppDataStore + @Environment(\.scenePhase) private var scenePhase @State private var selectedSection: MainSection = .home @State private var showNotifications = false + @State private var showMessages = false var body: some View { VStack(spacing: 0) { @@ -33,14 +35,22 @@ struct MainTabView: View { .onChange(of: authState.user?.id) { _, id in if let id { store.onUserIdAvailable(id) } } + .onChange(of: scenePhase) { _, phase in + // Replay any queued offline document edits when returning to the app. + if phase == .active { + Task { await store.pushOutbox() } + } + } } private var topBar: some View { HStack(spacing: 0) { - ForEach([MainSection.home, .lists, .documents, .profile], id: \.rawValue) { section in + ForEach([MainSection.home, .lists, .documents], id: \.rawValue) { section in topBarButton(section: section) } + envelopeButton bellButton + topBarButton(section: .profile) } .padding(.horizontal, 8) .padding(.vertical, 10) @@ -71,6 +81,38 @@ struct MainTabView: View { .buttonStyle(.plain) } + private var envelopeButton: some View { + Button { + showMessages = true + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "envelope") + .font(.ilTitle(20)) + .foregroundStyle(Color.secondary) + .frame(maxWidth: .infinity) + if store.dmUnreadCount > 0 { + Text(store.dmUnreadCount > 99 ? "99+" : "\(store.dmUnreadCount)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.red) + .clipShape(Capsule()) + .offset(x: 6, y: -4) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Direct messages") + .sheet(isPresented: $showMessages, onDismiss: { + Task { await store.refreshDMUnread() } + }) { + MessagesInboxView() + .environmentObject(authState) + } + .frame(maxWidth: .infinity) + } + private var bellButton: some View { Button { showNotifications = true @@ -166,6 +208,9 @@ private struct ProfileView: View { preferencesSection(user: user) } Section("Social") { + NavigationLink(destination: FindPeopleView().environmentObject(authState)) { + Label("Find people", systemImage: "magnifyingglass") + } if let userId = authState.user?.id { NavigationLink(destination: FollowListView(userId: userId, mode: .followers, isOwnProfile: true).environmentObject(authState)) { Label("Followers", systemImage: "person.2") diff --git a/InterlinedList/Views/MarkdownEditor.swift b/InterlinedList/Views/MarkdownEditor.swift index 1fdb5db..5c12262 100644 --- a/InterlinedList/Views/MarkdownEditor.swift +++ b/InterlinedList/Views/MarkdownEditor.swift @@ -28,7 +28,7 @@ final class MarkdownEditorController: ObservableObject { func italic() { handler?(.wrap(prefix: "_", suffix: "_", placeholder: "italic")) } func code() { handler?(.wrap(prefix: "`", suffix: "`", placeholder: "code")) } func link() { handler?(.wrap(prefix: "[", suffix: "](https://)", placeholder: "text")) } - func heading() { handler?(.linePrefix("## ")) } + func heading(level: Int) { handler?(.linePrefix(String(repeating: "#", count: level) + " ")) } func bullet() { handler?(.linePrefix("- ")) } func quote() { handler?(.linePrefix("> ")) } func focus() { handler?(.focus) } @@ -166,6 +166,94 @@ struct MarkdownTextView: UIViewRepresentable { } } +/// A horizontally-scrolling formatting toolbar (Bold, Italic, H1–H6 dropdown, +/// Bullet, Quote, Inline-code, Link) driving a `MarkdownEditorController`. +/// +/// The image button is only rendered when `imageButton` is supplied, so the same +/// bar serves both the document editor (with image upload) and lighter contexts +/// like a list long-text field (formatting only). +struct MarkdownFormatToolbar<ImageButton: View>: View { + let controller: MarkdownEditorController + @ViewBuilder let imageButton: () -> ImageButton + + // A persistent toolbar (rather than a `.keyboard`-placement one) so it works + // reliably above the UIKit-backed text view and stays reachable in Write mode. + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + formatButton("bold", "Bold") { controller.bold() } + formatButton("italic", "Italic") { controller.italic() } + headingMenu + formatButton("list.bullet", "Bullet list") { controller.bullet() } + formatButton("text.quote", "Quote") { controller.quote() } + formatButton("chevron.left.forwardslash.chevron.right", "Inline code") { controller.code() } + formatButton("link", "Link") { controller.link() } + let button = imageButton() + if ImageButton.self != EmptyView.self { + Divider().frame(height: 22).padding(.horizontal, 4) + button + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + } + + private var headingMenu: some View { + Menu { + ForEach(1...6, id: \.self) { level in + Button("Heading \(level)") { controller.heading(level: level) } + } + } label: { + Image(systemName: "textformat.size") + .font(.system(size: 16)) + .foregroundStyle(ILColor.primary) + .frame(width: 40, height: 34) + } + .accessibilityLabel("Heading") + } + + private func formatButton(_ systemImage: String, _ label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 16)) + .foregroundStyle(ILColor.primary) + .frame(width: 40, height: 34) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } +} + +extension MarkdownFormatToolbar where ImageButton == EmptyView { + init(controller: MarkdownEditorController) { + self.init(controller: controller) { EmptyView() } + } +} + +/// A compact, reusable markdown editing surface: the `MarkdownFormatToolbar` +/// above a `MarkdownTextView`, for use outside documents (e.g. a list's +/// long-text field). No image button, no Write/Preview toggle. +struct MarkdownFieldEditor: View { + @Binding var text: String + var minHeight: CGFloat = 120 + + @StateObject private var controller = MarkdownEditorController() + + var body: some View { + VStack(spacing: 0) { + MarkdownFormatToolbar(controller: controller) + Divider() + MarkdownTextView(text: $text, controller: controller) + .frame(minHeight: minHeight) + } + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(.separator), lineWidth: 1) + ) + } +} + /// Full editing surface for a document's markdown: a Write/Preview toggle, the /// `UITextView` editor with a keyboard formatting toolbar, and image insertion /// (upload progress inline, markdown written at the caret). Image upload is @@ -185,20 +273,18 @@ struct DocumentContentEditor: View { var body: some View { VStack(spacing: 0) { - Picker("Editing mode", selection: $mode) { - Text("Write").tag(Mode.write) - Text("Preview").tag(Mode.preview) + HStack { + Spacer() + modeToggle } - .pickerStyle(.segmented) .padding(.horizontal) .padding(.vertical, 8) - .accessibilityLabel("Editing mode") Divider() switch mode { case .write: - formatBar + MarkdownFormatToolbar(controller: controller) { imageButton } Divider() MarkdownTextView(text: $content, controller: controller) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -232,24 +318,30 @@ struct DocumentContentEditor: View { } } - // A persistent toolbar (rather than a `.keyboard`-placement one) so it works - // reliably above the UIKit-backed text view and stays reachable in Write mode. - private var formatBar: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 2) { - formatButton("bold", "Bold") { controller.bold() } - formatButton("italic", "Italic") { controller.italic() } - formatButton("number", "Heading") { controller.heading() } - formatButton("list.bullet", "Bullet list") { controller.bullet() } - formatButton("text.quote", "Quote") { controller.quote() } - formatButton("chevron.left.forwardslash.chevron.right", "Inline code") { controller.code() } - formatButton("link", "Link") { controller.link() } - Divider().frame(height: 22).padding(.horizontal, 4) - imageButton - } - .padding(.horizontal, 12) - .padding(.vertical, 6) + private var modeToggle: some View { + HStack(spacing: 0) { + modeButton(.write, systemImage: "square.and.pencil", label: "Write") + modeButton(.preview, systemImage: "eye", label: "Preview") } + .background(Color(.tertiarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func modeButton(_ target: Mode, systemImage: String, label: String) -> some View { + Button { + mode = target + } label: { + Image(systemName: systemImage) + .font(.system(size: 16)) + .foregroundStyle(mode == target ? ILColor.primary : .secondary) + .frame(width: 44, height: 30) + .background(mode == target ? Color(.systemBackground) : .clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .padding(2) + .accessibilityLabel(label) + .accessibilityAddTraits(mode == target ? [.isSelected] : []) } private var imageButton: some View { @@ -269,17 +361,6 @@ struct DocumentContentEditor: View { .accessibilityLabel(isUploadingImage ? "Uploading image" : "Insert image") } - private func formatButton(_ systemImage: String, _ label: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: systemImage) - .font(.system(size: 16)) - .foregroundStyle(ILColor.primary) - .frame(width: 40, height: 34) - } - .buttonStyle(.plain) - .accessibilityLabel(label) - } - private func uploadPhoto(_ item: PhotosPickerItem) async { imageError = nil isUploadingImage = true @@ -311,7 +392,7 @@ struct DocumentContentEditor: View { } } -#Preview { +#Preview("Document editor") { struct PreviewHost: View { @State private var content = "# Title\n\nSome **markdown** text." var body: some View { @@ -326,3 +407,17 @@ struct DocumentContentEditor: View { } return PreviewHost() } + +#Preview("Field editor") { + struct PreviewHost: View { + @State private var text = "Some **notes** with a\n- bullet" + var body: some View { + Form { + Section("Notes") { + MarkdownFieldEditor(text: $text) + } + } + } + } + return PreviewHost() +} diff --git a/InterlinedList/Views/MessageDetailView.swift b/InterlinedList/Views/MessageDetailView.swift index 6d77189..8fde5ba 100644 --- a/InterlinedList/Views/MessageDetailView.swift +++ b/InterlinedList/Views/MessageDetailView.swift @@ -29,6 +29,7 @@ struct MessageDetailView: View { _dugByMe = State(initialValue: message.dugByMe ?? false) } + private var shareURL: URL? { ILWebURL.message(message.id) } private var isPrivate: Bool { message.publiclyVisible == false } private var canReport: Bool { guard let uid = currentUserId else { return false } @@ -56,6 +57,11 @@ struct MessageDetailView: View { } .navigationTitle("Post") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + shareLinkButton + } + } .sheet(isPresented: $showReplyCompose) { ComposeView(replyTo: message) .environmentObject(authState) @@ -82,6 +88,16 @@ struct MessageDetailView: View { } } + @ViewBuilder + private var shareLinkButton: some View { + if let url = shareURL { + SwiftUI.ShareLink(item: url) { + Label("Share link", systemImage: "square.and.arrow.up") + } + .accessibilityLabel("Share link") + } + } + private var header: some View { HStack { if let username = message.user?.username { diff --git a/InterlinedList/Views/MessageLinkView.swift b/InterlinedList/Views/MessageLinkView.swift new file mode 100644 index 0000000..07ee5e8 --- /dev/null +++ b/InterlinedList/Views/MessageLinkView.swift @@ -0,0 +1,72 @@ +// +// MessageLinkView.swift +// InterlinedList +// + +import SwiftUI + +/// Loader shown when a `interlinedlist://message/<id>` (or web permalink) deep link +/// opens. `MessageDetailView` needs a full `Message`, but a deep link only carries an +/// id, so this fetches the message first and presents loading / error / loaded states. +struct MessageLinkView: View { + let messageId: String + + @EnvironmentObject private var authState: AuthState + @EnvironmentObject private var store: AppDataStore + @Environment(\.dismiss) private var dismiss + @State private var message: Message? + @State private var errorMessage: String? + @State private var isLoading = true + + var body: some View { + NavigationStack { + Group { + if let message { + MessageDetailView(message: message, currentUserId: authState.user?.id) + .environmentObject(authState) + .environmentObject(store) + } else if isLoading { + ProgressView("Loading post…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView { + Label("Unable to open post", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage ?? "This post could not be loaded.") + } actions: { + Button("Retry") { Task { await load() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + .task { await load() } + } + + private func load() async { + errorMessage = nil + isLoading = true + defer { isLoading = false } + do { + message = try await APIClient.shared.message(id: messageId) + } catch APIError.status(401) { + authState.handleUnauthorized() + errorMessage = "You need to be signed in to view this post." + } catch APIError.server(let msg) { + errorMessage = msg + } catch { + errorMessage = "This post could not be loaded." + } + } +} + +#Preview { + MessageLinkView(messageId: "preview-id") + .environmentObject(AuthState()) + .environmentObject(AppDataStore()) +} diff --git a/InterlinedList/Views/MessagesInboxView.swift b/InterlinedList/Views/MessagesInboxView.swift new file mode 100644 index 0000000..c89fdeb --- /dev/null +++ b/InterlinedList/Views/MessagesInboxView.swift @@ -0,0 +1,333 @@ +// +// MessagesInboxView.swift +// InterlinedList +// + +import SwiftUI + +struct MessagesInboxView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + + @State private var folder: DMFolder = .inbox + @State private var messages: [DMMessage] = [] + @State private var isLoading = true + @State private var error: String? + @State private var showRecipientPicker = false + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + Picker("Folder", selection: $folder) { + ForEach(DMFolder.allCases) { f in + Text(f.title).tag(f) + } + } + .pickerStyle(.segmented) + .padding() + .accessibilityLabel("Message folder") + + content + } + .navigationTitle("Messages") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showRecipientPicker = true + } label: { + Image(systemName: "square.and.pencil") + } + .accessibilityLabel("New message") + } + } + .sheet(isPresented: $showRecipientPicker) { + DMRecipientPickerView { user in + showRecipientPicker = false + selectedThreadUser = user + } + .environmentObject(authState) + } + .navigationDestination(item: $selectedThreadUser) { user in + DMThreadView(username: user.username, initialUser: user) + .environmentObject(authState) + } + } + .task(id: folder) { await load() } + } + + @State private var selectedThreadUser: DMUser? + + @ViewBuilder + private var content: some View { + if isLoading && messages.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, messages.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else if messages.isEmpty { + ContentUnavailableView( + emptyTitle, + systemImage: "envelope", + description: Text(emptyDescription) + ) + } else { + List { + ForEach(messages) { message in + Button { + if let other = otherParty(for: message) { + selectedThreadUser = other + } + } label: { + DMInboxRow(message: message, folder: folder, selfId: authState.user?.id) + } + .buttonStyle(.plain) + } + } + .listStyle(.plain) + .refreshable { await load() } + } + } + + private var emptyTitle: String { + switch folder { + case .inbox: return "No messages" + case .sent: return "Nothing sent" + case .deleted: return "Nothing deleted" + } + } + + private var emptyDescription: String { + switch folder { + case .inbox: return "Messages people send you appear here." + case .sent: return "Messages you send appear here." + case .deleted: return "Deleted messages appear here." + } + } + + private func otherParty(for message: DMMessage) -> DMUser? { + message.otherParty(selfId: authState.user?.id) + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + let response = try await APIClient.shared.directMessages(folder: folder) + messages = response.items + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + error = msg + } catch { + self.error = "Could not load messages." + } + } +} + +/// One preview row in the inbox/sent/deleted list. +private struct DMInboxRow: View { + let message: DMMessage + let folder: DMFolder + let selfId: String? + + private var other: DMUser? { message.otherParty(selfId: selfId) } + private var showUnreadDot: Bool { + folder == .inbox && !message.isRead && message.senderId != selfId + } + + var body: some View { + HStack(spacing: 12) { + avatar + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(other?.displayNameOrUsername ?? "Unknown") + .font(.ilBody(15)) + .fontWeight(showUnreadDot ? .bold : .medium) + Spacer() + Text(relativeTime(message.createdAt)) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + if let preview = previewText, !preview.isEmpty { + Text(preview) + .font(.ilBody()) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + if showUnreadDot { + Circle() + .fill(ILColor.primary) + .frame(width: 8, height: 8) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityText) + } + + private var previewText: String? { + if let preview = message.preview, !preview.isEmpty { return preview } + return message.body + } + + @ViewBuilder + private var avatar: some View { + if let urlString = other?.avatar, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image.resizable().scaledToFill() + } else { + Image(systemName: "person.circle.fill").resizable().scaledToFit() + } + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + .foregroundStyle(.secondary) + } + } + + private var accessibilityText: String { + let name = other?.displayNameOrUsername ?? "Unknown" + let unread = showUnreadDot ? "Unread. " : "" + return "\(unread)\(name). \(previewText ?? "")" + } + + private func relativeTime(_ iso: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f.localizedString(for: date, relativeTo: Date()) + } + return "" + } +} + +/// Recipient picker for starting a new conversation (mutual-follow set). +private struct DMRecipientPickerView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + let onSelect: (DMUser) -> Void + + @State private var recipients: [DMUser] = [] + @State private var isLoading = true + @State private var error: String? + @State private var query = "" + + private var filtered: [DMUser] { + DMRecipientFilter.matches(recipients, query: query) + } + + var body: some View { + NavigationStack { + Group { + if isLoading && recipients.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, recipients.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else if recipients.isEmpty { + ContentUnavailableView( + "No one to message", + systemImage: "person.2.slash", + description: Text("You can only message people who follow you back.") + ) + } else { + List(filtered) { user in + Button { + onSelect(user) + } label: { + HStack(spacing: 12) { + avatar(user) + VStack(alignment: .leading, spacing: 2) { + Text(user.displayNameOrUsername) + .font(.ilBody(15)) + .fontWeight(.medium) + Text("@\(user.username)") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Message @\(user.username)") + } + .listStyle(.plain) + .searchable(text: $query, prompt: "Search people") + } + } + .navigationTitle("New Message") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .task { await load() } + } + + @ViewBuilder + private func avatar(_ user: DMUser) -> some View { + if let urlString = user.avatar, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + if let image = phase.image { + image.resizable().scaledToFill() + } else { + Image(systemName: "person.circle.fill").resizable().scaledToFit() + } + } + .frame(width: 36, height: 36) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 36, height: 36) + .foregroundStyle(.secondary) + } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + recipients = try await APIClient.shared.dmRecipients() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load people." + } + } +} + +#Preview { + MessagesInboxView() + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/MutedUsersView.swift b/InterlinedList/Views/MutedUsersView.swift new file mode 100644 index 0000000..4be79de --- /dev/null +++ b/InterlinedList/Views/MutedUsersView.swift @@ -0,0 +1,101 @@ +// +// MutedUsersView.swift +// InterlinedList +// + +import SwiftUI + +struct MutedUsersView: View { + @EnvironmentObject private var authState: AuthState + @State private var mutedUsers: [MutedUser] = [] + @State private var isLoading = true + @State private var error: String? + @State private var actionError: String? + + var body: some View { + Group { + if isLoading && mutedUsers.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, mutedUsers.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else if mutedUsers.isEmpty { + ContentUnavailableView( + "No muted users", + systemImage: "speaker.slash", + description: Text("Users you mute will appear here.") + ) + } else { + List { + if let actionError { + Section { + Text(actionError).font(.ilMono()).foregroundStyle(.red) + } + } + ForEach(mutedUsers) { user in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("@\(user.username)") + .font(.ilBody(15)) + .fontWeight(.medium) + if let displayName = user.displayName, !displayName.isEmpty { + Text(displayName) + .font(.ilBody()) + .foregroundStyle(.secondary) + } + } + Spacer() + Button("Unmute") { + Task { await unmute(user) } + } + .buttonStyle(.bordered) + .font(.ilMono()) + .accessibilityLabel("Unmute @\(user.username)") + } + } + } + } + } + .navigationTitle("Muted Users") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + let response = try await APIClient.shared.mutedUsers() + mutedUsers = response.mutedUsers + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load muted users." + } + } + + private func unmute(_ user: MutedUser) async { + actionError = nil + do { + try await APIClient.shared.unmuteUser(id: user.id) + mutedUsers.removeAll { $0.id == user.id } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + actionError = "Could not unmute @\(user.username)." + } + } +} + +#Preview { + NavigationStack { + MutedUsersView() + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/SessionsView.swift b/InterlinedList/Views/SessionsView.swift new file mode 100644 index 0000000..07bcccb --- /dev/null +++ b/InterlinedList/Views/SessionsView.swift @@ -0,0 +1,164 @@ +// +// SessionsView.swift +// InterlinedList +// + +import SwiftUI + +struct SessionsView: View { + @EnvironmentObject private var authState: AuthState + @State private var sessions: [UserSession] = [] + @State private var isLoading = true + @State private var error: String? + @State private var actionError: String? + + var body: some View { + Group { + if isLoading && sessions.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, sessions.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else if sessions.isEmpty { + ContentUnavailableView( + "No active sessions", + systemImage: "laptopcomputer.and.iphone", + description: Text("Devices signed in to your account will appear here.") + ) + } else { + List { + if let actionError { + Section { + Text(actionError).font(.ilMono()).foregroundStyle(.red) + } + } + Section { + ForEach(sessions) { session in + SessionRow(session: session) + .listRowBackground(session.isCurrent ? Color.accentColor.opacity(0.12) : nil) + .swipeActions(edge: .trailing) { + if !session.isCurrent { + Button(role: .destructive) { + Task { await revoke(session) } + } label: { + Label("Revoke", systemImage: "xmark.circle") + } + .accessibilityLabel("Revoke \(session.deviceLabel ?? "Unknown device")") + } + } + } + } footer: { + Text("Revoking a session signs that device out. Your current device can't be revoked here.") + } + } + } + } + .navigationTitle("Where You're Signed In") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + sessions = try await APIClient.shared.userSessions() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load your sessions." + } + } + + private func revoke(_ session: UserSession) async { + actionError = nil + do { + try await APIClient.shared.revokeSession(id: session.id) + sessions.removeAll { $0.id == session.id } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + actionError = "Could not revoke \(session.deviceLabel ?? "that device")." + } + } +} + +private struct SessionRow: View { + let session: UserSession + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "laptopcomputer.and.iphone") + .font(.title3) + .foregroundStyle(session.isCurrent ? Color.accentColor : .secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(session.deviceLabel ?? "Unknown device") + .font(.ilBody(15)) + .fontWeight(.medium) + if session.isCurrent { + Text("Current device") + .font(.ilMono(10)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.15)) + .clipShape(Capsule()) + .accessibilityLabel("Current device") + } + } + if let subtitle = SessionRow.timestampText(session) { + Text(subtitle) + .font(.ilBody()) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + .padding(.vertical, 2) + } + + private static func timestampText(_ session: UserSession) -> String? { + if let lastUsed = session.lastUsedAt, let relative = relative(from: lastUsed) { + return "Last used \(relative)" + } + if let created = session.createdAt, let relative = relative(from: created) { + return "Signed in \(relative)" + } + return nil + } + + private static func relative(from iso: String) -> String? { + guard !iso.isEmpty else { return nil } + guard let date = fractional.date(from: iso) ?? plain.date(from: iso) else { return nil } + return relativeFormatter.localizedString(for: date, relativeTo: Date()) + } + + private static let fractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let plain = ISO8601DateFormatter() + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() +} + +#Preview { + NavigationStack { + SessionsView() + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/SettingsView.swift b/InterlinedList/Views/SettingsView.swift index b3a7ef6..7674484 100644 --- a/InterlinedList/Views/SettingsView.swift +++ b/InterlinedList/Views/SettingsView.swift @@ -139,6 +139,16 @@ struct SettingsView: View { } label: { Label("Blocked users", systemImage: "person.slash") } + NavigationLink { + MutedUsersView().environmentObject(authState) + } label: { + Label("Muted users", systemImage: "speaker.slash") + } + NavigationLink { + SessionsView().environmentObject(authState) + } label: { + Label("Where you're signed in", systemImage: "laptopcomputer.and.iphone") + } } } diff --git a/InterlinedList/Views/ShareInvitesSheet.swift b/InterlinedList/Views/ShareInvitesSheet.swift new file mode 100644 index 0000000..19aa920 --- /dev/null +++ b/InterlinedList/Views/ShareInvitesSheet.swift @@ -0,0 +1,248 @@ +// +// ShareInvitesSheet.swift +// InterlinedList +// + +import SwiftUI + +/// Owner-facing sheet for managing email share-invites on a list or document. +/// Lists pending/accepted invites (email, role, expiry) with swipe-to-revoke, +/// and — for subscribers — a form to send a new invite. Non-subscribing owners +/// can still see and revoke existing invites; the send form is hidden rather +/// than paywalled. +/// +/// Recipients claim their invite on the **web** (the claim flow is session-only), +/// so iOS only ever sends, lists, and revokes invites here. +struct ShareInvitesSheet: View { + let kind: ShareResourceKind + let resourceId: String + let title: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var invites: [ShareInvite] = [] + @State private var isLoading = true + @State private var error: String? + @State private var actionError: String? + @State private var newEmail = "" + @State private var newRole: WatcherRole = .watcher + @State private var useExpiry = false + @State private var expiryDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date() + @State private var isSending = false + + private var canInvite: Bool { authState.user?.isSubscriber == true } + + private var trimmedEmail: String { newEmail.trimmingCharacters(in: .whitespacesAndNewlines) } + private var isEmailValid: Bool { + let email = trimmedEmail + guard let at = email.firstIndex(of: "@"), at != email.startIndex else { return false } + let domain = email[email.index(after: at)...] + return domain.contains(".") && !domain.hasSuffix(".") + } + + var body: some View { + NavigationStack { + Group { + if isLoading && invites.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, invites.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else { + content + } + } + .navigationTitle("Invite by email") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .task { await load() } + } + } + + @ViewBuilder + private var content: some View { + List { + Section { + Text(title).font(.ilBody()).foregroundStyle(.secondary) + } + + if let actionError { + Section { Text(actionError).font(.ilMono()).foregroundStyle(.red) } + } + + if canInvite { + inviteSection + } + + Section("Invites") { + if invites.isEmpty { + Text("No invites sent yet.") + .font(.ilBody(15)).foregroundStyle(.secondary) + } else { + ForEach(invites) { invite in + ShareInviteRow(invite: invite) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { await revoke(invite) } + } label: { + Label("Revoke", systemImage: "envelope.badge") + } + } + } + } + } + } + } + + @ViewBuilder + private var inviteSection: some View { + Section("Send invite") { + TextField("Email address", text: $newEmail) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.emailAddress) + .accessibilityLabel("Recipient email address") + + Picker("Role", selection: $newRole) { + ForEach(WatcherRole.allCases, id: \.self) { role in + Text(role.label).tag(role) + } + } + .pickerStyle(.segmented) + Text(newRole.detail(for: kind.singularLabel)).font(.ilMono()).foregroundStyle(.secondary) + + Toggle("Set expiry", isOn: $useExpiry) + .accessibilityLabel("Set an expiry date for the invite") + if useExpiry { + DatePicker("Expires", selection: $expiryDate, in: Date()..., displayedComponents: [.date]) + } + + Button { + Task { await send() } + } label: { + HStack { + if isSending { ProgressView() } + Text("Send invite") + } + } + .disabled(isSending || !isEmailValid) + .accessibilityLabel("Send invite") + } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + invites = try await APIClient.shared.shareInvites(kind: kind, id: resourceId) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load invites." + } + } + + private func send() async { + isSending = true + actionError = nil + defer { isSending = false } + let expiresAt = useExpiry ? ISO8601DateFormatter().string(from: expiryDate) : nil + do { + _ = try await APIClient.shared.createShareInvite(kind: kind, id: resourceId, email: trimmedEmail, role: newRole, expiresAt: expiresAt) + newEmail = "" + await load() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.status(403) { + // Subscriber-gated server-side. The UI already hides the form for free + // users, so a 403 here is an edge case; surface a neutral message + // rather than the backend's "Subscribe to…" copy. + actionError = "You can't send invites right now." + } catch { + actionError = "Could not send this invite." + } + } + + private func revoke(_ invite: ShareInvite) async { + actionError = nil + do { + try await APIClient.shared.revokeShareInvite(kind: kind, id: resourceId, token: invite.token) + invites.removeAll { $0.token == invite.token } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not revoke this invite." + } + } +} + +private struct ShareInviteRow: View { + let invite: ShareInvite + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(invite.email) + .font(.ilBody()) + HStack(spacing: 8) { + Text((invite.shareRole ?? .watcher).label) + .font(.ilMono()) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(ILColor.surface2) + .clipShape(Capsule()) + Text(statusText) + .font(.ilMono()) + .foregroundStyle(invite.accepted == true ? Color.green : .secondary) + } + Text(expiryText) + .font(.ilBody(13)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 2) + } + + private var statusText: String { invite.accepted == true ? "Accepted" : "Pending" } + + private var expiryText: String { + guard let expiresAt = invite.expiresAt, !expiresAt.isEmpty else { return "No expiry" } + let date = ShareInviteRow.fractional.date(from: expiresAt) ?? ShareInviteRow.plain.date(from: expiresAt) + guard let date else { return "Expires \(expiresAt)" } + return "Expires \(ShareInviteRow.display.string(from: date))" + } + + private static let fractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let plain = ISO8601DateFormatter() + + private static let display: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .none + return f + }() +} + +#Preview { + ShareInvitesSheet(kind: .lists, resourceId: "list-1", title: "My List") + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/ShareLinksSheet.swift b/InterlinedList/Views/ShareLinksSheet.swift new file mode 100644 index 0000000..043c9f9 --- /dev/null +++ b/InterlinedList/Views/ShareLinksSheet.swift @@ -0,0 +1,228 @@ +// +// ShareLinksSheet.swift +// InterlinedList +// + +import SwiftUI +import UIKit + +/// Owner-facing sheet for managing tokenized share-links on a list or document. +/// Lists active links (role, expiry, copy, revoke) and — for subscribers — +/// offers a control to create a new link. Non-subscribing owners can still see +/// and revoke existing links; the create control is hidden rather than paywalled. +struct ShareLinksSheet: View { + let kind: ShareResourceKind + let resourceId: String + let title: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var links: [ShareLink] = [] + @State private var isLoading = true + @State private var error: String? + @State private var actionError: String? + @State private var newRole: WatcherRole = .watcher + @State private var useExpiry = false + @State private var expiryDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date() + @State private var isCreating = false + + private var canCreate: Bool { authState.user?.isSubscriber == true } + + var body: some View { + NavigationStack { + Group { + if isLoading && links.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, links.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else { + content + } + } + .navigationTitle("Share") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .task { await load() } + } + } + + @ViewBuilder + private var content: some View { + List { + Section { + Text(title).font(.ilBody()).foregroundStyle(.secondary) + } + + if let actionError { + Section { Text(actionError).font(.ilMono()).foregroundStyle(.red) } + } + + if canCreate { + createSection + } + + Section("Active links") { + if links.isEmpty { + Text("No share links yet.") + .font(.ilBody(15)).foregroundStyle(.secondary) + } else { + ForEach(links) { link in + ShareLinkRow(link: link, onCopy: { copy(link) }) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { await revoke(link) } + } label: { + Label("Revoke", systemImage: "link.badge.plus") + } + } + } + } + } + } + } + + @ViewBuilder + private var createSection: some View { + Section("Create link") { + Picker("Role", selection: $newRole) { + ForEach(WatcherRole.allCases, id: \.self) { role in + Text(role.label).tag(role) + } + } + .pickerStyle(.segmented) + Text(newRole.detail(for: kind.singularLabel)).font(.ilMono()).foregroundStyle(.secondary) + + Toggle("Set expiry", isOn: $useExpiry) + .accessibilityLabel("Set an expiry date for the link") + if useExpiry { + DatePicker("Expires", selection: $expiryDate, in: Date()..., displayedComponents: [.date]) + } + + Button { + Task { await create() } + } label: { + HStack { + if isCreating { ProgressView() } + Text("Create share link") + } + } + .disabled(isCreating) + .accessibilityLabel("Create share link") + } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + links = try await APIClient.shared.shareLinks(kind: kind, id: resourceId) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load share links." + } + } + + private func create() async { + isCreating = true + actionError = nil + defer { isCreating = false } + let expiresAt = useExpiry ? ISO8601DateFormatter().string(from: expiryDate) : nil + do { + let link = try await APIClient.shared.createShareLink(kind: kind, id: resourceId, role: newRole, expiresAt: expiresAt) + links.insert(link, at: 0) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not create share link." + } + } + + private func revoke(_ link: ShareLink) async { + actionError = nil + do { + try await APIClient.shared.revokeShareLink(kind: kind, id: resourceId, token: link.token) + links.removeAll { $0.token == link.token } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not revoke this link." + } + } + + private func copy(_ link: ShareLink) { + UIPasteboard.general.string = link.url + } +} + +private struct ShareLinkRow: View { + let link: ShareLink + let onCopy: () -> Void + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text((link.shareRole ?? .watcher).label) + .font(.ilMono()) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(ILColor.surface2) + .clipShape(Capsule()) + Text(expiryText) + .font(.ilBody(13)) + .foregroundStyle(.secondary) + } + Spacer() + Button { + onCopy() + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .accessibilityLabel("Copy link URL") + } + .padding(.vertical, 2) + } + + private var expiryText: String { + guard let expiresAt = link.expiresAt, !expiresAt.isEmpty else { return "No expiry" } + let date = ShareLinkRow.fractional.date(from: expiresAt) ?? ShareLinkRow.plain.date(from: expiresAt) + guard let date else { return "Expires \(expiresAt)" } + return "Expires \(ShareLinkRow.display.string(from: date))" + } + + private static let fractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let plain = ISO8601DateFormatter() + + private static let display: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .none + return f + }() +} + +#Preview { + ShareLinksSheet(kind: .lists, resourceId: "list-1", title: "My List") + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/SharedDocumentView.swift b/InterlinedList/Views/SharedDocumentView.swift new file mode 100644 index 0000000..63e5259 --- /dev/null +++ b/InterlinedList/Views/SharedDocumentView.swift @@ -0,0 +1,100 @@ +// +// SharedDocumentView.swift +// InterlinedList +// + +import SwiftUI + +/// Loader for a document share-link deep link +/// (`https://interlinedlist.com/documents/shared/<token>`), G10. Resolves the token +/// to a read-only document. Edit-capable links (`collaborator`/`manager`) can't be +/// *claimed* on iOS — the claim endpoint is session-cookie only — so we show the +/// content read-only and point the user to the web to accept an edit invite. +struct SharedDocumentView: View { + let token: String + + @EnvironmentObject private var authState: AuthState + @Environment(\.dismiss) private var dismiss + @State private var resolution: SharedDocumentResolution? + @State private var errorMessage: String? + @State private var isLoading = true + + var body: some View { + NavigationStack { + Group { + if let resolution { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if resolution.isEditLink { + editLinkNotice + } + Text(resolution.document.title) + .font(.ilDisplay(20)) + if let content = resolution.document.content, !content.isEmpty { + MarkdownView(content: content) + .textSelection(.enabled) + } else { + Text("This document is empty.") + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } else if isLoading { + ProgressView("Opening shared document…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView { + Label("Link unavailable", systemImage: "link.badge.plus") + } description: { + Text(errorMessage ?? "This share link is invalid, expired, or has been revoked.") + } actions: { + Button("Retry") { Task { await load() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle(resolution?.document.title ?? "Shared document") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + .task { await load() } + } + + private var editLinkNotice: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle") + Text("This is an edit invite. Open the link on interlinedlist.com to accept it — editing a shared document isn't available in the app yet.") + .font(.ilMono()) + } + .foregroundStyle(.secondary) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ILColor.surface) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityElement(children: .combine) + } + + private func load() async { + errorMessage = nil + isLoading = true + defer { isLoading = false } + do { + resolution = try await APIClient.shared.resolveSharedDocument(token: token) + } catch APIError.server(let msg) { + errorMessage = msg + } catch { + errorMessage = "This share link could not be opened." + } + } +} + +#Preview { + SharedDocumentView(token: "preview-token") + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/UserProfileView.swift b/InterlinedList/Views/UserProfileView.swift index f116889..d7e46df 100644 --- a/InterlinedList/Views/UserProfileView.swift +++ b/InterlinedList/Views/UserProfileView.swift @@ -39,6 +39,7 @@ struct UserProfileView: View { @State private var showReportSheet = false @State private var isBlocked = false @State private var blockError: String? + @State private var showMessageThread = false var body: some View { NavigationStack { @@ -73,6 +74,14 @@ struct UserProfileView: View { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } + ToolbarItem(placement: .topBarTrailing) { + if let url = ILWebURL.profile(username) { + SwiftUI.ShareLink(item: url) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share link") + } + } if authState.user?.username != username { ToolbarItem(placement: .topBarTrailing) { Menu { @@ -123,6 +132,19 @@ struct UserProfileView: View { ShareSheet(data: data, filename: exportFilename) } } + .sheet(isPresented: $showMessageThread) { + NavigationStack { + DMThreadView(username: username, initialUser: targetUserId.map { + DMUser(id: $0, username: username, displayName: nil, avatar: nil) + }) + .environmentObject(authState) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { showMessageThread = false } + } + } + } + } } } @@ -148,6 +170,7 @@ struct UserProfileView: View { } Spacer() if targetUserId != nil { + messageButton followButton } } @@ -213,6 +236,17 @@ struct UserProfileView: View { } } + private var messageButton: some View { + Button { + showMessageThread = true + } label: { + Image(systemName: "envelope") + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityLabel("Message @\(username)") + } + @ViewBuilder private var messagesTab: some View { if isLoadingMessages && messages.isEmpty { @@ -460,6 +494,7 @@ struct UserProfileView: View { .padding(.bottom, 8) exportButton(label: "Messages", type: .messages) exportButton(label: "Lists", type: .lists) + exportButton(label: "List Data Rows", type: .listDataRows) exportButton(label: "Follows", type: .follows) if let err = exportError { Text(err) diff --git a/InterlinedList/Views/WatchersListView.swift b/InterlinedList/Views/WatchersListView.swift index 32a413f..c4a8611 100644 --- a/InterlinedList/Views/WatchersListView.swift +++ b/InterlinedList/Views/WatchersListView.swift @@ -187,6 +187,7 @@ private struct AddWatcherView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var authState: AuthState @State private var role: WatcherRole = .watcher + @State private var query = "" @State private var candidates: [WatcherCandidate] = [] @State private var isLoading = false @State private var error: String? @@ -206,12 +207,18 @@ private struct AddWatcherView: View { } Section("People") { + TextField("Search by name or username", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel("Search people to add") + .onSubmit { Task { await loadCandidates() } } + if isLoading { ProgressView() } else if let error { Text(error).font(.ilMono()).foregroundStyle(.red) } else if candidates.isEmpty { - Text("No people available to add.") + Text(query.trimmingCharacters(in: .whitespaces).isEmpty ? "No people available to add." : "No matching people.") .font(.ilBody(15)).foregroundStyle(.secondary) } else { ForEach(candidates) { candidate in @@ -244,15 +251,25 @@ private struct AddWatcherView: View { } } .task { await loadCandidates() } + .onChange(of: query) { _, newValue in + Task { await debouncedSearch(for: newValue) } + } } } + private func debouncedSearch(for value: String) async { + try? await Task.sleep(nanoseconds: 300_000_000) + guard value == query else { return } + await loadCandidates() + } + private func loadCandidates() async { isLoading = true error = nil defer { isLoading = false } + let trimmed = query.trimmingCharacters(in: .whitespaces) do { - candidates = try await APIClient.shared.searchWatcherCandidates(listId: listId) + candidates = try await APIClient.shared.searchWatcherCandidates(listId: listId, search: trimmed.isEmpty ? nil : trimmed) } catch APIError.status(401) { authState.handleUnauthorized() } catch { diff --git a/InterlinedListTests/APIClientTests/APIClientConnectionsTests.swift b/InterlinedListTests/APIClientTests/APIClientConnectionsTests.swift index 077aaf9..ff57e26 100644 --- a/InterlinedListTests/APIClientTests/APIClientConnectionsTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientConnectionsTests.swift @@ -14,7 +14,7 @@ final class APIClientConnectionsTests: XCTestCase { func test_listConnections_sendsGetToCorrectPath() async throws { let json = #""" - {"connections":[{"id":"c1","sourceListId":"s1","targetListId":"t1","createdAt":"2024-01-01T00:00:00Z"}]} + {"connections":[{"id":"c1","fromListId":"s1","toListId":"t1","label":null,"createdAt":"2024-01-01T00:00:00Z"}]} """# session.stub(json: json) let result = try await sut.listConnections() @@ -22,21 +22,26 @@ final class APIClientConnectionsTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/connections") XCTAssertEqual(result.count, 1) XCTAssertEqual(result.first?.id, "c1") + XCTAssertEqual(result.first?.fromListId, "s1") + XCTAssertEqual(result.first?.toListId, "t1") } - func test_createListConnection_sendsPostWithSourceAndTarget() async throws { + func test_createListConnection_sendsPostWithFromAndTo() async throws { + // Backend responds with the raw connection object (not wrapped). let json = #""" - {"connection":{"id":"c2","sourceListId":"src","targetListId":"tgt","createdAt":null}} + {"id":"c2","fromListId":"src","toListId":"tgt","label":null,"createdAt":null} """# session.stub(json: json) - let conn = try await sut.createListConnection(sourceListId: "src", targetListId: "tgt") + let conn = try await sut.createListConnection(fromListId: "src", toListId: "tgt") XCTAssertEqual(session.lastRequest?.httpMethod, "POST") XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/connections") let bodyData = try XCTUnwrap(session.lastRequest?.httpBody) let bodyJSON = try JSONSerialization.jsonObject(with: bodyData) as? [String: String] - XCTAssertEqual(bodyJSON?["sourceListId"], "src") - XCTAssertEqual(bodyJSON?["targetListId"], "tgt") + XCTAssertEqual(bodyJSON?["fromListId"], "src") + XCTAssertEqual(bodyJSON?["toListId"], "tgt") XCTAssertEqual(conn.id, "c2") + XCTAssertEqual(conn.fromListId, "src") + XCTAssertEqual(conn.toListId, "tgt") } func test_deleteListConnection_sendsDeleteToCorrectPath() async throws { diff --git a/InterlinedListTests/APIClientTests/APIClientDirectMessagesTests.swift b/InterlinedListTests/APIClientTests/APIClientDirectMessagesTests.swift new file mode 100644 index 0000000..2840441 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientDirectMessagesTests.swift @@ -0,0 +1,251 @@ +import XCTest +@testable import InterlinedList + +final class APIClientDirectMessagesTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + private let dmMessageJSON = """ + { + "id": "m1", + "pairKey": "a:b", + "senderId": "s1", + "recipientId": "r1", + "body": "hello", + "imageUrls": ["https://img/1.png"], + "createdAt": "2026-07-31T12:00:00.000Z", + "readAt": null, + "sender": {"id":"s1","username":"alice","displayName":"Alice","avatar":null}, + "recipient": {"id":"r1","username":"bob","displayName":null,"avatar":null}, + "preview": "hello there" + } + """ + + // MARK: directMessages() + + func test_directMessages_buildsFolderAndCursorQuery() async throws { + session.stub(json: #"{"items":[],"nextCursor":null}"#) + _ = try await sut.directMessages(folder: .sent, cursor: "cur123") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm") + let query = session.lastRequest?.url?.query ?? "" + XCTAssertTrue(query.contains("folder=sent"), "query was \(query)") + XCTAssertTrue(query.contains("cursor=cur123"), "query was \(query)") + } + + func test_directMessages_inboxOmitsCursorWhenNil() async throws { + session.stub(json: #"{"items":[],"nextCursor":null}"#) + _ = try await sut.directMessages(folder: .inbox) + let query = session.lastRequest?.url?.query ?? "" + XCTAssertTrue(query.contains("folder=inbox")) + XCTAssertFalse(query.contains("cursor")) + } + + func test_directMessages_decodesItemsAndCursor() async throws { + session.stub(json: "{\"items\":[\(dmMessageJSON)],\"nextCursor\":\"next\"}") + let response = try await sut.directMessages(folder: .inbox) + XCTAssertEqual(response.items.count, 1) + XCTAssertEqual(response.items.first?.id, "m1") + XCTAssertEqual(response.items.first?.imageUrls.first, "https://img/1.png") + XCTAssertEqual(response.nextCursor, "next") + } + + func test_directMessages_sendsBearerToken() async throws { + session.stub(json: #"{"items":[],"nextCursor":null}"#) + _ = try await sut.directMessages(folder: .inbox) + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_directMessages_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.directMessages(folder: .inbox) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: sendDirectMessage() + + func test_sendDirectMessage_usesPostToDMPath() async throws { + session.stub(json: "{\"message\":\(dmMessageJSON)}") + _ = try await sut.sendDirectMessage(recipientId: "r1", body: "hi") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm") + } + + func test_sendDirectMessage_bodyUsesCamelCaseKeys() async throws { + session.stub(json: "{\"message\":\(dmMessageJSON)}") + _ = try await sut.sendDirectMessage(recipientId: "r1", body: "hi", imageUrls: ["https://img/x.png"]) + let bodyData = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + XCTAssertNotNil(json?["recipientId"], "recipientId key must be camelCase") + XCTAssertNotNil(json?["imageUrls"], "imageUrls key must be camelCase") + XCTAssertNil(json?["recipient_id"], "must NOT be snake_case") + XCTAssertNil(json?["image_urls"], "must NOT be snake_case") + XCTAssertEqual(json?["recipientId"] as? String, "r1") + XCTAssertEqual(json?["body"] as? String, "hi") + } + + func test_sendDirectMessage_decodesReturnedMessage() async throws { + session.stub(json: "{\"message\":\(dmMessageJSON)}") + let message = try await sut.sendDirectMessage(recipientId: "r1", body: "hello") + XCTAssertEqual(message.id, "m1") + XCTAssertEqual(message.senderId, "s1") + XCTAssertEqual(message.body, "hello") + } + + func test_sendDirectMessage_notMutual_mapsToServerError() async throws { + session.stub(json: #"{"error":"not_mutual"}"#, statusCode: 403) + do { + _ = try await sut.sendDirectMessage(recipientId: "r1", body: "hi") + XCTFail("Expected throw") + } catch APIError.server(let message) { + XCTAssertEqual(message, "not_mutual") + } + } + + func test_sendDirectMessage_selfMessage_mapsToServerError() async throws { + session.stub(json: #"{"error":"self_message"}"#, statusCode: 400) + do { + _ = try await sut.sendDirectMessage(recipientId: "me", body: "hi") + XCTFail("Expected throw") + } catch APIError.server(let message) { + XCTAssertEqual(message, "self_message") + } + } + + // MARK: directMessage(id:) + + func test_directMessage_sendsGetToIdPath() async throws { + session.stub(json: "{\"message\":\(dmMessageJSON)}") + let message = try await sut.directMessage(id: "m1") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/m1") + XCTAssertEqual(message.id, "m1") + } + + // MARK: markDMRead() + + func test_markDMRead_sendsPostToReadPath() async throws { + session.stub(json: #"{"updated":1}"#) + let updated = try await sut.markDMRead(id: "m1") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/m1/read") + XCTAssertEqual(updated, 1) + } + + // MARK: trashDM() / restoreDM() + + func test_trashDM_sendsPostToTrashPath() async throws { + session.stub(json: #"{"ok":true}"#) + try await sut.trashDM(id: "m1") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/m1/trash") + } + + func test_restoreDM_sendsPostToRestorePath() async throws { + session.stub(json: #"{"ok":true}"#) + try await sut.restoreDM(id: "m1") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/m1/restore") + } + + // MARK: dmRecipients() + + func test_dmRecipients_decodesUsers() async throws { + session.stub(json: #"{"recipients":[{"id":"u1","username":"alice","displayName":"Alice","avatar":null}]}"#) + let recipients = try await sut.dmRecipients() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/recipients") + XCTAssertEqual(recipients.count, 1) + XCTAssertEqual(recipients.first?.username, "alice") + } + + // MARK: dmThread() + + func test_dmThread_sendsGetToThreadPath() async throws { + session.stub(json: threadJSON) + _ = try await sut.dmThread(username: "bob") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/thread/bob") + } + + func test_dmThread_decodesFlagsOtherUserAndItems() async throws { + session.stub(json: threadJSON) + let thread = try await sut.dmThread(username: "bob") + XCTAssertTrue(thread.isMutual) + XCTAssertFalse(thread.isBlocked) + XCTAssertEqual(thread.otherUser.username, "bob") + XCTAssertEqual(thread.items.count, 1) + XCTAssertEqual(thread.items.first?.id, "m1") + XCTAssertEqual(thread.olderCursor, "older1") + } + + private var threadJSON: String { + """ + { + "items": [\(dmMessageJSON)], + "olderCursor": "older1", + "isMutual": true, + "isBlocked": false, + "otherUser": {"id":"r1","username":"bob","displayName":"Bob","avatar":null} + } + """ + } + + // MARK: dmThreadUpdates() + + func test_dmThreadUpdates_buildsAfterQuery() async throws { + session.stub(json: threadJSON) + _ = try await sut.dmThreadUpdates(username: "bob", after: "m1") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/thread/bob/updates") + XCTAssertTrue((session.lastRequest?.url?.query ?? "").contains("after=m1")) + } + + // MARK: dmUnreadCount() + + func test_dmUnreadCount_decodesCount() async throws { + session.stub(json: #"{"count":7}"#) + let count = try await sut.dmUnreadCount() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/unread-count") + XCTAssertEqual(count, 7) + } + + func test_dmUnreadCount_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.dmUnreadCount() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: uploadDMImage() + + func test_uploadDMImage_multipartFieldIsFile() async throws { + session.stub(json: #"{"url":"https://cdn/img.png"}"#) + let url = try await sut.uploadDMImage(data: Data([0x1, 0x2, 0x3]), mimeType: "image/png") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/dm/images/upload") + XCTAssertEqual(url, "https://cdn/img.png") + let body = try XCTUnwrap(session.lastRequest?.httpBody) + let bodyString = String(decoding: body, as: UTF8.self) + XCTAssertTrue(bodyString.contains("name=\"file\""), "multipart field must be 'file'") + } + + func test_uploadDMImage_setsMultipartContentType() async throws { + session.stub(json: #"{"url":"https://cdn/img.jpg"}"#) + _ = try await sut.uploadDMImage(data: Data([0x1]), mimeType: "image/jpeg") + let contentType = session.lastRequest?.value(forHTTPHeaderField: "Content-Type") ?? "" + XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary="), "was \(contentType)") + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientDocumentSyncTests.swift b/InterlinedListTests/APIClientTests/APIClientDocumentSyncTests.swift new file mode 100644 index 0000000..582f8b9 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientDocumentSyncTests.swift @@ -0,0 +1,212 @@ +import XCTest +@testable import InterlinedList + +final class APIClientDocumentSyncTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + func test_documentSync_noCursor_sendsGetToBarePath() async throws { + session.stub(json: #"{"folders":[],"documents":[],"lastSyncAt":"2026-07-31T00:00:00Z"}"#) + _ = try await sut.documentSync() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/sync") + XCTAssertNil(session.lastRequest?.url?.query) + } + + func test_documentSync_emptyCursor_omitsQuery() async throws { + session.stub(json: #"{"folders":[],"documents":[]}"#) + _ = try await sut.documentSync(lastSyncAt: "") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/sync") + XCTAssertNil(session.lastRequest?.url?.query) + } + + func test_documentSync_withCursor_appendsQueryThatRoundTrips() async throws { + session.stub(json: #"{"folders":[],"documents":[]}"#) + let cursor = "2026-07-31T12:34:56Z" + _ = try await sut.documentSync(lastSyncAt: cursor) + let url = session.lastRequest?.url + XCTAssertEqual(url?.path, "/api/documents/sync") + // The cursor round-trips: URLComponents leaves `:` unescaped in the query + // (it is query-allowed per RFC 3986), so the value parses back to the original. + let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(items?.first(where: { $0.name == "lastSyncAt" })?.value, cursor) + } + + func test_documentSync_cursorWithReservedChars_isPercentEncoded() async throws { + session.stub(json: #"{"folders":[],"documents":[]}"#) + // A value containing `&`/`=` must be escaped so it can't split the query. + let cursor = "a&b=c" + _ = try await sut.documentSync(lastSyncAt: cursor) + let url = session.lastRequest?.url + let raw = url?.absoluteString ?? "" + XCTAssertFalse(raw.contains("lastSyncAt=a&b=c"), "reserved chars must be escaped: \(raw)") + let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(items?.first(where: { $0.name == "lastSyncAt" })?.value, cursor) + } + + func test_documentSync_sendsAuthorizationHeader() async throws { + session.stub(json: #"{"folders":[],"documents":[]}"#) + _ = try await sut.documentSync() + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_documentSync_decodesFoldersDocumentsAndCursor() async throws { + let json = #""" + { + "folders": [{"id":"f1","name":"Projects","parentId":null,"updatedAt":"2026-07-30T00:00:00Z"}], + "documents": [{"id":"d1","folderId":"f1","title":"Notes","content":"# Hi","isPublic":false,"updatedAt":"2026-07-30T00:00:00Z"}], + "lastSyncAt": "2026-07-31T00:00:00Z" + } + """# + session.stub(json: json) + let resp = try await sut.documentSync() + XCTAssertEqual(resp.folders.count, 1) + XCTAssertEqual(resp.folders.first?.id, "f1") + XCTAssertEqual(resp.folders.first?.name, "Projects") + XCTAssertEqual(resp.documents.count, 1) + XCTAssertEqual(resp.documents.first?.id, "d1") + XCTAssertEqual(resp.documents.first?.folderId, "f1") + XCTAssertEqual(resp.lastSyncAt, "2026-07-31T00:00:00Z") + } + + func test_documentSync_toleratesTombstonedRows() async throws { + let json = #""" + { + "folders": [{"id":"f1","name":"Old","deletedAt":"2026-07-31T00:00:00Z"}], + "documents": [{"id":"d1","title":"Gone","deletedAt":"2026-07-31T00:00:00Z"}], + "lastSyncAt": "2026-07-31T00:00:00Z" + } + """# + session.stub(json: json) + let resp = try await sut.documentSync(lastSyncAt: "2026-07-30T00:00:00Z") + XCTAssertEqual(resp.folders.first?.deletedAt, "2026-07-31T00:00:00Z") + XCTAssertEqual(resp.documents.first?.deletedAt, "2026-07-31T00:00:00Z") + } + + func test_documentSync_missingArrays_decodeAsEmpty() async throws { + session.stub(json: #"{"lastSyncAt":"2026-07-31T00:00:00Z"}"#) + let resp = try await sut.documentSync() + XCTAssertTrue(resp.folders.isEmpty) + XCTAssertTrue(resp.documents.isEmpty) + XCTAssertEqual(resp.lastSyncAt, "2026-07-31T00:00:00Z") + } + + func test_documentSync_401_throwsStatus() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.documentSync() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_documentSync_429_throwsStatus() async throws { + session.stub(data: Data(), statusCode: 429) + do { + _ = try await sut.documentSync(lastSyncAt: "2026-07-30T00:00:00Z") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 429) + } + } + + // MARK: - pushDocumentSync (Slice 2) + + private func opsBody(from request: URLRequest?) throws -> [[String: Any]] { + let body = try XCTUnwrap(request?.httpBody) + let obj = try JSONSerialization.jsonObject(with: body) as? [String: Any] + return try XCTUnwrap(obj?["operations"] as? [[String: Any]]) + } + + func test_pushDocumentSync_postsToSyncPath() async throws { + session.stub(json: #"{"lastSyncAt":"2026-08-01T00:00:00Z"}"#) + _ = try await sut.pushDocumentSync(operations: [ + SyncOperation(op: .create, type: .document, data: SyncOpData(id: "d1", title: "T")) + ]) + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/sync") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_pushDocumentSync_returnsLastSyncAt() async throws { + session.stub(json: #"{"lastSyncAt":"2026-08-01T12:00:00Z"}"#) + let cursor = try await sut.pushDocumentSync(operations: [ + SyncOperation(op: .update, type: .document, data: SyncOpData(id: "d1", title: "T")) + ]) + XCTAssertEqual(cursor, "2026-08-01T12:00:00Z") + } + + func test_pushDocumentSync_bodyIsCamelCaseOperationsArray() async throws { + session.stub(json: #"{"lastSyncAt":"c"}"#) + _ = try await sut.pushDocumentSync(operations: [ + SyncOperation(op: .create, type: .document, + data: SyncOpData(id: "d1", folderId: "f1", title: "Notes", + content: "# Hi", isPublic: true)) + ]) + let ops = try opsBody(from: session.lastRequest) + XCTAssertEqual(ops.count, 1) + XCTAssertEqual(ops[0]["op"] as? String, "create") + XCTAssertEqual(ops[0]["type"] as? String, "document") + let data = try XCTUnwrap(ops[0]["data"] as? [String: Any]) + XCTAssertEqual(data["id"] as? String, "d1") + XCTAssertEqual(data["folderId"] as? String, "f1") + XCTAssertEqual(data["title"] as? String, "Notes") + XCTAssertEqual(data["isPublic"] as? Bool, true) + } + + func test_pushDocumentSync_deleteOp_carriesOnlyId() async throws { + session.stub(json: #"{"lastSyncAt":"c"}"#) + _ = try await sut.pushDocumentSync(operations: [ + SyncOperation(op: .delete, type: .document, data: SyncOpData(id: "d9")) + ]) + let ops = try opsBody(from: session.lastRequest) + XCTAssertEqual(ops[0]["op"] as? String, "delete") + let data = try XCTUnwrap(ops[0]["data"] as? [String: Any]) + XCTAssertEqual(data.keys.sorted(), ["id"]) + XCTAssertEqual(data["id"] as? String, "d9") + } + + func test_pushDocumentSync_429_throwsRateLimited() async throws { + session.stub(data: Data(), statusCode: 429) + do { + _ = try await sut.pushDocumentSync(operations: [ + SyncOperation(op: .create, type: .document, data: SyncOpData(id: "d1", title: "T")) + ]) + XCTFail("Expected rateLimited") + } catch APIError.rateLimited { + // expected — distinct retryable error, not a hard .status(429) + } + } + + func test_pushDocumentSync_401_throwsStatus() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.pushDocumentSync(operations: [ + SyncOperation(op: .create, type: .document, data: SyncOpData(id: "d1", title: "T")) + ]) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_pushDocumentSync_missingCursor_throwsNoData() async throws { + session.stub(json: #"{}"#) + do { + _ = try await sut.pushDocumentSync(operations: [ + SyncOperation(op: .create, type: .document, data: SyncOpData(id: "d1", title: "T")) + ]) + XCTFail("Expected noData") + } catch APIError.noData { + // expected + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientDocumentTemplatesTests.swift b/InterlinedListTests/APIClientTests/APIClientDocumentTemplatesTests.swift new file mode 100644 index 0000000..bd12d60 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientDocumentTemplatesTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import InterlinedList + +final class APIClientDocumentTemplatesTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + private func bodyString() -> String { + guard let data = session.lastRequest?.httpBody else { return "" } + return String(data: data, encoding: .utf8) ?? "" + } + + // MARK: documentTemplates() + + func test_documentTemplates_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"folderCreated":false,"templatesFolderId":"tf","templates":[]}"#) + _ = try await sut.documentTemplates() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/templates") + } + + func test_documentTemplates_sendsBearerToken() async throws { + session.stub(json: #"{"templates":[]}"#) + _ = try await sut.documentTemplates() + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_documentTemplates_decodesTemplates() async throws { + session.stub(json: #""" + {"folderCreated":true,"templatesFolderId":"tf","templates":[ + {"id":"t1","title":"Weekly Notes","relativePath":"notes/weekly"}, + {"id":"t2","title":"Meeting","relativePath":null} + ]} + """#) + let templates = try await sut.documentTemplates() + XCTAssertEqual(templates.count, 2) + XCTAssertEqual(templates.first?.id, "t1") + XCTAssertEqual(templates.first?.title, "Weekly Notes") + XCTAssertEqual(templates.first?.relativePath, "notes/weekly") + XCTAssertNil(templates.last?.relativePath) + } + + func test_documentTemplates_401_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.documentTemplates() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: createDocumentFromTemplate() + + func test_createFromTemplate_sendsPostToCorrectPath() async throws { + session.stub(json: #"{"document":{"id":"d9","title":"Copy"}}"#, statusCode: 201) + _ = try await sut.createDocumentFromTemplate(templateDocumentId: "t1", targetFolderId: "f2") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/from-template") + } + + func test_createFromTemplate_usesCamelCaseBodyKeys() async throws { + session.stub(json: #"{"document":{"id":"d9","title":"Copy"}}"#, statusCode: 201) + _ = try await sut.createDocumentFromTemplate(templateDocumentId: "t1", targetFolderId: "f2") + let body = bodyString() + XCTAssertTrue(body.contains("\"templateDocumentId\""), "camelCase key missing: \(body)") + XCTAssertTrue(body.contains("\"targetFolderId\""), "camelCase key missing: \(body)") + XCTAssertFalse(body.contains("template_document_id")) + XCTAssertFalse(body.contains("target_folder_id")) + } + + func test_createFromTemplate_nilFolder_omitsTargetFolderId() async throws { + session.stub(json: #"{"document":{"id":"d9","title":"Copy"}}"#, statusCode: 201) + _ = try await sut.createDocumentFromTemplate(templateDocumentId: "t1", targetFolderId: nil) + let body = bodyString() + XCTAssertTrue(body.contains("\"templateDocumentId\"")) + XCTAssertFalse(body.contains("targetFolderId"), "nil folder should omit the key: \(body)") + } + + func test_createFromTemplate_decodesWrappedDocument() async throws { + session.stub(json: #"{"message":"ok","document":{"id":"d9","title":"Copy","content":"hi"}}"#, statusCode: 201) + let doc = try await sut.createDocumentFromTemplate(templateDocumentId: "t1", targetFolderId: nil) + XCTAssertEqual(doc.id, "d9") + XCTAssertEqual(doc.title, "Copy") + XCTAssertEqual(doc.content, "hi") + } + + func test_createFromTemplate_decodesBareDocument() async throws { + session.stub(json: #"{"id":"d10","title":"Bare"}"#, statusCode: 201) + let doc = try await sut.createDocumentFromTemplate(templateDocumentId: "t1", targetFolderId: nil) + XCTAssertEqual(doc.id, "d10") + XCTAssertEqual(doc.title, "Bare") + } + + func test_createFromTemplate_403_throwsStatusError() async throws { + session.stub(json: #"{"error":"Subscribe to create documents."}"#, statusCode: 403) + do { + _ = try await sut.createDocumentFromTemplate(templateDocumentId: "t1", targetFolderId: nil) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } catch APIError.server(let msg) { + // The client surfaces the {"error":...} body when present; accept either shape + // so the test asserts the subscriber gate is rejected, not the exact error type. + XCTAssertEqual(msg, "Subscribe to create documents.") + } + } + + func test_createFromTemplate_403_withoutErrorBody_throwsStatus403() async throws { + session.stub(data: Data(), statusCode: 403) + do { + _ = try await sut.createDocumentFromTemplate(templateDocumentId: "t1", targetFolderId: nil) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientExportTests.swift b/InterlinedListTests/APIClientTests/APIClientExportTests.swift index cab7b99..5a0b66c 100644 --- a/InterlinedListTests/APIClientTests/APIClientExportTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientExportTests.swift @@ -36,6 +36,14 @@ final class APIClientExportTests: XCTestCase { XCTAssertEqual(String(data: data, encoding: .utf8), "follower_id,following_id\n") } + func test_exportCSV_listDataRows_sendsGetToCorrectPath() async throws { + session.stub(data: Data("list_id,row_id\n".utf8)) + let data = try await sut.exportCSV(.listDataRows) + XCTAssertEqual(session.lastRequest?.url?.path, "/api/exports/list-data-rows") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(String(data: data, encoding: .utf8), "list_id,row_id\n") + } + func test_exportCSV_401_throwsStatusError() async throws { session.stub(json: #"{"error":"unauthorized"}"#, statusCode: 401) do { diff --git a/InterlinedListTests/APIClientTests/APIClientFollowTests.swift b/InterlinedListTests/APIClientTests/APIClientFollowTests.swift index 6b32482..2680b3b 100644 --- a/InterlinedListTests/APIClientTests/APIClientFollowTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientFollowTests.swift @@ -5,7 +5,8 @@ final class APIClientFollowTests: XCTestCase { var sut: APIClient! var session: MockURLSession! - private let statusJSON = #"{"following":true,"followed_by":false,"pending_request":false}"# + private let statusJSON = #"{"status":"approved","isFollowing":true,"isPending":false}"# + private let followActionJSON = #"{"follow":{"id":"f1","status":"approved","followerId":"me","followingId":"u1"}}"# private let countsJSON = #"{"followers":10,"following":5}"# override func setUp() { @@ -18,7 +19,7 @@ final class APIClientFollowTests: XCTestCase { // MARK: followUser() func test_followUser_sendsPostToCorrectPath() async throws { - session.stub(json: statusJSON) + session.stub(json: followActionJSON) let status = try await sut.followUser(userId: "u1") XCTAssertEqual(session.lastRequest?.httpMethod, "POST") XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/follow/u1") == true) diff --git a/InterlinedListTests/APIClientTests/APIClientGitHubTests.swift b/InterlinedListTests/APIClientTests/APIClientGitHubTests.swift new file mode 100644 index 0000000..ad06e19 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientGitHubTests.swift @@ -0,0 +1,182 @@ +import XCTest +@testable import InterlinedList + +final class APIClientGitHubTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + // MARK: - githubRepos() + + private let reposJSON = """ + [ + {"full_name":"octocat/Hello-World","name":"Hello-World","private":false,"owner":{"login":"octocat"}}, + {"full_name":"acme/secret-repo","name":"secret-repo","private":true,"owner":{"login":"acme"}} + ] + """ + + func test_githubRepos_sendsGetToCorrectPath() async throws { + session.stub(json: reposJSON) + _ = try await sut.githubRepos() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/github/repos") + } + + func test_githubRepos_sendsBearerToken() async throws { + session.stub(json: reposJSON) + _ = try await sut.githubRepos() + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_githubRepos_decodesFullNameIsPrivateOwnerLogin() async throws { + session.stub(json: reposJSON) + let repos = try await sut.githubRepos() + XCTAssertEqual(repos.count, 2) + let first = try XCTUnwrap(repos.first) + XCTAssertEqual(first.fullName, "octocat/Hello-World") + XCTAssertEqual(first.name, "Hello-World") + XCTAssertEqual(first.isPrivate, false) + XCTAssertEqual(first.ownerLogin, "octocat") + XCTAssertEqual(repos[1].isPrivate, true) + XCTAssertEqual(repos[1].ownerLogin, "acme") + } + + func test_githubRepos_tolerantOfMissingOptionalFields() async throws { + // Only full_name present — name/private/owner absent. + session.stub(json: #"[{"full_name":"solo/repo"}]"#) + let repos = try await sut.githubRepos() + let repo = try XCTUnwrap(repos.first) + XCTAssertEqual(repo.fullName, "solo/repo") + XCTAssertNil(repo.name) + XCTAssertNil(repo.isPrivate) + XCTAssertNil(repo.ownerLogin) + // Derived accessors still work off full_name. + XCTAssertEqual(repo.owner, "solo") + XCTAssertEqual(repo.repo, "repo") + } + + func test_githubRepos_400NotLinked_throwsServer() async throws { + session.stub(json: #"{"error":"GitHub account not linked"}"#, statusCode: 400) + do { + _ = try await sut.githubRepos() + XCTFail("Expected throw") + } catch APIError.server(let msg) { + XCTAssertEqual(msg, "GitHub account not linked") + } + } + + func test_githubRepos_400WithoutErrorBody_throwsStatus() async throws { + session.stub(data: Data(), statusCode: 400) + do { + _ = try await sut.githubRepos() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 400) + } + } + + func test_githubRepos_401_throwsStatus() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.githubRepos() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: - githubIssues() + + func test_githubIssues_sendsRepoAndStateQuery() async throws { + session.stub(json: #"[{"number":1,"title":"Bug","state":"open","html_url":"https://github.com/o/r/issues/1"}]"#) + let issues = try await sut.githubIssues(repo: "octocat/Hello-World", state: "open") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/github/issues") + let query = session.lastRequest?.url?.query ?? "" + XCTAssertTrue(query.contains("repo=octocat/Hello-World") || query.contains("repo=octocat%2FHello-World")) + XCTAssertTrue(query.contains("state=open")) + XCTAssertEqual(issues.first?.number, 1) + XCTAssertEqual(issues.first?.title, "Bug") + XCTAssertEqual(issues.first?.htmlUrl, "https://github.com/o/r/issues/1") + } + + // MARK: - refreshList() + + func test_refreshList_sendsPostToCorrectPath() async throws { + session.stub(json: #"{"message":"Refreshed","count":3}"#) + try await sut.refreshList(id: "l1") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/l1/refresh") + } + + func test_refreshList_sendsBearerToken() async throws { + session.stub(json: #"{"message":"Refreshed"}"#) + try await sut.refreshList(id: "l1") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_refreshList_400NotGitHub_throwsServer() async throws { + session.stub(json: #"{"error":"Refresh is only available for GitHub-backed lists"}"#, statusCode: 400) + do { + try await sut.refreshList(id: "l1") + XCTFail("Expected throw") + } catch APIError.server(let msg) { + XCTAssertEqual(msg, "Refresh is only available for GitHub-backed lists") + } + } + + // MARK: - createList() with githubSource + + private let listJSON = #"{"id":"gh1","title":"Issues","source":"github","githubRepo":"octocat/Hello-World","created_at":"2024-01-01T00:00:00Z"}"# + + func test_createList_withGitHubSource_sendsGitHubSourceObject() async throws { + session.stub(json: #"{"data":\#(listJSON),"refreshStatus":"pending"}"#) + _ = try await sut.createList( + title: "Issues", + description: nil, + isPublic: true, + githubSource: APIClient.GitHubSource(owner: "octocat", repo: "Hello-World") + ) + let body = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try XCTUnwrap(try? JSONSerialization.jsonObject(with: body) as? [String: Any]) + let source = try XCTUnwrap(json["githubSource"] as? [String: Any], + "githubSource must be an object with owner/repo") + XCTAssertEqual(source["owner"] as? String, "octocat") + XCTAssertEqual(source["repo"] as? String, "Hello-World") + XCTAssertNil(json["schema"], "GitHub-backed create must not send a DSL schema") + } + + func test_createList_withGitHubSource_decodesGitHubList() async throws { + session.stub(json: #"{"data":\#(listJSON),"refreshStatus":"pending"}"#) + let list = try await sut.createList( + title: "Issues", + description: nil, + isPublic: true, + githubSource: APIClient.GitHubSource(owner: "octocat", repo: "Hello-World") + ) + XCTAssertEqual(list.source, "github") + XCTAssertEqual(list.githubRepo, "octocat/Hello-World") + XCTAssertTrue(list.isGitHubBacked) + } + + func test_createList_localList_stillSendsDSLObjectAndNoGitHubSource() async throws { + session.stub(json: #"{"data":{"id":"l2","title":"Books","created_at":"2024-01-01T00:00:00Z"}}"#) + let schema = ListSchemaDSL(name: "Books", description: nil, fields: [ + .init(key: "title", label: "Title", type: "text", displayOrder: 0, required: false, visible: true), + ]) + _ = try await sut.createList(title: "Books", description: nil, isPublic: true, schema: schema) + let body = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try XCTUnwrap(try? JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertNil(json["githubSource"], "Local list must not send githubSource") + let sentSchema = try XCTUnwrap(json["schema"] as? [String: Any], + "Local list must still send the DSL schema object") + XCTAssertEqual(sentSchema["name"] as? String, "Books") + let fields = try XCTUnwrap(sentSchema["fields"] as? [[String: Any]]) + XCTAssertEqual(fields.first?["key"] as? String, "title") + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift b/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift index ef615c8..ce62ed0 100644 --- a/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift @@ -145,12 +145,12 @@ final class APIClientImageUploadTests: XCTestCase { "Multipart body should declare a .png filename for PNG") } - func test_uploadDocumentImage_multipartFieldNameIsImage() async throws { + func test_uploadDocumentImage_multipartFieldNameIsFile() async throws { session.stub(json: #"{"url":"https://cdn.example.com/doc-img.jpg"}"#) _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") let body = session.lastRequest?.httpBody ?? Data() - XCTAssertNotNil(body.range(of: Data(#"name="image""#.utf8)), - "Multipart form field must be named 'image'") + XCTAssertNotNil(body.range(of: Data(#"name="file""#.utf8)), + "Multipart form field must be named 'file' (backend reads formData.get(\"file\"))") } func test_uploadDocumentImage_401_throwsStatusError() async throws { diff --git a/InterlinedListTests/APIClientTests/APIClientLinkMetadataTests.swift b/InterlinedListTests/APIClientTests/APIClientLinkMetadataTests.swift new file mode 100644 index 0000000..c1abe3a --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientLinkMetadataTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import InterlinedList + +final class APIClientLinkMetadataTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + func test_linkMetadata_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"link":{"url":"https://x.com","fetchStatus":"failed"}}"#) + _ = try await sut.linkMetadata(url: "https://x.com") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/link-metadata") + } + + func test_linkMetadata_sendsUrlQueryItem() async throws { + session.stub(json: #"{"link":{"url":"https://x.com","fetchStatus":"failed"}}"#) + _ = try await sut.linkMetadata(url: "https://example.com/a") + let items = URLComponents(url: session.lastRequest!.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "url" }?.value, "https://example.com/a") + } + + func test_linkMetadata_percentEncodesUrlWithQueryString() async throws { + session.stub(json: #"{"link":{"url":"https://x.com","fetchStatus":"failed"}}"#) + _ = try await sut.linkMetadata(url: "https://x.com/p?a=1&b=2") + // The reserved `&` in the target URL must not split into spurious params. + let items = URLComponents(url: session.lastRequest!.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first { $0.name == "url" }?.value, "https://x.com/p?a=1&b=2") + } + + func test_linkMetadata_decodesSuccessfulPreview() async throws { + session.stub(json: #""" + {"link":{ + "url":"https://example.com", + "platform":"web", + "fetchStatus":"success", + "metadata":{"title":"Example","description":"A site","thumbnail":"https://example.com/og.png","type":"website"} + }} + """#) + let link = try await sut.linkMetadata(url: "https://example.com") + XCTAssertEqual(link.url, "https://example.com") + XCTAssertEqual(link.fetchStatus, "success") + XCTAssertEqual(link.metadata?.title, "Example") + XCTAssertEqual(link.metadata?.description, "A site") + XCTAssertEqual(link.metadata?.thumbnail, "https://example.com/og.png") + } + + func test_linkMetadata_decodesFailedPreview() async throws { + session.stub(json: #"{"link":{"url":"https://nope.example","fetchStatus":"failed","metadata":null}}"#) + let link = try await sut.linkMetadata(url: "https://nope.example") + XCTAssertEqual(link.fetchStatus, "failed") + XCTAssertNil(link.metadata) + } + + func test_linkMetadata_sendsBearerToken() async throws { + session.stub(json: #"{"link":{"url":"https://x.com","fetchStatus":"failed"}}"#) + _ = try await sut.linkMetadata(url: "https://x.com") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_linkMetadata_429_withErrorBody_throwsServerError() async throws { + // The live endpoint returns `{ error: "Too many requests" }` on 429, which + // checkResponse maps to .server (an {error} body wins over the raw status). + session.stub(json: #"{"error":"Too many requests"}"#, statusCode: 429) + do { + _ = try await sut.linkMetadata(url: "https://x.com") + XCTFail("Expected throw") + } catch APIError.server(let msg) { + XCTAssertEqual(msg, "Too many requests") + } + } + + func test_linkMetadata_429_withoutBody_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 429) + do { + _ = try await sut.linkMetadata(url: "https://x.com") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 429) + } + } + + func test_linkMetadata_400_badUrl_throwsServerError() async throws { + session.stub(json: #"{"error":"URL must be http or https"}"#, statusCode: 400) + do { + _ = try await sut.linkMetadata(url: "ftp://x") + XCTFail("Expected throw") + } catch APIError.server(let msg) { + XCTAssertEqual(msg, "URL must be http or https") + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientLinkedInTargetsTests.swift b/InterlinedListTests/APIClientTests/APIClientLinkedInTargetsTests.swift new file mode 100644 index 0000000..68314c0 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientLinkedInTargetsTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import InterlinedList + +final class APIClientLinkedInTargetsTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + private let fullTargetsJSON = #""" + { + "targets": [ + { "kind": "personal", "label": "Jane Doe", "avatarUrl": "https://cdn/a.jpg", "pageId": null, "personalPageId": null, "linkedInPageId": null, "enabled": true }, + { "kind": "orgPage", "label": "Acme Inc", "avatarUrl": null, "pageId": "page-1", "personalPageId": null, "linkedInPageId": "urn:li:org:1", "enabled": true }, + { "kind": "personalPage", "label": "Side Co", "avatarUrl": null, "pageId": null, "personalPageId": "pp-9", "linkedInPageId": "urn:li:org:9", "enabled": false } + ], + "orgScopeMissing": false + } + """# + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + // MARK: linkedInPostingTargets + + func test_linkedInPostingTargets_sendsGetToCorrectPathWithBearer() async throws { + session.stub(json: fullTargetsJSON) + _ = try await sut.linkedInPostingTargets() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/linkedin/posting-targets") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_linkedInPostingTargets_decodesAllVariants() async throws { + session.stub(json: fullTargetsJSON) + let targets = try await sut.linkedInPostingTargets() + XCTAssertEqual(targets.count, 3) + + let personal = targets[0] + XCTAssertEqual(personal.kind, "personal") + XCTAssertEqual(personal.label, "Jane Doe") + XCTAssertEqual(personal.avatarUrl, "https://cdn/a.jpg") + XCTAssertNil(personal.pageId) + XCTAssertNil(personal.personalPageId) + XCTAssertTrue(personal.enabled) + + let org = targets[1] + XCTAssertEqual(org.kind, "orgPage") + XCTAssertEqual(org.pageId, "page-1") + XCTAssertEqual(org.linkedInPageId, "urn:li:org:1") + XCTAssertNil(org.avatarUrl) + + let personalPage = targets[2] + XCTAssertEqual(personalPage.kind, "personalPage") + XCTAssertEqual(personalPage.personalPageId, "pp-9") + XCTAssertFalse(personalPage.enabled) + } + + func test_linkedInPostingTargets_tolerantOfMissingOptionalFields() async throws { + session.stub(json: #"{"targets":[{"kind":"personal","label":"Me","enabled":true}]}"#) + let targets = try await sut.linkedInPostingTargets() + XCTAssertEqual(targets.count, 1) + let t = targets[0] + XCTAssertEqual(t.kind, "personal") + XCTAssertNil(t.avatarUrl) + XCTAssertNil(t.pageId) + XCTAssertNil(t.personalPageId) + XCTAssertNil(t.linkedInPageId) + } + + func test_linkedInPostingTargets_missingTargetsKey_returnsEmpty() async throws { + session.stub(json: #"{"orgScopeMissing":true}"#) + let targets = try await sut.linkedInPostingTargets() + XCTAssertTrue(targets.isEmpty) + } + + func test_linkedInPostingTargets_401_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.linkedInPostingTargets() + XCTFail("Expected APIError.status(401)") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_linkedInPostingTargets_403_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 403) + do { + _ = try await sut.linkedInPostingTargets() + XCTFail("Expected APIError.status(403)") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } + } + + func test_linkedInPostingTargets_500_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 500) + do { + _ = try await sut.linkedInPostingTargets() + XCTFail("Expected APIError.status(500)") + } catch APIError.status(let code) { + XCTAssertEqual(code, 500) + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientListFolderTests.swift b/InterlinedListTests/APIClientTests/APIClientListFolderTests.swift index 823c51e..82bd36d 100644 --- a/InterlinedListTests/APIClientTests/APIClientListFolderTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientListFolderTests.swift @@ -1,11 +1,13 @@ import XCTest @testable import InterlinedList +// NOTE: List folders were removed from the app; this file retains the plain +// list-mutation coverage (updateList / searchLists) that lived alongside the +// old folder tests. final class APIClientListFolderTests: XCTestCase { var sut: APIClient! var session: MockURLSession! - private let folderJSON = #"{"id":"f1","name":"My Folder","created_at":"2024-01-01T00:00:00Z"}"# private let listJSON = #"{"id":"l1","title":"My List","created_at":"2024-01-01T00:00:00Z"}"# override func setUp() { @@ -15,151 +17,6 @@ final class APIClientListFolderTests: XCTestCase { sut.setBearerToken("tok") } - // MARK: createListFolder() - - func test_createListFolder_sendsPostToCorrectPath() async throws { - session.stub(json: #"{"folder":\#(folderJSON)}"#) - _ = try await sut.createListFolder(name: "My Folder", parentId: nil) - XCTAssertEqual(session.lastRequest?.httpMethod, "POST") - XCTAssertEqual(session.lastRequest?.url?.path, "/api/folders") - } - - func test_createListFolder_sendsBearerToken() async throws { - session.stub(json: #"{"folder":\#(folderJSON)}"#) - _ = try await sut.createListFolder(name: "My Folder", parentId: nil) - XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") - } - - func test_createListFolder_bodyContainsName() async throws { - session.stub(json: #"{"folder":\#(folderJSON)}"#) - _ = try await sut.createListFolder(name: "My Folder", parentId: nil) - let body = try XCTUnwrap(session.lastRequest?.httpBody) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertEqual(json["name"] as? String, "My Folder") - } - - func test_createListFolder_withParentId_sendsParentId() async throws { - session.stub(json: #"{"folder":\#(folderJSON)}"#) - _ = try await sut.createListFolder(name: "Child", parentId: "parent-123") - let body = try XCTUnwrap(session.lastRequest?.httpBody) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertEqual(json["parent_id"] as? String, "parent-123") - } - - func test_createListFolder_decodesReturnedFolder() async throws { - session.stub(json: #"{"folder":\#(folderJSON)}"#) - let folder = try await sut.createListFolder(name: "My Folder", parentId: nil) - XCTAssertEqual(folder.id, "f1") - XCTAssertEqual(folder.name, "My Folder") - } - - func test_createListFolder_401_throwsStatusError() async throws { - session.stub(data: Data(), statusCode: 401) - do { - _ = try await sut.createListFolder(name: "X", parentId: nil) - XCTFail("Expected throw") - } catch APIError.status(let code) { - XCTAssertEqual(code, 401) - } - } - - func test_createListFolder_serverError_throwsServerError() async throws { - session.stub(json: #"{"error":"name required"}"#, statusCode: 422) - do { - _ = try await sut.createListFolder(name: "", parentId: nil) - XCTFail("Expected throw") - } catch APIError.server(let msg) { - XCTAssertEqual(msg, "name required") - } - } - - func test_createListFolder_subscriberOnly403_surfacesServerMessageVerbatim() async throws { - let message = "Subscriber only feature" - session.stub(json: #"{"error":"\#(message)"}"#, statusCode: 403) - do { - _ = try await sut.createListFolder(name: "X", parentId: nil) - XCTFail("Expected throw") - } catch APIError.server(let msg) { - XCTAssertEqual(msg, message) - } catch { - XCTFail("Expected APIError.server, got \(error)") - } - } - - // MARK: updateListFolder() - - func test_updateListFolder_sendsPutToCorrectPath() async throws { - session.stub(json: #"{"folder":\#(folderJSON)}"#) - _ = try await sut.updateListFolder(id: "f1", name: "Renamed", parentId: nil) - XCTAssertEqual(session.lastRequest?.httpMethod, "PUT") - XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/folders/f1") == true) - } - - func test_updateListFolder_bodyContainsName() async throws { - session.stub(json: #"{"folder":\#(folderJSON)}"#) - _ = try await sut.updateListFolder(id: "f1", name: "Renamed", parentId: nil) - let body = try XCTUnwrap(session.lastRequest?.httpBody) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertEqual(json["name"] as? String, "Renamed") - } - - func test_updateListFolder_withParentId_sendsParentId() async throws { - session.stub(json: #"{"folder":\#(folderJSON)}"#) - _ = try await sut.updateListFolder(id: "f1", name: nil, parentId: "p1") - let body = try XCTUnwrap(session.lastRequest?.httpBody) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertEqual(json["parent_id"] as? String, "p1") - } - - func test_updateListFolder_409_throwsStatusError() async throws { - session.stub(data: Data(), statusCode: 409) - do { - _ = try await sut.updateListFolder(id: "f1", name: "Clash", parentId: nil) - XCTFail("Expected throw") - } catch APIError.status(let code) { - XCTAssertEqual(code, 409) - } - } - - func test_updateListFolder_decodesReturnedFolder() async throws { - session.stub(json: #"{"folder":\#(folderJSON)}"#) - let folder = try await sut.updateListFolder(id: "f1", name: "My Folder", parentId: nil) - XCTAssertEqual(folder.id, "f1") - } - - // MARK: deleteListFolder() - - func test_deleteListFolder_sendsDeleteToCorrectPath() async throws { - session.stub(data: Data(), statusCode: 200) - try await sut.deleteListFolder(id: "f1") - XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") - XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/folders/f1") == true) - } - - func test_deleteListFolder_sendsBearerToken() async throws { - session.stub(data: Data(), statusCode: 200) - try await sut.deleteListFolder(id: "f1") - XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") - } - - func test_deleteListFolder_percentEncodesId() async throws { - session.stub(data: Data(), statusCode: 200) - try await sut.deleteListFolder(id: "f 1") - let path = session.lastRequest?.url?.path ?? "" - XCTAssertTrue(path.hasSuffix("/api/folders/f%201") || path.hasSuffix("/api/folders/f 1"), - "Expected percent-encoded path, got: \(path)") - } - - func test_deleteListFolder_401_throwsStatusError() async throws { - session.stub(data: Data(), statusCode: 401) - do { - try await sut.deleteListFolder(id: "f1") - XCTFail("Expected throw") - } catch APIError.status(let code) { - XCTAssertEqual(code, 401) - } - } - // MARK: updateList() func test_updateList_sendsPutToCorrectPath() async throws { @@ -182,7 +39,8 @@ final class APIClientListFolderTests: XCTestCase { let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) XCTAssertEqual(json["title"] as? String, "Renamed") XCTAssertEqual(json["description"] as? String, "Desc") - XCTAssertEqual(json["is_public"] as? Bool, true) + // camelCase: the backend reads `isPublic` (see updateList encoder choice). + XCTAssertEqual(json["isPublic"] as? Bool, true) } func test_updateList_bodyContainsIsPublic() async throws { @@ -190,7 +48,7 @@ final class APIClientListFolderTests: XCTestCase { _ = try await sut.updateList(id: "l1", title: "T", description: nil, isPublic: true) let body = try XCTUnwrap(session.lastRequest?.httpBody) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertEqual(json["is_public"] as? Bool, true) + XCTAssertEqual(json["isPublic"] as? Bool, true) } func test_updateList_bodyContainsDescription() async throws { diff --git a/InterlinedListTests/APIClientTests/APIClientListsTests.swift b/InterlinedListTests/APIClientTests/APIClientListsTests.swift index 1de588c..5cef20f 100644 --- a/InterlinedListTests/APIClientTests/APIClientListsTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientListsTests.swift @@ -14,49 +14,26 @@ final class APIClientListsTests: XCTestCase { sut.setBearerToken("tok") } - // MARK: listsAndFolders() - - func test_listsAndFolders_returnsBoth() async throws { - session.enqueue(json: #"{"folders":[{"id":"f1","name":"Work","parentId":null}]}"#) - session.enqueue(json: #"{"lists":[\#(listJSON)]}"#) - let (folders, lists) = try await sut.listsAndFolders() - XCTAssertEqual(folders.count, 1) - XCTAssertEqual(folders.first?.id, "f1") - XCTAssertEqual(lists.count, 1) - XCTAssertEqual(lists.first?.name, "My List") - } + // MARK: lists() - func test_listsAndFolders_folderError_propagates() async throws { - // Old behavior swallowed folder errors; new behavior propagates them so the - // UI can surface real failures from /api/folders (now a documented endpoint). - session.enqueue(data: Data(), statusCode: 500) - session.enqueue(json: #"{"lists":[]}"#) - do { - _ = try await sut.listsAndFolders() - XCTFail("Expected folder error to propagate") - } catch APIError.status(let code) { - XCTAssertEqual(code, 500) - } catch APIError.server { - // Acceptable: server returned a parseable error body. - } + func test_lists_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"lists":[\#(listJSON)]}"#) + _ = try await sut.lists() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists") } - func test_listsAndFolders_401OnFolders_throws() async throws { - session.enqueue(data: Data(), statusCode: 401) - session.enqueue(json: #"{"lists":[]}"#) - do { - _ = try await sut.listsAndFolders() - XCTFail("Expected throw from folders call") - } catch APIError.status(let code) { - XCTAssertEqual(code, 401) - } + func test_lists_decodesLists() async throws { + session.stub(json: #"{"lists":[\#(listJSON)]}"#) + let lists = try await sut.lists() + XCTAssertEqual(lists.count, 1) + XCTAssertEqual(lists.first?.name, "My List") } - func test_listsAndFolders_401OnLists_throws() async throws { - session.enqueue(json: #"{"folders":[]}"#) - session.enqueue(data: Data(), statusCode: 401) + func test_lists_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) do { - _ = try await sut.listsAndFolders() + _ = try await sut.lists() XCTFail("Expected throw from lists call") } catch APIError.status(let code) { XCTAssertEqual(code, 401) @@ -141,8 +118,13 @@ final class APIClientListsTests: XCTestCase { } // MARK: updateList() — isPublic round-trip + // + // The backend PUT /api/lists/:id reads camelCase (`isPublic`) from the body, so + // updateList uses the camelCase encoder. Sending snake_case (`is_public`) left + // the field `undefined` server-side and the update was silently dropped — hence + // these assert the camelCase key. - func test_updateList_isPublicTrue_sentAsSnakeCaseBoolAndDecodes() async throws { + func test_updateList_isPublicTrue_sentAsCamelCaseBoolAndDecodes() async throws { let body = #"{"list":{"id":"l1","title":"My List","isPublic":true,"createdAt":"2024-01-01T00:00:00Z"}}"# session.stub(json: body) let updated = try await sut.updateList(id: "l1", title: "My List", description: nil, isPublic: true) @@ -152,13 +134,14 @@ final class APIClientListsTests: XCTestCase { let sentBody = try XCTUnwrap(session.lastRequest?.httpBody) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: sentBody) as? [String: Any]) - XCTAssertEqual(json["is_public"] as? Bool, true) + XCTAssertEqual(json["isPublic"] as? Bool, true) + XCTAssertNil(json["is_public"], "Must send camelCase, not snake_case") XCTAssertEqual(updated.id, "l1") XCTAssertEqual(updated.isPublic, true) } - func test_updateList_isPublicFalse_sentAsSnakeCaseBoolAndDecodes() async throws { + func test_updateList_isPublicFalse_sentAsCamelCaseBoolAndDecodes() async throws { let body = #"{"list":{"id":"l1","title":"My List","isPublic":false,"createdAt":"2024-01-01T00:00:00Z"}}"# session.stub(json: body) let updated = try await sut.updateList(id: "l1", title: "My List", description: nil, isPublic: false) @@ -168,7 +151,7 @@ final class APIClientListsTests: XCTestCase { let sentBody = try XCTUnwrap(session.lastRequest?.httpBody) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: sentBody) as? [String: Any]) - XCTAssertEqual(json["is_public"] as? Bool, false) + XCTAssertEqual(json["isPublic"] as? Bool, false) XCTAssertEqual(updated.id, "l1") XCTAssertEqual(updated.isPublic, false) diff --git a/InterlinedListTests/APIClientTests/APIClientMessageByIdTests.swift b/InterlinedListTests/APIClientTests/APIClientMessageByIdTests.swift new file mode 100644 index 0000000..00b0caa --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientMessageByIdTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import InterlinedList + +final class APIClientMessageByIdTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + private let bareMessageJSON = #"{"id":"m1","content":"Hello","user_id":"u1","created_at":"2024-01-01T00:00:00Z"}"# + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + func test_message_sendsGetToCorrectPath() async throws { + session.stub(json: bareMessageJSON) + _ = try await sut.message(id: "m1") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/messages/m1") + } + + func test_message_sendsBearerToken() async throws { + session.stub(json: bareMessageJSON) + _ = try await sut.message(id: "m1") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_message_percentEncodesId() async throws { + session.stub(json: bareMessageJSON) + _ = try await sut.message(id: "a b") + let urlString = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertFalse(urlString.contains("a b")) + XCTAssertTrue(urlString.contains("a%20b")) + } + + func test_message_decodesBareObject() async throws { + session.stub(json: bareMessageJSON) + let message = try await sut.message(id: "m1") + XCTAssertEqual(message.id, "m1") + XCTAssertEqual(message.content, "Hello") + XCTAssertEqual(message.userId, "u1") + } + + func test_message_decodesMessageWrapper() async throws { + session.stub(json: #"{"message":\#(bareMessageJSON)}"#) + let message = try await sut.message(id: "m1") + XCTAssertEqual(message.id, "m1") + } + + func test_message_decodesDataWrapper() async throws { + session.stub(json: #"{"data":\#(bareMessageJSON)}"#) + let message = try await sut.message(id: "m1") + XCTAssertEqual(message.id, "m1") + } + + func test_message_404_throwsAPIError() async throws { + session.stub(json: #"{"error":"Message not found"}"#, statusCode: 404) + do { + _ = try await sut.message(id: "missing") + XCTFail("Expected throw") + } catch APIError.server(let msg) { + XCTAssertEqual(msg, "Message not found") + } + } + + func test_message_404_withoutErrorBody_throwsStatus() async throws { + session.stub(data: Data(), statusCode: 404) + do { + _ = try await sut.message(id: "missing") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 404) + } + } + + func test_message_401_throwsStatus401() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.message(id: "m1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift b/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift index fb8a5d9..7aa59ef 100644 --- a/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift @@ -175,14 +175,25 @@ final class APIClientMessagesTests: XCTestCase { // MARK: editMessage() - func test_editMessage_sendsPutToCorrectPath() async throws { + func test_editMessage_sendsPatchToCorrectPath() async throws { let wrapped = #"{"data":\#(messageJSON)}"# session.stub(json: wrapped) _ = try await sut.editMessage(id: "m1", content: "Updated", publiclyVisible: nil) - XCTAssertEqual(session.lastRequest?.httpMethod, "PUT") + XCTAssertEqual(session.lastRequest?.httpMethod, "PATCH") XCTAssertEqual(session.lastRequest?.url?.path, "/api/messages/m1") } + func test_editMessage_bodyUsesCamelCaseKeys() async throws { + let wrapped = #"{"data":\#(messageJSON)}"# + session.stub(json: wrapped) + _ = try await sut.editMessage(id: "m1", content: "Updated", publiclyVisible: true) + let body = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["content"] as? String, "Updated") + XCTAssertNotNil(json["publiclyVisible"], "Body must use camelCase key 'publiclyVisible'") + XCTAssertNil(json["publicly_visible"], "Body must NOT use snake_case key") + } + // MARK: dig() / undig() func test_dig_sendsPostToDigPath() async throws { diff --git a/InterlinedListTests/APIClientTests/APIClientModerationTests.swift b/InterlinedListTests/APIClientTests/APIClientModerationTests.swift index 0ebf908..93c6108 100644 --- a/InterlinedListTests/APIClientTests/APIClientModerationTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientModerationTests.swift @@ -202,6 +202,13 @@ final class APIClientModerationTests: XCTestCase { // MARK: mutedUsers() + func test_mutedUsers_sendsGetRequest() async throws { + session.stub(json: #"{"mutedUsers":[],"pagination":null}"#) + _ = try await sut.mutedUsers() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertTrue(session.lastRequest?.url?.path.hasPrefix("/api/user/mutes") == true) + } + func test_mutedUsers_decodesResponse() async throws { session.stub(json: #"{"mutedUsers":[{"id":"u2","username":"bob","displayName":null,"avatar":null}],"pagination":null}"#) let response = try await sut.mutedUsers() diff --git a/InterlinedListTests/APIClientTests/APIClientNotificationsTests.swift b/InterlinedListTests/APIClientTests/APIClientNotificationsTests.swift index 27672d0..d545039 100644 --- a/InterlinedListTests/APIClientTests/APIClientNotificationsTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientNotificationsTests.swift @@ -42,10 +42,10 @@ final class APIClientNotificationsTests: XCTestCase { // MARK: markNotificationRead() - func test_markNotificationRead_sendsPutToCorrectPath() async throws { + func test_markNotificationRead_sendsPatchToCorrectPath() async throws { session.stub(json: #"{"ok":true}"#) try await sut.markNotificationRead(id: "n1") - XCTAssertEqual(session.lastRequest?.httpMethod, "PUT") + XCTAssertEqual(session.lastRequest?.httpMethod, "PATCH") XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/notifications/n1/read") == true) } diff --git a/InterlinedListTests/APIClientTests/APIClientProfileTests.swift b/InterlinedListTests/APIClientTests/APIClientProfileTests.swift index 15f8cd5..75b3c27 100644 --- a/InterlinedListTests/APIClientTests/APIClientProfileTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientProfileTests.swift @@ -16,21 +16,21 @@ final class APIClientProfileTests: XCTestCase { // MARK: updateProfile() - func test_updateProfile_sendsPostToCorrectPath() async throws { + func test_updateProfile_sendsPatchToCorrectPath() async throws { session.stub(json: #"{"user":\#(userJSON)}"#) _ = try await sut.updateProfile(displayName: "Alice", bio: "Bio", defaultVisibility: true) - XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.httpMethod, "PATCH") XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/user/update") == true) } - func test_updateProfile_bodyContainsDisplayName() async throws { + func test_updateProfile_bodyContainsCamelCaseDisplayName() async throws { session.stub(json: #"{"user":\#(userJSON)}"#) _ = try await sut.updateProfile(displayName: "Alice", bio: nil, defaultVisibility: nil) let body = try XCTUnwrap(session.lastRequest?.httpBody) let json = try XCTUnwrap(try? JSONSerialization.jsonObject(with: body) as? [String: Any]) - // /api/user/update uses the default snake_case encoder (same as register), so the - // wire key is display_name, not displayName. - XCTAssertEqual(json["display_name"] as? String, "Alice") + // /api/user/update only reads camelCase keys — snake_case display_name is dropped. + XCTAssertEqual(json["displayName"] as? String, "Alice") + XCTAssertNil(json["display_name"], "Body must NOT use snake_case key") } func test_updateProfile_returnsUser() async throws { @@ -59,4 +59,30 @@ final class APIClientProfileTests: XCTestCase { XCTAssertEqual(code, 401) } } + + // MARK: updateUserSettings() + + func test_updateUserSettings_sendsPatchToCorrectPath() async throws { + session.stub(json: #"{"user":\#(userJSON)}"#) + _ = try await sut.updateUserSettings(theme: "dark") + XCTAssertEqual(session.lastRequest?.httpMethod, "PATCH") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/user/update") == true) + } + + func test_updateUserSettings_bodyUsesCamelCaseKeys() async throws { + session.stub(json: #"{"user":\#(userJSON)}"#) + _ = try await sut.updateUserSettings(defaultVisibility: false, showAdvancedPostSettings: true) + let body = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try XCTUnwrap(try? JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertNotNil(json["defaultVisibility"], "Body must use camelCase key 'defaultVisibility'") + XCTAssertNotNil(json["showAdvancedPostSettings"], "Body must use camelCase key 'showAdvancedPostSettings'") + XCTAssertNil(json["default_visibility"], "Body must NOT use snake_case key") + XCTAssertNil(json["show_advanced_post_settings"], "Body must NOT use snake_case key") + } + + func test_updateUserSettings_returnsUser() async throws { + session.stub(json: #"{"user":\#(userJSON)}"#) + let user = try await sut.updateUserSettings(theme: "light") + XCTAssertEqual(user.username, "alice") + } } diff --git a/InterlinedListTests/APIClientTests/APIClientSearchDocumentsTests.swift b/InterlinedListTests/APIClientTests/APIClientSearchDocumentsTests.swift index 3980dd7..1c05f4c 100644 --- a/InterlinedListTests/APIClientTests/APIClientSearchDocumentsTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientSearchDocumentsTests.swift @@ -101,7 +101,7 @@ final class APIClientSearchDocumentsTests: XCTestCase { _ = try await sut.updateDocument(id: "d1", title: "Doc", content: nil, isPublic: false, folderId: "f1") let body = try XCTUnwrap(session.lastRequest?.httpBody) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertEqual(json["folder_id"] as? String, "f1") + XCTAssertEqual(json["folderId"] as? String, "f1") } func test_updateDocument_nilFolderId_omitsFolderIdFromBody() async throws { @@ -110,7 +110,7 @@ final class APIClientSearchDocumentsTests: XCTestCase { _ = try await sut.updateDocument(id: "d1", title: "Doc", content: nil, isPublic: false, folderId: nil) let body = try XCTUnwrap(session.lastRequest?.httpBody) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertNil(json["folder_id"], "folderId should be absent when nil is passed") + XCTAssertNil(json["folderId"], "folderId should be absent when nil is passed") } func test_updateDocument_emptyFolderId_sendsEmptyString() async throws { @@ -119,7 +119,7 @@ final class APIClientSearchDocumentsTests: XCTestCase { _ = try await sut.updateDocument(id: "d1", title: "Doc", content: nil, isPublic: false, folderId: "") let body = try XCTUnwrap(session.lastRequest?.httpBody) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertEqual(json["folder_id"] as? String, "") + XCTAssertEqual(json["folderId"] as? String, "") } func test_updateDocument_withFolderId_usesPatchMethod() async throws { @@ -135,6 +135,6 @@ final class APIClientSearchDocumentsTests: XCTestCase { _ = try await sut.updateDocument(id: "d1", title: "Doc", content: nil, isPublic: false) let body = try XCTUnwrap(session.lastRequest?.httpBody) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertNil(json["folder_id"]) + XCTAssertNil(json["folderId"]) } } diff --git a/InterlinedListTests/APIClientTests/APIClientSearchUsersTests.swift b/InterlinedListTests/APIClientTests/APIClientSearchUsersTests.swift new file mode 100644 index 0000000..a39511e --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientSearchUsersTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import InterlinedList + +final class APIClientSearchUsersTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + func test_searchUsers_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"users":[]}"#) + _ = try await sut.searchUsers(query: "ada") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/search") + } + + func test_searchUsers_sendsQueryAndDefaultLimit() async throws { + session.stub(json: #"{"users":[]}"#) + _ = try await sut.searchUsers(query: "ada") + let items = URLComponents(url: session.lastRequest!.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "q" }?.value, "ada") + XCTAssertEqual(items.first { $0.name == "limit" }?.value, "20") + } + + func test_searchUsers_customLimit() async throws { + session.stub(json: #"{"users":[]}"#) + _ = try await sut.searchUsers(query: "bob", limit: 5) + let items = URLComponents(url: session.lastRequest!.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "limit" }?.value, "5") + } + + func test_searchUsers_percentEncodesQuery() async throws { + session.stub(json: #"{"users":[]}"#) + _ = try await sut.searchUsers(query: "a b&c") + let raw = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertTrue(raw.contains("q=a%20b%26c"), "query not percent-encoded: \(raw)") + // The encoded ampersand must not introduce a spurious query parameter. + let items = URLComponents(url: session.lastRequest!.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "q" }?.value, "a b&c") + } + + func test_searchUsers_decodesUsers() async throws { + session.stub(json: #""" + {"users":[ + {"id":"u1","username":"ada","displayName":"Ada L.","avatar":"https://x/a.png"}, + {"id":"u2","username":"bob","displayName":null,"avatar":null} + ],"total":2} + """#) + let users = try await sut.searchUsers(query: "a") + XCTAssertEqual(users.count, 2) + XCTAssertEqual(users.first?.id, "u1") + XCTAssertEqual(users.first?.username, "ada") + XCTAssertEqual(users.first?.displayName, "Ada L.") + XCTAssertEqual(users.first?.avatar, "https://x/a.png") + XCTAssertEqual(users.last?.displayNameOrUsername, "bob") + } + + func test_searchUsers_sendsBearerToken() async throws { + session.stub(json: #"{"users":[]}"#) + _ = try await sut.searchUsers(query: "ada") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_searchUsers_401_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.searchUsers(query: "ada") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_searchUsers_400_missingQuery_throwsServerError() async throws { + session.stub(json: #"{"error":"missing_query"}"#, statusCode: 400) + do { + _ = try await sut.searchUsers(query: "x") + XCTFail("Expected throw") + } catch APIError.server(let msg) { + XCTAssertEqual(msg, "missing_query") + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientSessionsTests.swift b/InterlinedListTests/APIClientTests/APIClientSessionsTests.swift new file mode 100644 index 0000000..3a9b482 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientSessionsTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import InterlinedList + +final class APIClientSessionsTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + // MARK: userSessions() + + func test_userSessions_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"sessions":[]}"#) + _ = try await sut.userSessions() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/sessions") + } + + func test_userSessions_sendsBearerToken() async throws { + session.stub(json: #"{"sessions":[]}"#) + _ = try await sut.userSessions() + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_userSessions_decodesArray() async throws { + session.stub(json: #""" + {"sessions":[ + {"id":"s1","deviceLabel":"iPhone 16","createdAt":"2026-07-01T10:00:00.000Z","lastUsedAt":"2026-07-31T09:00:00.000Z","isCurrent":true}, + {"id":"s2","deviceLabel":"MacBook Pro","createdAt":"2026-06-01T10:00:00Z","lastUsedAt":"2026-07-20T09:00:00Z","isCurrent":false} + ]} + """#) + let sessions = try await sut.userSessions() + XCTAssertEqual(sessions.count, 2) + XCTAssertEqual(sessions.first?.id, "s1") + XCTAssertEqual(sessions.first?.deviceLabel, "iPhone 16") + XCTAssertEqual(sessions.first?.isCurrent, true) + XCTAssertEqual(sessions.last?.id, "s2") + XCTAssertEqual(sessions.last?.isCurrent, false) + } + + func test_userSessions_missingDeviceLabelAndIsCurrent_defaultsDefensively() async throws { + session.stub(json: #"{"sessions":[{"id":"s3"}]}"#) + let sessions = try await sut.userSessions() + XCTAssertEqual(sessions.count, 1) + XCTAssertNil(sessions.first?.deviceLabel) + XCTAssertNil(sessions.first?.createdAt) + XCTAssertNil(sessions.first?.lastUsedAt) + XCTAssertEqual(sessions.first?.isCurrent, false) + } + + func test_userSessions_401_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.userSessions() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: revokeSession() + + func test_revokeSession_sendsDeleteToCorrectPath() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.revokeSession(id: "s2") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/sessions/s2") + } + + func test_revokeSession_sendsBearerToken() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.revokeSession(id: "s2") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_revokeSession_toleratesEmptyBody() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.revokeSession(id: "s2") + } + + func test_revokeSession_401_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.revokeSession(id: "s2") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientShareInvitesTests.swift b/InterlinedListTests/APIClientTests/APIClientShareInvitesTests.swift new file mode 100644 index 0000000..634aaf3 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientShareInvitesTests.swift @@ -0,0 +1,178 @@ +import XCTest +@testable import InterlinedList + +final class APIClientShareInvitesTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + private let inviteJSON = #"{"token":"inv123","email":"alice@example.com","role":"collaborator","expiresAt":"2026-09-01T00:00:00Z","accepted":false,"createdAt":"2026-08-01T00:00:00Z"}"# + private let createJSON = #"{"email":"alice@example.com","role":"manager","expiresAt":"2026-09-01T00:00:00Z","url":"https://interlinedlist.com/invite/inv123"}"# + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + private func bodyString() -> String { + guard let data = session.lastRequest?.httpBody else { return "" } + return String(data: data, encoding: .utf8) ?? "" + } + + // MARK: - shareInvites() + + func test_shareInvites_lists_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"invites":[]}"#) + _ = try await sut.shareInvites(kind: .lists, id: "l1") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/l1/invites") + } + + func test_shareInvites_documents_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"invites":[]}"#) + _ = try await sut.shareInvites(kind: .documents, id: "d1") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/invites") + } + + func test_shareInvites_decodesArray() async throws { + session.stub(json: #"{"invites":[\#(inviteJSON)]}"#) + let invites = try await sut.shareInvites(kind: .lists, id: "l1") + XCTAssertEqual(invites.count, 1) + XCTAssertEqual(invites.first?.token, "inv123") + XCTAssertEqual(invites.first?.email, "alice@example.com") + XCTAssertEqual(invites.first?.shareRole, .collaborator) + XCTAssertEqual(invites.first?.accepted, false) + } + + func test_shareInvites_sendsAuthorizationHeader() async throws { + session.stub(json: #"{"invites":[]}"#) + _ = try await sut.shareInvites(kind: .lists, id: "l1") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_shareInvites_404_throwsStatus() async throws { + session.stub(data: Data(), statusCode: 404) + do { + _ = try await sut.shareInvites(kind: .lists, id: "l1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 404) + } + } + + // MARK: - createShareInvite() + + func test_createShareInvite_lists_postsToCorrectPath() async throws { + session.stub(json: createJSON, statusCode: 201) + _ = try await sut.createShareInvite(kind: .lists, id: "l1", email: "alice@example.com", role: .manager, expiresAt: "2026-09-01T00:00:00Z") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/l1/invites") + } + + func test_createShareInvite_documents_postsToCorrectPath() async throws { + session.stub(json: createJSON, statusCode: 201) + _ = try await sut.createShareInvite(kind: .documents, id: "d1", email: "alice@example.com", role: .watcher) + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/invites") + } + + func test_createShareInvite_bodyUsesCamelCase() async throws { + session.stub(json: createJSON, statusCode: 201) + _ = try await sut.createShareInvite(kind: .lists, id: "l1", email: "alice@example.com", role: .manager, expiresAt: "2026-09-01T00:00:00Z") + let body = bodyString() + XCTAssertTrue(body.contains("\"email\""), "expected email key, got: \(body)") + XCTAssertTrue(body.contains("alice@example.com")) + XCTAssertTrue(body.contains("\"role\""), "expected role key, got: \(body)") + XCTAssertTrue(body.contains("\"manager\"")) + XCTAssertTrue(body.contains("\"expiresAt\""), "expected camelCase expiresAt, got: \(body)") + XCTAssertFalse(body.contains("expires_at")) + } + + func test_createShareInvite_nilExpiry_omitsField() async throws { + session.stub(json: createJSON, statusCode: 201) + _ = try await sut.createShareInvite(kind: .lists, id: "l1", email: "alice@example.com", role: .watcher, expiresAt: nil) + let body = bodyString() + XCTAssertFalse(body.contains("expiresAt"), "nil expiry should be omitted, got: \(body)") + XCTAssertTrue(body.contains("\"email\"")) + } + + func test_createShareInvite_decodesCreateResponse() async throws { + session.stub(json: createJSON, statusCode: 201) + let result = try await sut.createShareInvite(kind: .lists, id: "l1", email: "alice@example.com", role: .manager) + XCTAssertEqual(result.email, "alice@example.com") + XCTAssertEqual(result.shareRole, .manager) + XCTAssertEqual(result.url, "https://interlinedlist.com/invite/inv123") + XCTAssertEqual(result.expiresAt, "2026-09-01T00:00:00Z") + } + + func test_createShareInvite_403_throwsStatus() async throws { + session.stub(json: #"{"error":"Subscribe to send invites"}"#, statusCode: 403) + do { + _ = try await sut.createShareInvite(kind: .lists, id: "l1", email: "alice@example.com", role: .watcher) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } catch APIError.server(let msg) { + XCTAssertTrue(msg.contains("Subscribe")) + } + } + + // MARK: - revokeShareInvite() + + func test_revokeShareInvite_lists_sendsDeleteWithToken() async throws { + session.stub(json: #"{"revoked":true}"#) + try await sut.revokeShareInvite(kind: .lists, id: "l1", token: "inv123") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/l1/invites/inv123") + } + + func test_revokeShareInvite_documents_sendsDeleteWithToken() async throws { + session.stub(json: #"{"revoked":true}"#) + try await sut.revokeShareInvite(kind: .documents, id: "d1", token: "abc") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/invites/abc") + } + + func test_revokeShareInvite_encodesTokenSpaces() async throws { + // Matches revokeShareLink: tokens are escaped with .urlPathAllowed, which + // percent-encodes spaces (but leaves "/" — a legal path char — intact). + session.stub(json: #"{"revoked":true}"#) + try await sut.revokeShareInvite(kind: .lists, id: "l1", token: "ab cd") + let url = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertTrue(url.contains("/api/lists/l1/invites/ab%20cd"), "expected space percent-encoded, got: \(url)") + } + + func test_revokeShareInvite_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.revokeShareInvite(kind: .lists, id: "l1", token: "inv123") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: - searchWatcherCandidates(search:) + + func test_searchWatcherCandidates_withSearch_appendsSearchParam() async throws { + session.stub(json: #"{"users":[]}"#) + _ = try await sut.searchWatcherCandidates(listId: "l1", search: "alice smith") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/l1/watchers/users") + let url = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertTrue(url.contains("search=alice%20smith") || url.contains("search=alice+smith"), "expected percent-encoded search, got: \(url)") + } + + func test_searchWatcherCandidates_withoutSearch_omitsSearchParam() async throws { + session.stub(json: #"{"users":[]}"#) + _ = try await sut.searchWatcherCandidates(listId: "l1") + let url = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertFalse(url.contains("search="), "expected no search param, got: \(url)") + } + + func test_searchWatcherCandidates_emptySearch_omitsSearchParam() async throws { + session.stub(json: #"{"users":[]}"#) + _ = try await sut.searchWatcherCandidates(listId: "l1", search: "") + let url = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertFalse(url.contains("search="), "empty search should be omitted, got: \(url)") + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientSharedResolverTests.swift b/InterlinedListTests/APIClientTests/APIClientSharedResolverTests.swift new file mode 100644 index 0000000..99f272d --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientSharedResolverTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import InterlinedList + +final class APIClientSharedResolverTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + func test_resolveSharedDocument_sendsGetToTokenPath() async throws { + session.stub(json: #"{"role":"watcher","document":{"id":"d1","title":"Doc"}}"#) + _ = try await sut.resolveSharedDocument(token: "abc123") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/shared/abc123") + } + + func test_resolveSharedDocument_percentEncodesToken() async throws { + session.stub(json: #"{"role":"watcher","document":{"id":"d1","title":"Doc"}}"#) + // Real tokens are hex, but a stray space must still be percent-encoded + // (matching the project-wide `.urlPathAllowed` path-segment convention) so + // the request URL stays valid rather than producing a malformed path. + _ = try await sut.resolveSharedDocument(token: "a b") + let raw = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertTrue(raw.contains("/api/documents/shared/a%20b"), "token not encoded: \(raw)") + } + + func test_resolveSharedDocument_decodesViewerLink() async throws { + session.stub(json: #""" + {"role":"watcher","canClaim":false,"needsAuth":false, + "document":{"id":"d1","title":"Shared Doc","content":"# Hi","isPublic":false,"updatedAt":"2026-08-15T00:00:00Z"}} + """#) + let res = try await sut.resolveSharedDocument(token: "t") + XCTAssertEqual(res.role, "watcher") + XCTAssertEqual(res.canClaim, false) + XCTAssertEqual(res.document.id, "d1") + XCTAssertEqual(res.document.title, "Shared Doc") + XCTAssertEqual(res.document.content, "# Hi") + XCTAssertFalse(res.isEditLink) + } + + func test_resolveSharedDocument_editLinkIsEditLinkTrue() async throws { + session.stub(json: #""" + {"role":"collaborator","canClaim":false,"needsAuth":true, + "document":{"id":"d2","title":"Editable"}} + """#) + let res = try await sut.resolveSharedDocument(token: "t") + XCTAssertTrue(res.isEditLink) + XCTAssertEqual(res.needsAuth, true) + } + + func test_resolveSharedDocument_404_throwsServerError() async throws { + session.stub(json: #"{"error":"Share link not found, expired, or revoked"}"#, statusCode: 404) + do { + _ = try await sut.resolveSharedDocument(token: "gone") + XCTFail("Expected throw") + } catch APIError.server(let msg) { + XCTAssertEqual(msg, "Share link not found, expired, or revoked") + } + } + + func test_resolveSharedDocument_429_withoutBody_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 429) + do { + _ = try await sut.resolveSharedDocument(token: "t") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 429) + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientSharingTests.swift b/InterlinedListTests/APIClientTests/APIClientSharingTests.swift new file mode 100644 index 0000000..d49205f --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientSharingTests.swift @@ -0,0 +1,249 @@ +import XCTest +@testable import InterlinedList + +final class APIClientSharingTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + private let linkJSON = #"{"token":"tok123","url":"https://interlinedlist.com/s/tok123","role":"collaborator","expiresAt":"2026-08-10T00:00:00Z"}"# + private let collaboratorJSON = #"{"userId":"u1","role":"watcher","username":"alice","displayName":"Alice","avatar":null}"# + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + private func bodyString() -> String { + guard let data = session.lastRequest?.httpBody else { return "" } + return String(data: data, encoding: .utf8) ?? "" + } + + // MARK: - shareLinks() + + func test_shareLinks_lists_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"shareLinks":[]}"#) + _ = try await sut.shareLinks(kind: .lists, id: "l1") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/l1/share-links") + } + + func test_shareLinks_documents_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"shareLinks":[]}"#) + _ = try await sut.shareLinks(kind: .documents, id: "d1") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/share-links") + } + + func test_shareLinks_decodesArray() async throws { + session.stub(json: #"{"shareLinks":[\#(linkJSON)]}"#) + let links = try await sut.shareLinks(kind: .lists, id: "l1") + XCTAssertEqual(links.count, 1) + XCTAssertEqual(links.first?.token, "tok123") + XCTAssertEqual(links.first?.shareRole, .collaborator) + XCTAssertEqual(links.first?.expiresAt, "2026-08-10T00:00:00Z") + } + + func test_shareLinks_sendsAuthorizationHeader() async throws { + session.stub(json: #"{"shareLinks":[]}"#) + _ = try await sut.shareLinks(kind: .lists, id: "l1") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_shareLinks_404_throwsStatus() async throws { + session.stub(data: Data(), statusCode: 404) + do { + _ = try await sut.shareLinks(kind: .lists, id: "l1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 404) + } + } + + // MARK: - createShareLink() + + func test_createShareLink_lists_postsToCorrectPath() async throws { + session.stub(json: linkJSON) + _ = try await sut.createShareLink(kind: .lists, id: "l1", role: .collaborator, expiresAt: "2026-08-10T00:00:00Z") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/l1/share-links") + } + + func test_createShareLink_documents_postsToCorrectPath() async throws { + session.stub(json: linkJSON) + _ = try await sut.createShareLink(kind: .documents, id: "d1", role: .watcher) + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/share-links") + } + + func test_createShareLink_bodyUsesCamelCase() async throws { + session.stub(json: linkJSON) + _ = try await sut.createShareLink(kind: .lists, id: "l1", role: .manager, expiresAt: "2026-08-10T00:00:00Z") + let body = bodyString() + XCTAssertTrue(body.contains("\"role\""), "expected role, got: \(body)") + XCTAssertTrue(body.contains("\"manager\"")) + XCTAssertTrue(body.contains("\"expiresAt\""), "expected camelCase expiresAt, got: \(body)") + XCTAssertFalse(body.contains("expires_at")) + } + + func test_createShareLink_nilExpiry_omitsField() async throws { + // The camelCase encoder drops nil optionals; the backend treats an absent + // expiresAt the same as null (no expiry), so omission is correct. + session.stub(json: linkJSON) + _ = try await sut.createShareLink(kind: .lists, id: "l1", role: .watcher, expiresAt: nil) + let body = bodyString() + XCTAssertFalse(body.contains("expiresAt"), "nil expiry should be omitted, got: \(body)") + XCTAssertTrue(body.contains("\"role\"")) + } + + func test_createShareLink_decodesResult() async throws { + session.stub(json: linkJSON) + let link = try await sut.createShareLink(kind: .lists, id: "l1", role: .collaborator) + XCTAssertEqual(link.token, "tok123") + XCTAssertEqual(link.url, "https://interlinedlist.com/s/tok123") + } + + func test_createShareLink_403_throwsStatus() async throws { + session.stub(json: #"{"error":"Subscribe to share this list"}"#, statusCode: 403) + do { + _ = try await sut.createShareLink(kind: .lists, id: "l1", role: .watcher) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } catch APIError.server(let msg) { + XCTAssertTrue(msg.contains("Subscribe")) + } + } + + // MARK: - revokeShareLink() + + func test_revokeShareLink_lists_sendsDeleteWithToken() async throws { + session.stub(json: #"{"revoked":true}"#) + try await sut.revokeShareLink(kind: .lists, id: "l1", token: "tok123") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/lists/l1/share-links/tok123") + } + + func test_revokeShareLink_documents_sendsDeleteWithToken() async throws { + session.stub(json: #"{"revoked":true}"#) + try await sut.revokeShareLink(kind: .documents, id: "d1", token: "abc") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/share-links/abc") + } + + func test_revokeShareLink_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.revokeShareLink(kind: .lists, id: "l1", token: "tok123") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: - documentCollaborators() + + func test_documentCollaborators_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"collaborators":[],"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"#) + _ = try await sut.documentCollaborators(id: "d1") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/collaborators") + } + + func test_documentCollaborators_decodesArray() async throws { + session.stub(json: #"{"collaborators":[\#(collaboratorJSON)],"pagination":{"total":1,"limit":20,"offset":0,"hasMore":false}}"#) + let collaborators = try await sut.documentCollaborators(id: "d1") + XCTAssertEqual(collaborators.count, 1) + XCTAssertEqual(collaborators.first?.userId, "u1") + XCTAssertEqual(collaborators.first?.collaboratorRole, .watcher) + XCTAssertEqual(collaborators.first?.displayNameOrUsername, "Alice") + } + + func test_documentCollaborators_missingOptionalFields_decodesNil() async throws { + session.stub(json: #"{"collaborators":[{"userId":"u2","role":"manager"}]}"#) + let collaborators = try await sut.documentCollaborators(id: "d1") + XCTAssertEqual(collaborators.first?.username, nil) + XCTAssertEqual(collaborators.first?.displayNameOrUsername, "User") + } + + // MARK: - addDocumentCollaborator() + + func test_addDocumentCollaborator_postsToCorrectPath() async throws { + session.stub(json: #"{"collaborator":\#(collaboratorJSON)}"#) + _ = try await sut.addDocumentCollaborator(id: "d1", userId: "u1", role: .collaborator, notify: true) + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/collaborators") + } + + func test_addDocumentCollaborator_bodyUsesCamelCase() async throws { + session.stub(json: #"{"collaborator":\#(collaboratorJSON)}"#) + _ = try await sut.addDocumentCollaborator(id: "d1", userId: "u1", role: .collaborator, notify: true) + let body = bodyString() + XCTAssertTrue(body.contains("\"userId\""), "expected camelCase userId, got: \(body)") + XCTAssertFalse(body.contains("user_id")) + XCTAssertTrue(body.contains("\"role\"")) + XCTAssertTrue(body.contains("\"collaborator\"")) + XCTAssertTrue(body.contains("\"notify\"")) + } + + func test_addDocumentCollaborator_403_throwsStatus() async throws { + session.stub(json: #"{"error":"Subscribe to add collaborators"}"#, statusCode: 403) + do { + _ = try await sut.addDocumentCollaborator(id: "d1", userId: "u1", role: .watcher) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } catch APIError.server(let msg) { + XCTAssertTrue(msg.contains("Subscribe")) + } + } + + // MARK: - setDocumentCollaboratorRole() + + func test_setDocumentCollaboratorRole_sendsPutToCorrectPath() async throws { + session.stub(json: #"{"role":"manager"}"#) + _ = try await sut.setDocumentCollaboratorRole(id: "d1", userId: "u1", role: .manager) + XCTAssertEqual(session.lastRequest?.httpMethod, "PUT") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/collaborators/u1") + } + + func test_setDocumentCollaboratorRole_bodyUsesCamelCase() async throws { + session.stub(json: #"{"role":"manager"}"#) + _ = try await sut.setDocumentCollaboratorRole(id: "d1", userId: "u1", role: .manager, notify: true) + let body = bodyString() + XCTAssertTrue(body.contains("\"role\"")) + XCTAssertTrue(body.contains("\"manager\"")) + XCTAssertTrue(body.contains("\"notify\"")) + } + + func test_setDocumentCollaboratorRole_returnsRole() async throws { + session.stub(json: #"{"role":"manager"}"#) + let role = try await sut.setDocumentCollaboratorRole(id: "d1", userId: "u1", role: .manager) + XCTAssertEqual(role, "manager") + } + + // MARK: - removeDocumentCollaborator() + + func test_removeDocumentCollaborator_sendsDeleteToCorrectPath() async throws { + session.stub(json: #"{"removed":true}"#) + try await sut.removeDocumentCollaborator(id: "d1", userId: "u1") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/collaborators/u1") + } + + // MARK: - searchDocumentCollaboratorCandidates() + + func test_searchDocumentCollaboratorCandidates_sendsGetWithQuery() async throws { + session.stub(json: #"{"users":[]}"#) + _ = try await sut.searchDocumentCollaboratorCandidates(id: "d1", query: "alice smith") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/d1/collaborators/users") + let url = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertTrue(url.contains("q=alice%20smith") || url.contains("q=alice+smith"), "expected percent-encoded query, got: \(url)") + } + + func test_searchDocumentCollaboratorCandidates_decodesUsers() async throws { + session.stub(json: #"{"users":[{"id":"u1","username":"alice","displayName":"Alice","email":"a@b.co","avatar":null}]}"#) + let users = try await sut.searchDocumentCollaboratorCandidates(id: "d1", query: "al") + XCTAssertEqual(users.count, 1) + XCTAssertEqual(users.first?.id, "u1") + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientTagsTests.swift b/InterlinedListTests/APIClientTests/APIClientTagsTests.swift new file mode 100644 index 0000000..a16a637 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientTagsTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import InterlinedList + +final class APIClientTagsTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + // MARK: - trendingTags + + func test_trendingTags_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"tags":[]}"#) + _ = try await sut.trendingTags() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/tags/trending") + } + + func test_trendingTags_sendsDefaultWindowAndLimit() async throws { + session.stub(json: #"{"tags":[]}"#) + _ = try await sut.trendingTags() + let items = URLComponents(url: session.lastRequest!.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "window" }?.value, "week") + XCTAssertEqual(items.first { $0.name == "limit" }?.value, "20") + } + + func test_trendingTags_customWindowAndLimit() async throws { + session.stub(json: #"{"tags":[]}"#) + _ = try await sut.trendingTags(window: "month", limit: 50) + let items = URLComponents(url: session.lastRequest!.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "window" }?.value, "month") + XCTAssertEqual(items.first { $0.name == "limit" }?.value, "50") + } + + func test_trendingTags_decodesTags() async throws { + session.stub(json: #""" + {"tags":[ + {"tag":"interlinedlist","count":13,"lastUsedAt":"2026-08-13T05:24:06.402Z"}, + {"tag":"llms","count":3,"lastUsedAt":"2026-08-13T16:00:19.383Z"} + ]} + """#) + let tags = try await sut.trendingTags() + XCTAssertEqual(tags.count, 2) + XCTAssertEqual(tags.first?.tag, "interlinedlist") + XCTAssertEqual(tags.first?.count, 13) + XCTAssertEqual(tags.first?.lastUsedAt, "2026-08-13T05:24:06.402Z") + XCTAssertEqual(tags.first?.id, "interlinedlist") + } + + func test_trendingTags_decodesWhenLastUsedAtMissing() async throws { + session.stub(json: #"{"tags":[{"tag":"ai","count":8}]}"#) + let tags = try await sut.trendingTags() + XCTAssertEqual(tags.first?.tag, "ai") + XCTAssertNil(tags.first?.lastUsedAt) + } + + func test_trendingTags_sendsBearerToken() async throws { + session.stub(json: #"{"tags":[]}"#) + _ = try await sut.trendingTags() + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_trendingTags_401_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.trendingTags() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: - tagAutocomplete + + func test_tagAutocomplete_sendsGetToCorrectPath() async throws { + session.stub(json: #"{"tags":[]}"#) + _ = try await sut.tagAutocomplete(query: "ai") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/tags/autocomplete") + } + + func test_tagAutocomplete_sendsQueryAndDefaultLimit() async throws { + session.stub(json: #"{"tags":[]}"#) + _ = try await sut.tagAutocomplete(query: "ai") + let items = URLComponents(url: session.lastRequest!.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "q" }?.value, "ai") + XCTAssertEqual(items.first { $0.name == "limit" }?.value, "10") + } + + func test_tagAutocomplete_percentEncodesQuery() async throws { + session.stub(json: #"{"tags":[]}"#) + _ = try await sut.tagAutocomplete(query: "a b&c") + let items = URLComponents(url: session.lastRequest!.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(items.first { $0.name == "q" }?.value, "a b&c") + } + + func test_tagAutocomplete_decodesSuggestions() async throws { + session.stub(json: #"{"tags":[{"tag":"ai","count":8},{"tag":"ai coding","count":2}]}"#) + let suggestions = try await sut.tagAutocomplete(query: "ai") + XCTAssertEqual(suggestions.count, 2) + XCTAssertEqual(suggestions.first?.tag, "ai") + XCTAssertEqual(suggestions.first?.count, 8) + XCTAssertEqual(suggestions.last?.tag, "ai coding") + } + + func test_tagAutocomplete_400_blankQuery_throwsServerError() async throws { + session.stub(json: #"{"error":"Query parameter 'q' is required"}"#, statusCode: 400) + do { + _ = try await sut.tagAutocomplete(query: "x") + XCTFail("Expected throw") + } catch APIError.server(let msg) { + XCTAssertEqual(msg, "Query parameter 'q' is required") + } + } +} diff --git a/InterlinedListTests/E2E/E2EReadOnlyTests.swift b/InterlinedListTests/E2E/E2EReadOnlyTests.swift index 2c975b2..7a688d4 100644 --- a/InterlinedListTests/E2E/E2EReadOnlyTests.swift +++ b/InterlinedListTests/E2E/E2EReadOnlyTests.swift @@ -99,9 +99,8 @@ final class E2EReadOnlyTests: XCTestCase { // MARK: - Lists - func test_e2e_listsAndFolders_returnsBoth() async throws { - let (folders, lists) = try await client.listsAndFolders() - XCTAssertGreaterThanOrEqual(folders.count, 0) + func test_e2e_lists_returnsArray() async throws { + let lists = try await client.lists() XCTAssertGreaterThanOrEqual(lists.count, 0) } diff --git a/InterlinedListTests/ModelTests/AppDeepLinkParseTests.swift b/InterlinedListTests/ModelTests/AppDeepLinkParseTests.swift new file mode 100644 index 0000000..3750676 --- /dev/null +++ b/InterlinedListTests/ModelTests/AppDeepLinkParseTests.swift @@ -0,0 +1,124 @@ +import XCTest +@testable import InterlinedList + +final class AppDeepLinkParseTests: XCTestCase { + + private func parse(_ string: String) -> AppDeepLink? { + guard let url = URL(string: string) else { return nil } + return AppDeepLink.parse(url) + } + + // MARK: Custom scheme — content + + func test_parse_customSchemeUserHost_returnsUserProfile() { + XCTAssertEqual(parse("interlinedlist://user/bob"), .userProfile(username: "bob")) + } + + func test_parse_customSchemeMessageHost_returnsMessage() { + XCTAssertEqual(parse("interlinedlist://message/abc"), .message(id: "abc")) + } + + func test_parse_customSchemeUserAsFirstPathSegment_returnsUserProfile() { + // interlinedlist:///user/bob puts the target in the first path segment (empty host). + XCTAssertEqual(parse("interlinedlist:///user/bob"), .userProfile(username: "bob")) + } + + // MARK: Web permalinks — content + + func test_parse_httpsCanonicalUser_returnsUserProfile() { + XCTAssertEqual(parse("https://interlinedlist.com/user/bob"), .userProfile(username: "bob")) + } + + func test_parse_httpsWwwMessage_returnsMessage() { + XCTAssertEqual(parse("https://www.interlinedlist.com/message/abc"), .message(id: "abc")) + } + + func test_parse_httpsCanonicalList_isNotRouted_returnsNil() { + // List permalinks are deliberately not routed: a bare `/lists/<id>` has no + // owner username, which the list-detail endpoint requires (G10 follow-on). + XCTAssertNil(parse("https://interlinedlist.com/lists/xyz")) + } + + // MARK: Documents (G10) + + func test_parse_httpsCanonicalDocument_returnsDocument() { + XCTAssertEqual(parse("https://interlinedlist.com/documents/doc-1"), + .document(id: "doc-1")) + } + + func test_parse_customSchemeDocumentHost_returnsDocument() { + XCTAssertEqual(parse("interlinedlist://documents/doc-1"), + .document(id: "doc-1")) + } + + func test_parse_httpsSharedDocument_returnsSharedDocument() { + XCTAssertEqual(parse("https://interlinedlist.com/documents/shared/tok-123"), + .sharedDocument(token: "tok-123")) + } + + func test_parse_customSchemeSharedDocument_returnsSharedDocument() { + XCTAssertEqual(parse("interlinedlist://documents/shared/tok-123"), + .sharedDocument(token: "tok-123")) + } + + func test_parse_documentsMissingId_returnsNil() { + XCTAssertNil(parse("https://interlinedlist.com/documents")) + } + + func test_parse_sharedDocumentMissingToken_returnsNil() { + XCTAssertNil(parse("https://interlinedlist.com/documents/shared")) + } + + func test_parse_httpsCanonicalSharedList_isNotRouted_returnsNil() { + // List share-link resolution is deferred (see the-gaps.md G10 / lists refactor). + XCTAssertNil(parse("https://interlinedlist.com/lists/shared/tok-9")) + } + + // MARK: Rejections + + func test_parse_httpsForeignHost_returnsNil() { + XCTAssertNil(parse("https://evil.example.com/user/bob")) + } + + func test_parse_httpsInterlinedlistUnknownTarget_returnsNil() { + XCTAssertNil(parse("https://interlinedlist.com/dashboard")) + } + + func test_parse_customSchemeUnknownTarget_returnsNil() { + XCTAssertNil(parse("interlinedlist://settings/foo")) + } + + func test_parse_customSchemeUserMissingUsername_returnsNil() { + XCTAssertNil(parse("interlinedlist://user")) + } + + func test_parse_otherScheme_returnsNil() { + XCTAssertNil(parse("mailto:someone@example.com")) + } + + // MARK: Auth deep links still recognized + + func test_parse_resetPassword_returnsResetPasswordWithToken() { + XCTAssertEqual(parse("interlinedlist://reset-password?token=abc123"), + .resetPassword(token: "abc123")) + } + + func test_parse_verifyEmail_returnsVerifyEmailWithToken() { + XCTAssertEqual(parse("interlinedlist://verify-email?token=tok"), + .verifyEmail(token: "tok")) + } + + func test_parse_verifyEmailChange_returnsVerifyEmailChangeWithToken() { + XCTAssertEqual(parse("interlinedlist://verify-email-change?token=tok"), + .verifyEmailChange(token: "tok")) + } + + func test_parse_resetPasswordMissingToken_returnsNil() { + XCTAssertNil(parse("interlinedlist://reset-password")) + } + + func test_parse_httpsResetPassword_returnsResetPassword() { + XCTAssertEqual(parse("https://interlinedlist.com/reset-password?token=xyz"), + .resetPassword(token: "xyz")) + } +} diff --git a/InterlinedListTests/ModelTests/DirectMessageModelTests.swift b/InterlinedListTests/ModelTests/DirectMessageModelTests.swift new file mode 100644 index 0000000..900dcad --- /dev/null +++ b/InterlinedListTests/ModelTests/DirectMessageModelTests.swift @@ -0,0 +1,175 @@ +import XCTest +@testable import InterlinedList + +final class DirectMessageModelTests: XCTestCase { + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + + // MARK: DMMessage + + func test_dmMessage_decodesFullPayload() throws { + let json = """ + { + "id": "m1", + "pairKey": "a:b", + "senderId": "s1", + "recipientId": "r1", + "body": "hi **there**", + "imageUrls": ["https://img/1.png","https://img/2.png"], + "createdAt": "2026-07-31T12:00:00.000Z", + "readAt": "2026-07-31T12:05:00.000Z", + "sender": {"id":"s1","username":"alice","displayName":"Alice","avatar":"a.png"}, + "recipient": {"id":"r1","username":"bob","displayName":null,"avatar":null}, + "preview": "hi there" + } + """ + let message = try decoder.decode(DMMessage.self, from: Data(json.utf8)) + XCTAssertEqual(message.id, "m1") + XCTAssertEqual(message.senderId, "s1") + XCTAssertEqual(message.recipientId, "r1") + XCTAssertEqual(message.imageUrls.count, 2) + XCTAssertEqual(message.sender?.username, "alice") + XCTAssertEqual(message.recipient?.username, "bob") + XCTAssertTrue(message.isRead) + } + + func test_dmMessage_missingOptionalsDoNotCrash() throws { + let json = """ + { + "id": "m2", + "senderId": "s1", + "recipientId": "r1", + "createdAt": "2026-07-31T12:00:00.000Z" + } + """ + let message = try decoder.decode(DMMessage.self, from: Data(json.utf8)) + XCTAssertEqual(message.id, "m2") + XCTAssertEqual(message.body, "") + XCTAssertTrue(message.imageUrls.isEmpty) + XCTAssertNil(message.readAt) + XCTAssertNil(message.sender) + XCTAssertNil(message.preview) + XCTAssertFalse(message.isRead) + } + + func test_dmMessage_nullReadAt_isUnread() throws { + let json = """ + {"id":"m3","senderId":"s1","recipientId":"r1","body":"x","createdAt":"t","readAt":null} + """ + let message = try decoder.decode(DMMessage.self, from: Data(json.utf8)) + XCTAssertFalse(message.isRead) + } + + func test_dmMessage_otherParty_returnsRecipientWhenSelfIsSender() throws { + let json = """ + { + "id":"m4","senderId":"me","recipientId":"r1","body":"x","createdAt":"t", + "sender":{"id":"me","username":"self","displayName":null,"avatar":null}, + "recipient":{"id":"r1","username":"bob","displayName":null,"avatar":null} + } + """ + let message = try decoder.decode(DMMessage.self, from: Data(json.utf8)) + XCTAssertEqual(message.otherParty(selfId: "me")?.username, "bob") + } + + func test_dmMessage_otherParty_returnsSenderWhenSelfIsRecipient() throws { + let json = """ + { + "id":"m5","senderId":"s1","recipientId":"me","body":"x","createdAt":"t", + "sender":{"id":"s1","username":"alice","displayName":null,"avatar":null}, + "recipient":{"id":"me","username":"self","displayName":null,"avatar":null} + } + """ + let message = try decoder.decode(DMMessage.self, from: Data(json.utf8)) + XCTAssertEqual(message.otherParty(selfId: "me")?.username, "alice") + } + + // MARK: DMUser + + func test_dmUser_displayNameOrUsername_fallsBackToUsername() throws { + let json = #"{"id":"u1","username":"alice","displayName":"","avatar":null}"# + let user = try decoder.decode(DMUser.self, from: Data(json.utf8)) + XCTAssertEqual(user.displayNameOrUsername, "alice") + } + + func test_dmUser_displayNameOrUsername_prefersDisplayName() throws { + let json = #"{"id":"u1","username":"alice","displayName":"Alice A","avatar":null}"# + let user = try decoder.decode(DMUser.self, from: Data(json.utf8)) + XCTAssertEqual(user.displayNameOrUsername, "Alice A") + } + + // MARK: DMThread + + func test_dmThread_decodesFlagsAndItems() throws { + let json = """ + { + "items": [{"id":"m1","senderId":"s1","recipientId":"r1","body":"hi","createdAt":"t"}], + "olderCursor": "c1", + "isMutual": true, + "isBlocked": false, + "otherUser": {"id":"r1","username":"bob","displayName":"Bob","avatar":null} + } + """ + let thread = try decoder.decode(DMThread.self, from: Data(json.utf8)) + XCTAssertEqual(thread.items.count, 1) + XCTAssertEqual(thread.olderCursor, "c1") + XCTAssertTrue(thread.isMutual) + XCTAssertFalse(thread.isBlocked) + XCTAssertEqual(thread.otherUser.username, "bob") + } + + func test_dmThread_missingFlagsDefaultToFalse() throws { + let json = """ + { + "items": [], + "otherUser": {"id":"r1","username":"bob","displayName":null,"avatar":null} + } + """ + let thread = try decoder.decode(DMThread.self, from: Data(json.utf8)) + XCTAssertTrue(thread.items.isEmpty) + XCTAssertFalse(thread.isMutual) + XCTAssertFalse(thread.isBlocked) + XCTAssertNil(thread.olderCursor) + } + + // MARK: DMFolder + + func test_dmFolder_rawValuesMatchAPIContract() { + XCTAssertEqual(DMFolder.inbox.rawValue, "inbox") + XCTAssertEqual(DMFolder.sent.rawValue, "sent") + XCTAssertEqual(DMFolder.deleted.rawValue, "deleted") + XCTAssertEqual(DMFolder.allCases.count, 3) + } + + // MARK: DMRecipientFilter + + private func user(_ username: String, _ displayName: String? = nil) -> DMUser { + DMUser(id: username, username: username, displayName: displayName, avatar: nil) + } + + func test_recipientFilter_blankQuery_returnsEveryone() { + let people = [user("alice"), user("bob")] + XCTAssertEqual(DMRecipientFilter.matches(people, query: "").count, 2) + XCTAssertEqual(DMRecipientFilter.matches(people, query: " ").count, 2) + } + + func test_recipientFilter_matchesUsernameCaseInsensitively() { + let people = [user("alice"), user("bob")] + let result = DMRecipientFilter.matches(people, query: "ALI") + XCTAssertEqual(result.map(\.username), ["alice"]) + } + + func test_recipientFilter_matchesDisplayName() { + let people = [user("alice", "Alice Anderson"), user("bob", "Bob Brown")] + let result = DMRecipientFilter.matches(people, query: "brown") + XCTAssertEqual(result.map(\.username), ["bob"]) + } + + func test_recipientFilter_noMatch_returnsEmpty() { + let people = [user("alice"), user("bob")] + XCTAssertTrue(DMRecipientFilter.matches(people, query: "zzz").isEmpty) + } +} diff --git a/InterlinedListTests/ModelTests/DocumentModelTests.swift b/InterlinedListTests/ModelTests/DocumentModelTests.swift index b6ace50..ff91113 100644 --- a/InterlinedListTests/ModelTests/DocumentModelTests.swift +++ b/InterlinedListTests/ModelTests/DocumentModelTests.swift @@ -63,3 +63,57 @@ final class DocumentFolderCodableTests: XCTestCase { XCTAssertEqual(r.folders.count, 2) } } + +final class DocumentTemplateCodableTests: XCTestCase { + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + + func test_decode_withRelativePath() throws { + let json = #"{"id":"t1","title":"Weekly Notes","relative_path":"notes/weekly"}"# + let t = try decoder.decode(DocumentTemplate.self, from: Data(json.utf8)) + XCTAssertEqual(t.id, "t1") + XCTAssertEqual(t.title, "Weekly Notes") + XCTAssertEqual(t.relativePath, "notes/weekly") + } + + func test_decode_nullRelativePath() throws { + let json = #"{"id":"t2","title":"Meeting","relative_path":null}"# + let t = try decoder.decode(DocumentTemplate.self, from: Data(json.utf8)) + XCTAssertNil(t.relativePath) + } + + func test_decode_missingRelativePath_isNilNotCrash() throws { + let json = #"{"id":"t3","title":"Bare"}"# + let t = try decoder.decode(DocumentTemplate.self, from: Data(json.utf8)) + XCTAssertNil(t.relativePath) + } + + func test_decode_templatesResponse_ignoresExtraKeys() throws { + let json = #""" + {"folderCreated":true,"templatesFolderId":"tf","templates":[ + {"id":"t1","title":"A","relativePath":"a"}, + {"id":"t2","title":"B"} + ]} + """# + let r = try decoder.decode(DocumentTemplatesResponse.self, from: Data(json.utf8)) + XCTAssertEqual(r.templates.count, 2) + XCTAssertEqual(r.templates.first?.id, "t1") + } + + func test_roundTrip_preservesFields() throws { + let original = DocumentTemplate(id: "t9", title: "Round", relativePath: "path/x") + let encoder = JSONEncoder() + let data = try encoder.encode(original) + let decoded = try JSONDecoder().decode(DocumentTemplate.self, from: data) + XCTAssertEqual(decoded, original) + } +} + +extension DocumentTemplate: Equatable { + public static func == (lhs: DocumentTemplate, rhs: DocumentTemplate) -> Bool { + lhs.id == rhs.id && lhs.title == rhs.title && lhs.relativePath == rhs.relativePath + } +} diff --git a/InterlinedListTests/ModelTests/DocumentSyncModelTests.swift b/InterlinedListTests/ModelTests/DocumentSyncModelTests.swift new file mode 100644 index 0000000..760eb7c --- /dev/null +++ b/InterlinedListTests/ModelTests/DocumentSyncModelTests.swift @@ -0,0 +1,186 @@ +import XCTest +@testable import InterlinedList + +final class DocumentSyncModelTests: XCTestCase { + private let snakeDecoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + + // MARK: - New Document / DocumentFolder fields + + func test_document_decodesDeletedAt() throws { + let json = #"{"id":"d1","title":"Gone","deleted_at":"2026-07-31T00:00:00Z"}"# + let d = try snakeDecoder.decode(Document.self, from: Data(json.utf8)) + XCTAssertEqual(d.deletedAt, "2026-07-31T00:00:00Z") + } + + func test_document_missingDeletedAt_isNil() throws { + let json = #"{"id":"d1","title":"Live"}"# + let d = try snakeDecoder.decode(Document.self, from: Data(json.utf8)) + XCTAssertNil(d.deletedAt) + } + + func test_documentFolder_decodesUpdatedAtAndDeletedAt() throws { + let json = #"{"id":"f1","name":"F","parent_id":null,"updated_at":"2026-07-30T00:00:00Z","deleted_at":"2026-07-31T00:00:00Z"}"# + let f = try snakeDecoder.decode(DocumentFolder.self, from: Data(json.utf8)) + XCTAssertEqual(f.updatedAt, "2026-07-30T00:00:00Z") + XCTAssertEqual(f.deletedAt, "2026-07-31T00:00:00Z") + } + + func test_documentFolder_legacyCacheWithoutNewKeys_stillDecodes() throws { + // A cache written before the new fields existed must still decode (fields absent). + let json = #"{"id":"f1","name":"Legacy","parent_id":"p1"}"# + let f = try snakeDecoder.decode(DocumentFolder.self, from: Data(json.utf8)) + XCTAssertEqual(f.parentId, "p1") + XCTAssertNil(f.updatedAt) + XCTAssertNil(f.deletedAt) + } + + // MARK: - DocumentSyncResponse + + func test_syncResponse_decodesCamelCaseBody() throws { + // The /sync endpoint returns camelCase; the response still decodes with the + // snake-case-converting decoder because camelCase keys pass through unchanged. + let json = #""" + {"folders":[{"id":"f1","name":"F"}], + "documents":[{"id":"d1","title":"D"}], + "lastSyncAt":"2026-07-31T00:00:00Z"} + """# + let r = try snakeDecoder.decode(DocumentSyncResponse.self, from: Data(json.utf8)) + XCTAssertEqual(r.folders.count, 1) + XCTAssertEqual(r.documents.count, 1) + XCTAssertEqual(r.lastSyncAt, "2026-07-31T00:00:00Z") + } + + func test_syncResponse_absentArrays_defaultEmpty() throws { + let json = #"{"lastSyncAt":"2026-07-31T00:00:00Z"}"# + let r = try snakeDecoder.decode(DocumentSyncResponse.self, from: Data(json.utf8)) + XCTAssertTrue(r.folders.isEmpty) + XCTAssertTrue(r.documents.isEmpty) + } + + // MARK: - DocumentSyncState round-trip + + func test_syncState_roundTrip_preservesFieldsAndCursor() throws { + let state = DocumentSyncState( + folders: [DocumentFolder(id: "f1", name: "F", parentId: "p1", updatedAt: "2026-07-30T00:00:00Z")], + documents: [Document(id: "d1", title: "D", content: "x", folderId: "f1", isPublic: true, + createdAt: "2026-07-01T00:00:00Z", updatedAt: "2026-07-30T00:00:00Z")], + lastSyncAt: "2026-07-31T00:00:00Z" + ) + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(DocumentSyncState.self, from: data) + XCTAssertEqual(decoded.lastSyncAt, "2026-07-31T00:00:00Z") + XCTAssertEqual(decoded.folders.first?.id, "f1") + XCTAssertEqual(decoded.folders.first?.updatedAt, "2026-07-30T00:00:00Z") + XCTAssertEqual(decoded.documents.first?.id, "d1") + XCTAssertEqual(decoded.documents.first?.folderId, "f1") + XCTAssertEqual(decoded.documents.first?.isPublic, true) + } + + func test_syncState_emptyRoundTrip() throws { + let state = DocumentSyncState() + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(DocumentSyncState.self, from: data) + XCTAssertTrue(decoded.folders.isEmpty) + XCTAssertTrue(decoded.documents.isEmpty) + XCTAssertNil(decoded.lastSyncAt) + XCTAssertTrue(decoded.outbox.isEmpty) + XCTAssertTrue(decoded.localStates.isEmpty) + } + + // MARK: - Slice 2: outbox + localStates persistence + + func test_syncState_withOutboxAndStates_roundTrips() throws { + var state = DocumentSyncState(documents: [Document(id: "d1", title: "D")], lastSyncAt: "c1") + state.outbox = [ + SyncOperation(op: .create, type: .document, + data: SyncOpData(id: "d1", title: "D", content: "x", isPublic: false)), + SyncOperation(op: .delete, type: .folder, data: SyncOpData(id: "f9")), + ] + state.localStates = ["d1": .dirty, "f9": .deleted] + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(DocumentSyncState.self, from: data) + XCTAssertEqual(decoded.outbox.count, 2) + XCTAssertEqual(decoded.outbox.first?.op, .create) + XCTAssertEqual(decoded.outbox.first?.data.content, "x") + XCTAssertEqual(decoded.outbox.last?.type, .folder) + XCTAssertEqual(decoded.localStates["d1"], .dirty) + XCTAssertEqual(decoded.localStates["f9"], .deleted) + } + + func test_syncState_slice1CacheWithoutOutbox_stillDecodes() throws { + // A cache written by Slice 1 has no outbox/localStates keys. + let json = #"{"folders":[],"documents":[{"id":"d1","title":"D"}],"lastSyncAt":"c1"}"# + let decoded = try JSONDecoder().decode(DocumentSyncState.self, from: Data(json.utf8)) + XCTAssertEqual(decoded.documents.first?.id, "d1") + XCTAssertTrue(decoded.outbox.isEmpty) + XCTAssertTrue(decoded.localStates.isEmpty) + } + + // MARK: - Slice 3: baselines persistence + + func test_syncState_baselinesRoundTrip() throws { + var state = DocumentSyncState(documents: [Document(id: "d1", title: "D")], lastSyncAt: "c1") + state.baselines = ["d1": "2026-08-01T00:00:00Z", "d2": "2026-08-02T00:00:00Z"] + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(DocumentSyncState.self, from: data) + XCTAssertEqual(decoded.baselines["d1"], "2026-08-01T00:00:00Z") + XCTAssertEqual(decoded.baselines["d2"], "2026-08-02T00:00:00Z") + } + + func test_syncState_slice2CacheWithoutBaselines_stillDecodes() throws { + // A Slice-1/2 cache has no baselines key. + let json = #"{"folders":[],"documents":[{"id":"d1","title":"D"}],"lastSyncAt":"c1","outbox":[],"localStates":{}}"# + let decoded = try JSONDecoder().decode(DocumentSyncState.self, from: Data(json.utf8)) + XCTAssertEqual(decoded.documents.first?.id, "d1") + XCTAssertTrue(decoded.baselines.isEmpty) + } + + // MARK: - SyncConflictNotice + + func test_syncConflictNotice_equatableByFields() { + let a = SyncConflictNotice(id: "d1", originalTitle: "Live", copyTitle: "Live (conflicted copy 2026-08-02)") + let b = SyncConflictNotice(id: "d1", originalTitle: "Live", copyTitle: "Live (conflicted copy 2026-08-02)") + XCTAssertEqual(a, b) + XCTAssertEqual(a.id, "d1") + } + + // MARK: - SyncOpData encodes only non-nil keys + + func test_syncOpData_deleteEncodesOnlyId() throws { + let data = SyncOpData(id: "d1") + let json = try JSONEncoder().encode(data) + let obj = try JSONSerialization.jsonObject(with: json) as? [String: Any] + XCTAssertEqual(obj?.keys.sorted(), ["id"]) + XCTAssertEqual(obj?["id"] as? String, "d1") + } + + func test_syncOpData_encodesCamelCaseAndDropsNilKeys() throws { + let data = SyncOpData(id: "d1", folderId: "f1", title: "T", content: "C", isPublic: true) + let json = try JSONEncoder().encode(data) + let obj = try JSONSerialization.jsonObject(with: json) as? [String: Any] + XCTAssertEqual(obj?["folderId"] as? String, "f1") + XCTAssertEqual(obj?["title"] as? String, "T") + XCTAssertEqual(obj?["isPublic"] as? Bool, true) + // Untouched fields are absent, not null. + XCTAssertNil(obj?["name"]) + XCTAssertNil(obj?["parentId"]) + XCTAssertNil(obj?["relativePath"]) + } + + func test_syncOperation_encodesCamelCaseOpTypeAndData() throws { + let op = SyncOperation(op: .create, type: .document, path: "notes.md", + data: SyncOpData(id: "d1", title: "Notes")) + let json = try JSONEncoder().encode(op) + let obj = try JSONSerialization.jsonObject(with: json) as? [String: Any] + XCTAssertEqual(obj?["op"] as? String, "create") + XCTAssertEqual(obj?["type"] as? String, "document") + XCTAssertEqual(obj?["path"] as? String, "notes.md") + let dataObj = obj?["data"] as? [String: Any] + XCTAssertEqual(dataObj?["id"] as? String, "d1") + XCTAssertEqual(dataObj?["title"] as? String, "Notes") + } +} diff --git a/InterlinedListTests/ModelTests/FollowStateModelTests.swift b/InterlinedListTests/ModelTests/FollowStateModelTests.swift index 09d1a38..0abeccf 100644 --- a/InterlinedListTests/ModelTests/FollowStateModelTests.swift +++ b/InterlinedListTests/ModelTests/FollowStateModelTests.swift @@ -2,26 +2,73 @@ import XCTest @testable import InterlinedList final class FollowStatusCodableTests: XCTestCase { + // Mirrors APIClient's decoder (convertFromSnakeCase). The real + // GET /api/follow/:id/status body is camelCase: { status, isFollowing, isPending }. private let decoder: JSONDecoder = { let d = JSONDecoder() d.keyDecodingStrategy = .convertFromSnakeCase return d }() - func test_decode_allFields() throws { - let json = #"{"following":true,"followed_by":false,"pending_request":false}"# + func test_decode_statusApproved_isFollowing() throws { + let json = #"{"status":"approved","isFollowing":true,"isPending":false}"# let s = try decoder.decode(FollowStatus.self, from: Data(json.utf8)) XCTAssertTrue(s.following) + XCTAssertFalse(s.pendingRequest) XCTAssertFalse(s.followedBy) + } + + func test_decode_statusNull_notFollowing() throws { + let json = #"{"status":null,"isFollowing":false,"isPending":false}"# + let s = try decoder.decode(FollowStatus.self, from: Data(json.utf8)) + XCTAssertFalse(s.following) XCTAssertFalse(s.pendingRequest) + XCTAssertFalse(s.followedBy) } - func test_decode_pendingRequest() throws { - let json = #"{"following":false,"followed_by":false,"pending_request":true}"# + func test_decode_statusPending_isPending() throws { + let json = #"{"status":"pending","isFollowing":false,"isPending":true}"# let s = try decoder.decode(FollowStatus.self, from: Data(json.utf8)) XCTAssertTrue(s.pendingRequest) XCTAssertFalse(s.following) } + + // The endpoint omits followedBy entirely — decode must not throw keyNotFound. + func test_decode_missingFollowedBy_defaultsToFalse() throws { + let json = #"{"status":"approved","isFollowing":true,"isPending":false}"# + let s = try decoder.decode(FollowStatus.self, from: Data(json.utf8)) + XCTAssertFalse(s.followedBy) + } + + // Defensive fallback: if the boolean flags are absent, derive from `status`. + func test_decode_flagsAbsent_derivedFromStatus() throws { + let json = #"{"status":"pending"}"# + let s = try decoder.decode(FollowStatus.self, from: Data(json.utf8)) + XCTAssertTrue(s.pendingRequest) + XCTAssertFalse(s.following) + } + + // POST /api/follow/:id returns a nested { follow: { status, ... } } shape. + func test_decode_nestedFollowApproved_isFollowing() throws { + let json = #"{"follow":{"id":"f1","status":"approved","followerId":"a","followingId":"b"}}"# + let s = try decoder.decode(FollowStatus.self, from: Data(json.utf8)) + XCTAssertTrue(s.following) + XCTAssertFalse(s.pendingRequest) + } + + func test_decode_nestedFollowPending_isPending() throws { + let json = #"{"follow":{"id":"f1","status":"pending"}}"# + let s = try decoder.decode(FollowStatus.self, from: Data(json.utf8)) + XCTAssertTrue(s.pendingRequest) + XCTAssertFalse(s.following) + } + + func test_roundTrip_encodeThenDecode_preservesFlags() throws { + let original = FollowStatus(following: true, pendingRequest: false) + let data = try JSONEncoder().encode(original) + let restored = try decoder.decode(FollowStatus.self, from: data) + XCTAssertEqual(original, restored) + } } final class FollowCountsCodableTests: XCTestCase { diff --git a/InterlinedListTests/ModelTests/GapModelsTests.swift b/InterlinedListTests/ModelTests/GapModelsTests.swift index 99c66d4..34aedd4 100644 --- a/InterlinedListTests/ModelTests/GapModelsTests.swift +++ b/InterlinedListTests/ModelTests/GapModelsTests.swift @@ -50,6 +50,21 @@ final class GapModelsTests: XCTestCase { XCTAssertFalse(WatcherRole.collaborator.canManage) } + func test_watcherRole_detail_defaultUsesListNoun() { + XCTAssertEqual(WatcherRole.watcher.detail, "Can view this list") + } + + func test_watcherRole_detail_watcherIsResourceAware() { + XCTAssertEqual(WatcherRole.watcher.detail(for: "document"), "Can view this document") + XCTAssertEqual(WatcherRole.watcher.detail(for: "list"), "Can view this list") + } + + func test_watcherRole_detail_nonWatcherRolesIgnoreNoun() { + XCTAssertEqual(WatcherRole.collaborator.detail(for: "document"), "Can add and edit rows") + XCTAssertEqual(WatcherRole.manager.detail(for: "document"), + "Can edit the schema and manage access") + } + // MARK: - OrgRole func test_orgRole_ordering() { diff --git a/InterlinedListTests/ModelTests/GitHubModelTests.swift b/InterlinedListTests/ModelTests/GitHubModelTests.swift new file mode 100644 index 0000000..8769eca --- /dev/null +++ b/InterlinedListTests/ModelTests/GitHubModelTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import InterlinedList + +final class GitHubRepoCodableTests: XCTestCase { + // GitHubRepo is only ever decoded via APIClient's convertFromSnakeCase decoder, + // which rewrites full_name → fullName before CodingKeys match. Mirror that here. + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + + private let json = #"{"full_name":"octocat/Hello-World","name":"Hello-World","private":true,"owner":{"login":"octocat"}}"# + + func test_decode_mapsSnakeCaseFields() throws { + let repo = try decoder.decode(GitHubRepo.self, from: Data(json.utf8)) + XCTAssertEqual(repo.fullName, "octocat/Hello-World") + XCTAssertEqual(repo.name, "Hello-World") + XCTAssertEqual(repo.isPrivate, true) + XCTAssertEqual(repo.ownerLogin, "octocat") + } + + func test_decode_missingOptionalFields_isNil() throws { + let repo = try decoder.decode(GitHubRepo.self, from: Data(#"{"full_name":"solo/repo"}"#.utf8)) + XCTAssertEqual(repo.fullName, "solo/repo") + XCTAssertNil(repo.name) + XCTAssertNil(repo.isPrivate) + XCTAssertNil(repo.ownerLogin) + } + + func test_id_isFullName() { + let repo = GitHubRepo(fullName: "a/b", name: nil, isPrivate: nil, ownerLogin: nil) + XCTAssertEqual(repo.id, "a/b") + } + + func test_ownerAndRepo_derivedFromFullName_whenFieldsAbsent() { + let repo = GitHubRepo(fullName: "octocat/Hello-World", name: nil, isPrivate: nil, ownerLogin: nil) + XCTAssertEqual(repo.owner, "octocat") + XCTAssertEqual(repo.repo, "Hello-World") + } + + func test_ownerAndRepo_preferExplicitFields() { + let repo = GitHubRepo(fullName: "octocat/Hello-World", name: "Hello-World", isPrivate: nil, ownerLogin: "octocat") + XCTAssertEqual(repo.owner, "octocat") + XCTAssertEqual(repo.repo, "Hello-World") + } +} + +final class GitHubIssueCodableTests: XCTestCase { + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + + func test_decode_mapsHtmlUrl() throws { + let json = #"{"number":42,"title":"Fix crash","state":"open","html_url":"https://github.com/o/r/issues/42"}"# + let issue = try decoder.decode(GitHubIssue.self, from: Data(json.utf8)) + XCTAssertEqual(issue.number, 42) + XCTAssertEqual(issue.title, "Fix crash") + XCTAssertEqual(issue.state, "open") + XCTAssertEqual(issue.htmlUrl, "https://github.com/o/r/issues/42") + XCTAssertEqual(issue.id, 42) + } + + func test_decode_missingOptionalFields() throws { + let issue = try decoder.decode(GitHubIssue.self, from: Data(#"{"number":1,"title":"x"}"#.utf8)) + XCTAssertNil(issue.state) + XCTAssertNil(issue.htmlUrl) + } +} + +final class UserListGitHubFieldsTests: XCTestCase { + // UserList declares explicit CodingKeys, so github fields arrive camelCase. + private let decoder = JSONDecoder() + + func test_decode_localList_hasNilGitHubFields() throws { + let json = #"{"id":"1","title":"Books","createdAt":"2024-01-01T00:00:00Z"}"# + let list = try decoder.decode(UserList.self, from: Data(json.utf8)) + XCTAssertNil(list.source) + XCTAssertNil(list.githubRepo) + XCTAssertNil(list.githubMeta) + XCTAssertFalse(list.isGitHubBacked) + } + + func test_decode_gitHubList_withMeta() throws { + let json = """ + { + "id":"gh1","title":"Issues","createdAt":"2024-01-01T00:00:00Z", + "source":"github","githubRepo":"octocat/Hello-World", + "githubMeta":{"lastRefreshedAt":"2024-06-01T10:00:00Z","refreshStatus":"idle","refreshError":null} + } + """ + let list = try decoder.decode(UserList.self, from: Data(json.utf8)) + XCTAssertEqual(list.source, "github") + XCTAssertEqual(list.githubRepo, "octocat/Hello-World") + XCTAssertTrue(list.isGitHubBacked) + XCTAssertEqual(list.githubMeta?.lastRefreshedAt, "2024-06-01T10:00:00Z") + XCTAssertEqual(list.githubMeta?.refreshStatus, "idle") + XCTAssertNil(list.githubMeta?.refreshError) + } + + func test_decode_gitHubList_withoutMeta_stillDecodes() throws { + let json = #"{"id":"gh1","title":"Issues","createdAt":"2024-01-01T00:00:00Z","source":"github","githubRepo":"o/r"}"# + let list = try decoder.decode(UserList.self, from: Data(json.utf8)) + XCTAssertTrue(list.isGitHubBacked) + XCTAssertNil(list.githubMeta) + } + + func test_decode_gitHubMeta_failedStatusWithError() throws { + let json = """ + { + "id":"gh1","title":"Issues","createdAt":"2024-01-01T00:00:00Z", + "source":"github","githubRepo":"o/r", + "githubMeta":{"lastRefreshedAt":null,"refreshStatus":"failed","refreshError":"rate limited"} + } + """ + let list = try decoder.decode(UserList.self, from: Data(json.utf8)) + XCTAssertEqual(list.githubMeta?.refreshStatus, "failed") + XCTAssertEqual(list.githubMeta?.refreshError, "rate limited") + XCTAssertNil(list.githubMeta?.lastRefreshedAt) + } +} + +final class UserGitHubDefaultRepoTests: XCTestCase { + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + + func test_decode_withGitHubDefaultRepo() throws { + let json = #"{"id":"u1","email":"a@b.com","username":"a","githubDefaultRepo":"octocat/Hello-World"}"# + let user = try decoder.decode(User.self, from: Data(json.utf8)) + XCTAssertEqual(user.githubDefaultRepo, "octocat/Hello-World") + } + + func test_decode_withoutGitHubDefaultRepo_isNil() throws { + let json = #"{"id":"u1","email":"a@b.com","username":"a"}"# + let user = try decoder.decode(User.self, from: Data(json.utf8)) + XCTAssertNil(user.githubDefaultRepo) + } +} diff --git a/InterlinedListTests/ModelTests/ILWebURLTests.swift b/InterlinedListTests/ModelTests/ILWebURLTests.swift new file mode 100644 index 0000000..ec86237 --- /dev/null +++ b/InterlinedListTests/ModelTests/ILWebURLTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import InterlinedList + +final class ILWebURLTests: XCTestCase { + + func test_profile_buildsUserPath() { + XCTAssertEqual(ILWebURL.profile("bob")?.absoluteString, + "https://interlinedlist.com/user/bob") + } + + func test_message_buildsMessagePath() { + XCTAssertEqual(ILWebURL.message("abc")?.absoluteString, + "https://interlinedlist.com/message/abc") + } + + func test_list_buildsListsPath() { + XCTAssertEqual(ILWebURL.list("l1")?.absoluteString, + "https://interlinedlist.com/lists/l1") + } + + func test_document_buildsDocumentsPath() { + XCTAssertEqual(ILWebURL.document("d1")?.absoluteString, + "https://interlinedlist.com/documents/d1") + } + + func test_profile_percentEncodesUsername() { + let url = ILWebURL.profile("user name") + XCTAssertEqual(url?.absoluteString, "https://interlinedlist.com/user/user%20name") + } + + func test_profile_emptyUsername_returnsNil() { + XCTAssertNil(ILWebURL.profile("")) + } + + func test_message_emptyId_returnsNil() { + XCTAssertNil(ILWebURL.message("")) + } +} diff --git a/InterlinedListTests/ModelTests/LinkedInTargetModelTests.swift b/InterlinedListTests/ModelTests/LinkedInTargetModelTests.swift new file mode 100644 index 0000000..b03bd11 --- /dev/null +++ b/InterlinedListTests/ModelTests/LinkedInTargetModelTests.swift @@ -0,0 +1,130 @@ +import XCTest +@testable import InterlinedList + +final class LinkedInTargetModelTests: XCTestCase { + + private let encoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = .sortedKeys + return e + }() + + private func encodedJSON<T: Encodable>(_ value: T) throws -> [String: Any] { + let data = try encoder.encode(value) + return try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + // MARK: LinkedInTarget union encoding + + func test_encode_personal_onlyKind_noPageIds_noOrganizationId() throws { + let json = try encodedJSON(LinkedInTarget.personal()) + XCTAssertEqual(json["kind"] as? String, "personal") + XCTAssertNil(json["pageId"], "personal must not carry pageId") + XCTAssertNil(json["personalPageId"], "personal must not carry personalPageId") + XCTAssertNil(json["organizationId"], "organizationId is the outdated shape and must never be emitted") + XCTAssertEqual(json.keys.count, 1) + } + + func test_encode_orgPage_emitsKindAndPageId() throws { + let json = try encodedJSON(LinkedInTarget.orgPage(pageId: "page-1")) + XCTAssertEqual(json["kind"] as? String, "orgPage") + XCTAssertEqual(json["pageId"] as? String, "page-1") + XCTAssertNil(json["personalPageId"]) + XCTAssertNil(json["organizationId"]) + } + + func test_encode_personalPage_emitsKindAndPersonalPageId() throws { + let json = try encodedJSON(LinkedInTarget.personalPage(personalPageId: "pp-9")) + XCTAssertEqual(json["kind"] as? String, "personalPage") + XCTAssertEqual(json["personalPageId"] as? String, "pp-9") + XCTAssertNil(json["pageId"]) + XCTAssertNil(json["organizationId"]) + } + + // MARK: CreateMessageBody.linkedInTargets encoding + + func test_encode_messageBody_linkedInTargets_serializesToUnionKeys() throws { + let body = CreateMessageBody( + content: "hi", publiclyVisible: true, parentId: nil, tags: nil, + scheduledAt: nil, imageUrls: nil, videoUrls: nil, pushedMessageId: nil, + mastodonProviderIds: nil, crossPostToBluesky: nil, crossPostToLinkedIn: true, + linkedInTargets: [.personal(), .orgPage(pageId: "page-1"), .personalPage(personalPageId: "pp-9")], + linkedInLinkAsFirstComment: nil, crossPostToTwitter: nil, + scheduledCrossPostConfig: nil, organizationId: nil) + let json = try encodedJSON(body) + let arr = try XCTUnwrap(json["linkedInTargets"] as? [[String: Any]]) + XCTAssertEqual(arr.count, 3) + XCTAssertEqual(arr[0]["kind"] as? String, "personal") + XCTAssertNil(arr[0]["pageId"]) + XCTAssertNil(arr[0]["organizationId"]) + XCTAssertEqual(arr[1]["kind"] as? String, "orgPage") + XCTAssertEqual(arr[1]["pageId"] as? String, "page-1") + XCTAssertEqual(arr[2]["kind"] as? String, "personalPage") + XCTAssertEqual(arr[2]["personalPageId"] as? String, "pp-9") + // The whole array must be free of the outdated organizationId key. + for element in arr { + XCTAssertNil(element["organizationId"]) + } + } + + // MARK: LinkedInPostingTarget.asTarget mapping + + private func makeTarget( + kind: String, pageId: String? = nil, personalPageId: String? = nil + ) -> LinkedInPostingTarget { + LinkedInPostingTarget( + kind: kind, label: "L", avatarUrl: nil, pageId: pageId, + personalPageId: personalPageId, linkedInPageId: nil, enabled: true) + } + + func test_asTarget_personal_mapsToPersonalNoIds() { + let t = makeTarget(kind: "personal").asTarget + XCTAssertEqual(t.kind, "personal") + XCTAssertNil(t.pageId) + XCTAssertNil(t.personalPageId) + } + + func test_asTarget_orgPage_carriesPageId() { + let t = makeTarget(kind: "orgPage", pageId: "page-1").asTarget + XCTAssertEqual(t.kind, "orgPage") + XCTAssertEqual(t.pageId, "page-1") + XCTAssertNil(t.personalPageId) + } + + func test_asTarget_personalPage_carriesPersonalPageId() { + let t = makeTarget(kind: "personalPage", personalPageId: "pp-9").asTarget + XCTAssertEqual(t.kind, "personalPage") + XCTAssertEqual(t.personalPageId, "pp-9") + XCTAssertNil(t.pageId) + } + + func test_asTarget_orgPageMissingPageId_fallsBackToPersonal() { + let t = makeTarget(kind: "orgPage", pageId: nil).asTarget + XCTAssertEqual(t.kind, "personal") + XCTAssertNil(t.pageId) + } + + func test_asTarget_unknownKind_fallsBackToPersonal() { + let t = makeTarget(kind: "somethingNew").asTarget + XCTAssertEqual(t.kind, "personal") + } + + // MARK: LinkedInPostingTarget identity + decode + + func test_id_prefersPageIdThenPersonalPageIdThenKind() { + XCTAssertEqual(makeTarget(kind: "orgPage", pageId: "p1").id, "p1") + XCTAssertEqual(makeTarget(kind: "personalPage", personalPageId: "pp2").id, "pp2") + XCTAssertEqual(makeTarget(kind: "personal").id, "personal") + } + + func test_decode_missingOptionalFields_producesNilNotCrash() throws { + let json = #"{"kind":"personal","label":"Me","enabled":true}"# + let t = try JSONDecoder().decode(LinkedInPostingTarget.self, from: Data(json.utf8)) + XCTAssertEqual(t.kind, "personal") + XCTAssertNil(t.avatarUrl) + XCTAssertNil(t.pageId) + XCTAssertNil(t.personalPageId) + XCTAssertNil(t.linkedInPageId) + XCTAssertTrue(t.enabled) + } +} diff --git a/InterlinedListTests/ModelTests/ListConnectionTests.swift b/InterlinedListTests/ModelTests/ListConnectionTests.swift index 3ec75f7..d162a2b 100644 --- a/InterlinedListTests/ModelTests/ListConnectionTests.swift +++ b/InterlinedListTests/ModelTests/ListConnectionTests.swift @@ -6,20 +6,21 @@ final class ListConnectionTests: XCTestCase { func test_decode_listConnection_allFields() throws { let json = #""" - {"id":"c1","sourceListId":"src","targetListId":"tgt","createdAt":"2024-06-01T12:00:00Z"} + {"id":"c1","fromListId":"src","toListId":"tgt","label":"related","createdAt":"2024-06-01T12:00:00Z"} """# let conn = try decoder.decode(ListConnection.self, from: Data(json.utf8)) XCTAssertEqual(conn.id, "c1") - XCTAssertEqual(conn.sourceListId, "src") - XCTAssertEqual(conn.targetListId, "tgt") + XCTAssertEqual(conn.fromListId, "src") + XCTAssertEqual(conn.toListId, "tgt") + XCTAssertEqual(conn.label, "related") XCTAssertEqual(conn.createdAt, "2024-06-01T12:00:00Z") } func test_decode_connectionsResponse_multipleItems() throws { let json = #""" {"connections":[ - {"id":"a","sourceListId":"s1","targetListId":"t1","createdAt":null}, - {"id":"b","sourceListId":"s2","targetListId":"t2","createdAt":"2024-01-01T00:00:00Z"} + {"id":"a","fromListId":"s1","toListId":"t1","label":null,"createdAt":null}, + {"id":"b","fromListId":"s2","toListId":"t2","label":null,"createdAt":"2024-01-01T00:00:00Z"} ]} """# let response = try decoder.decode(ConnectionsResponse.self, from: Data(json.utf8)) @@ -27,6 +28,20 @@ final class ListConnectionTests: XCTestCase { XCTAssertEqual(response.connections[0].id, "a") XCTAssertNil(response.connections[0].createdAt) XCTAssertEqual(response.connections[1].id, "b") - XCTAssertEqual(response.connections[1].sourceListId, "s2") + XCTAssertEqual(response.connections[1].fromListId, "s2") + } + + // MARK: - Relationship semantics (fromList is the parent of toList) + + func test_relationship_fromListSeesOtherAsChild() { + let conn = ListConnection(id: "c", fromListId: "A", toListId: "B", label: nil, createdAt: nil) + XCTAssertEqual(conn.relationship(relativeTo: "A"), .child) + XCTAssertEqual(conn.otherListId(relativeTo: "A"), "B") + } + + func test_relationship_toListSeesOtherAsParent() { + let conn = ListConnection(id: "c", fromListId: "A", toListId: "B", label: nil, createdAt: nil) + XCTAssertEqual(conn.relationship(relativeTo: "B"), .parent) + XCTAssertEqual(conn.otherListId(relativeTo: "B"), "A") } } diff --git a/InterlinedListTests/ModelTests/ListModelTests.swift b/InterlinedListTests/ModelTests/ListModelTests.swift index 840297f..52f0f12 100644 --- a/InterlinedListTests/ModelTests/ListModelTests.swift +++ b/InterlinedListTests/ModelTests/ListModelTests.swift @@ -10,53 +10,67 @@ final class UserListCodableTests: XCTestCase { XCTAssertEqual(list.name, "My List") } - func test_decode_mapsParentIdToFolderId() throws { - let json = #"{"id":"1","title":"L","parentId":"folder-99","createdAt":"2024-01-01T00:00:00Z"}"# + func test_decode_parentIdKey_populatesParentId() throws { + // parentId is list-in-list nesting. + let json = #"{"id":"1","title":"L","parentId":"list-99","createdAt":"2024-01-01T00:00:00Z"}"# let list = try decoder.decode(UserList.self, from: Data(json.utf8)) - XCTAssertEqual(list.folderId, "folder-99") + XCTAssertEqual(list.parentId, "list-99") } func test_decode_emptyParentIdPreservedAsEmptyString() throws { let json = #"{"id":"1","title":"L","parentId":"","createdAt":"2024-01-01T00:00:00Z"}"# let list = try decoder.decode(UserList.self, from: Data(json.utf8)) - XCTAssertEqual(list.folderId, "") + XCTAssertEqual(list.parentId, "") // The tree-builder treats "" same as nil — guard this invariant - XCTAssertTrue((list.folderId ?? "").isEmpty) + XCTAssertTrue((list.parentId ?? "").isEmpty) } } final class ListTreeNodeTests: XCTestCase { - func test_buildTree_rootListWithNoFolder_appearsAtRoot() { - let list = makeList(id: "1", folderId: nil) - let nodes = ListTreeNode.buildTree(folders: [], lists: [list]) + func test_buildTree_rootList_appearsAtRoot() { + let list = makeList(id: "1") + let nodes = ListTreeNode.buildTree(lists: [list]) XCTAssertEqual(nodes.count, 1) XCTAssertEqual(nodes.first?.id, "1") + XCTAssertNil(nodes.first?.children) } - func test_buildTree_listWithEmptyFolderIdAppearsAtRoot() { - let list = makeList(id: "1", folderId: "") - let nodes = ListTreeNode.buildTree(folders: [], lists: [list]) + func test_buildTree_listWithEmptyParentIdAppearsAtRoot() { + let list = makeList(id: "1", parentId: "") + let nodes = ListTreeNode.buildTree(lists: [list]) XCTAssertEqual(nodes.count, 1) } - func test_buildTree_listInFolderAppearsAsChild() { - let folder = ListFolder(id: "f1", name: "Folder", parentId: nil, createdAt: nil) - let list = makeList(id: "l1", folderId: "f1") - let nodes = ListTreeNode.buildTree(folders: [folder], lists: [list]) + func test_buildTree_orphanedParentIdAppearsAtRoot() { + // A parentId that references a list we don't have falls through to root. + let list = makeList(id: "1", parentId: "nonexistent-list") + let nodes = ListTreeNode.buildTree(lists: [list]) XCTAssertEqual(nodes.count, 1) - XCTAssertEqual(nodes.first?.children?.count, 1) - XCTAssertEqual(nodes.first?.children?.first?.id, "l1") + XCTAssertEqual(nodes.first?.id, "1") } - func test_buildTree_orphanedParentIdAppearsAtRoot() { - let list = makeList(id: "1", folderId: "nonexistent-folder") - let nodes = ListTreeNode.buildTree(folders: [], lists: [list]) + func test_buildTree_listNestedUnderParentList() { + let parent = makeList(id: "a") + let child = makeList(id: "b", parentId: "a") + let nodes = ListTreeNode.buildTree(lists: [parent, child]) XCTAssertEqual(nodes.count, 1) + XCTAssertEqual(nodes.first?.id, "a") + XCTAssertEqual(nodes.first?.children?.count, 1) + XCTAssertEqual(nodes.first?.children?.first?.id, "b") + } + + func test_buildTree_multipleRootsPreserved() { + let a = makeList(id: "a") + let b = makeList(id: "b") + let nodes = ListTreeNode.buildTree(lists: [a, b]) + XCTAssertEqual(nodes.count, 2) + XCTAssertEqual(Set(nodes.map { $0.id }), ["a", "b"]) } - private func makeList(id: String, folderId: String?) -> UserList { - UserList(id: id, name: "List \(id)", description: nil, folderId: folderId, - isPublic: nil, createdAt: "2024-01-01T00:00:00Z", updatedAt: nil, itemCount: nil) + private func makeList(id: String, parentId: String? = nil) -> UserList { + UserList(id: id, name: "List \(id)", description: nil, parentId: parentId, + isPublic: nil, createdAt: "2024-01-01T00:00:00Z", + updatedAt: nil, itemCount: nil) } } @@ -81,32 +95,3 @@ final class JSONValueTests: XCTestCase { XCTAssertEqual(JSONValue.null.displayString, "") } } - -final class ListFolderCodableTests: XCTestCase { - private let decoder: JSONDecoder = { - let d = JSONDecoder() - d.keyDecodingStrategy = .convertFromSnakeCase - return d - }() - - func test_decode_allFields() throws { - let json = #"{"id":"f1","name":"Work","parent_id":"f0","created_at":"2024-03-15T12:00:00Z"}"# - let folder = try decoder.decode(ListFolder.self, from: Data(json.utf8)) - XCTAssertEqual(folder.id, "f1") - XCTAssertEqual(folder.name, "Work") - XCTAssertEqual(folder.parentId, "f0") - XCTAssertEqual(folder.createdAt, "2024-03-15T12:00:00Z") - } - - func test_decode_nilParentId() throws { - let json = #"{"id":"f2","name":"Personal","created_at":"2024-03-15T12:00:00Z"}"# - let folder = try decoder.decode(ListFolder.self, from: Data(json.utf8)) - XCTAssertNil(folder.parentId) - } - - func test_decode_nilCreatedAt() throws { - let json = #"{"id":"f3","name":"Archive","parent_id":"f0"}"# - let folder = try decoder.decode(ListFolder.self, from: Data(json.utf8)) - XCTAssertNil(folder.createdAt) - } -} diff --git a/InterlinedListTests/ModelTests/ShareInviteModelTests.swift b/InterlinedListTests/ModelTests/ShareInviteModelTests.swift new file mode 100644 index 0000000..1ff61b7 --- /dev/null +++ b/InterlinedListTests/ModelTests/ShareInviteModelTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import InterlinedList + +final class ShareInviteModelTests: XCTestCase { + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + + func test_shareInvite_decodesAllFields() throws { + let json = #""" + {"token":"inv123","email":"alice@example.com","role":"collaborator","expiresAt":"2026-09-01T00:00:00Z","accepted":true,"createdAt":"2026-08-01T00:00:00Z"} + """# + let invite = try decoder.decode(ShareInvite.self, from: Data(json.utf8)) + XCTAssertEqual(invite.token, "inv123") + XCTAssertEqual(invite.id, "inv123") + XCTAssertEqual(invite.email, "alice@example.com") + XCTAssertEqual(invite.role, "collaborator") + XCTAssertEqual(invite.shareRole, .collaborator) + XCTAssertEqual(invite.expiresAt, "2026-09-01T00:00:00Z") + XCTAssertEqual(invite.accepted, true) + XCTAssertEqual(invite.createdAt, "2026-08-01T00:00:00Z") + } + + func test_shareInvite_missingOptionalFields_decodeNil() throws { + let json = #"{"token":"t1","email":"bob@example.com","role":"watcher"}"# + let invite = try decoder.decode(ShareInvite.self, from: Data(json.utf8)) + XCTAssertNil(invite.expiresAt) + XCTAssertNil(invite.accepted) + XCTAssertNil(invite.createdAt) + XCTAssertEqual(invite.shareRole, .watcher) + } + + func test_shareInvite_unknownRole_shareRoleNil() throws { + let json = #"{"token":"t1","email":"bob@example.com","role":"owner"}"# + let invite = try decoder.decode(ShareInvite.self, from: Data(json.utf8)) + XCTAssertNil(invite.shareRole) + } + + func test_shareInvite_roundTrip() throws { + let original = ShareInvite(token: "t9", email: "c@d.co", role: "manager", expiresAt: nil, accepted: false, createdAt: "2026-01-01T00:00:00Z") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ShareInvite.self, from: data) + XCTAssertEqual(decoded.token, original.token) + XCTAssertEqual(decoded.email, original.email) + XCTAssertEqual(decoded.role, original.role) + XCTAssertEqual(decoded.accepted, original.accepted) + XCTAssertEqual(decoded.createdAt, original.createdAt) + } + + func test_shareInvitesResponse_decodesWrapper() throws { + let json = #"{"invites":[{"token":"t1","email":"a@b.co","role":"watcher"}]}"# + let response = try decoder.decode(ShareInvitesResponse.self, from: Data(json.utf8)) + XCTAssertEqual(response.invites.count, 1) + XCTAssertEqual(response.invites.first?.email, "a@b.co") + } + + func test_createShareInviteResponse_decodes() throws { + let json = #"{"email":"a@b.co","role":"manager","expiresAt":"2026-09-01T00:00:00Z","url":"https://interlinedlist.com/invite/x"}"# + let response = try decoder.decode(CreateShareInviteResponse.self, from: Data(json.utf8)) + XCTAssertEqual(response.email, "a@b.co") + XCTAssertEqual(response.shareRole, .manager) + XCTAssertEqual(response.url, "https://interlinedlist.com/invite/x") + XCTAssertEqual(response.expiresAt, "2026-09-01T00:00:00Z") + } +} diff --git a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift index 63c41dc..4f0c99b 100644 --- a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift +++ b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift @@ -112,6 +112,131 @@ final class AppDataStoreTests: XCTestCase { XCTAssertTrue(sut.documents.contains { $0.id == "d2" }) } + // MARK: - Offline document write cycle (Slice 2) + + func test_createDocumentOffline_insertsOptimisticallyAndMarksPending() { + let store = AppDataStore(syncAPI: FailingSyncAPI()) + let doc = store.createDocumentOffline(title: "Draft", content: "hi", isPublic: false, folderId: nil) + XCTAssertEqual(store.documents.first?.id, doc.id) + XCTAssertEqual(store.documents.first?.title, "Draft") + XCTAssertTrue(store.pendingSyncDocIds.contains(doc.id)) + } + + func test_pushOutbox_failure_keepsPendingState() async { + let api = FailingSyncAPI() + let store = AppDataStore(syncAPI: api) + let doc = store.createDocumentOffline(title: "Draft", content: nil, isPublic: false, folderId: nil) + await store.pushOutbox() + // Push failed (offline): the doc is still optimistically present and pending. + XCTAssertTrue(store.documents.contains { $0.id == doc.id }) + XCTAssertTrue(store.pendingSyncDocIds.contains(doc.id)) + XCTAssertTrue(api.pushCalled) + } + + func test_pushOutbox_success_clearsPendingAndPulls() async { + let api = RecordingSyncAPI(pushCursor: "2026-08-02T00:00:00Z", + pullResponse: DocumentSyncResponse(lastSyncAt: "2026-08-02T00:00:00Z")) + let store = AppDataStore(syncAPI: api) + let doc = store.createDocumentOffline(title: "Draft", content: nil, isPublic: false, folderId: nil) + await store.pushOutbox() + // On success the outbox is cleared → no longer pending, and the doc stays. + XCTAssertFalse(store.pendingSyncDocIds.contains(doc.id)) + XCTAssertTrue(store.documents.contains { $0.id == doc.id }) + XCTAssertTrue(api.pushCalled, "push runs first") + XCTAssertTrue(api.pullCalled, "then pull reconciles") + } + + func test_deleteDocumentOffline_removesOptimistically() { + let store = AppDataStore(syncAPI: FailingSyncAPI()) + let doc = store.createDocumentOffline(title: "D", content: nil, isPublic: false, folderId: nil) + store.deleteDocumentOffline(id: doc.id) + XCTAssertFalse(store.documents.contains { $0.id == doc.id }) + } + + func test_reset_clearsPendingSyncState() { + let store = AppDataStore(syncAPI: FailingSyncAPI()) + _ = store.createDocumentOffline(title: "D", content: nil, isPublic: false, folderId: nil) + store.reset() + XCTAssertTrue(store.pendingSyncDocIds.isEmpty) + XCTAssertTrue(store.documents.isEmpty) + } + + // MARK: - Slice 3: pull-first conflict-copy cycle + + func test_syncCycle_dirtyDocWithNewerServer_keepsLocalAndCreatesConflictCopy() async { + let api = ControllableSyncAPI() + + // Cycle 1: a clean pull seeds document d1 and records its baseline (T1). + api.nextPull = DocumentSyncResponse( + documents: [makeSyncedDoc(id: "d1", title: "server v1", updatedAt: "2026-08-01T00:00:00Z")], + lastSyncAt: "cursor-1") + let store = AppDataStore(syncAPI: api) + await store.pushOutbox() + XCTAssertTrue(store.documents.contains { $0.id == "d1" }) + XCTAssertTrue(store.syncConflicts.isEmpty, "clean pull is not a conflict") + + // A pull that throws keeps the debounced push (from the edit below) a no-op + // while we stage the divergent server state deterministically. + api.nextPull = nil + _ = store.updateDocumentOffline(id: "d1", title: "my local edit", + content: "local body", isPublic: false, folderId: nil) + XCTAssertTrue(store.pendingSyncDocIds.contains("d1")) + + // Cycle 2: the server has a NEWER version of d1 → conflict-copy. + api.nextPull = DocumentSyncResponse( + documents: [makeSyncedDoc(id: "d1", title: "server v2", content: "server body", + updatedAt: "2026-08-02T00:00:00Z")], + lastSyncAt: "cursor-2") + api.nextPushCursor = "2026-08-02T12:00:00Z" + await store.pushOutbox() + + // Local doc stays live (protected from the merge). + let live = store.documents.first { $0.id == "d1" } + XCTAssertEqual(live?.title, "my local edit") + + // A conflict copy of the SERVER version was created as a new doc. + let copy = store.documents.first { $0.id != "d1" && $0.title.contains("conflicted copy") } + XCTAssertNotNil(copy, "a conflict copy document exists") + XCTAssertTrue(copy?.title.hasPrefix("server v2") == true) + XCTAssertEqual(copy?.content, "server body") + + // Banner notice recorded for the conflicting doc. + XCTAssertEqual(store.syncConflicts.count, 1) + XCTAssertEqual(store.syncConflicts.first?.id, "d1") + + // Push drained both the local update and the new conflict-copy create. + let pushedIds = Set(api.lastPushedOps.map { $0.data.id }) + XCTAssertTrue(pushedIds.contains("d1"), "local edit pushed") + XCTAssertTrue(copy.map { pushedIds.contains($0.id) } ?? false, "conflict copy pushed") + // Outbox cleared after the successful push. + XCTAssertFalse(store.pendingSyncDocIds.contains("d1")) + } + + func test_dismissSyncConflicts_clearsBanner() async { + let api = ControllableSyncAPI() + api.nextPull = DocumentSyncResponse( + documents: [makeSyncedDoc(id: "d1", title: "v1", updatedAt: "2026-08-01T00:00:00Z")], + lastSyncAt: "c1") + let store = AppDataStore(syncAPI: api) + await store.pushOutbox() + api.nextPull = nil + _ = store.updateDocumentOffline(id: "d1", title: "local", content: nil, isPublic: false, folderId: nil) + api.nextPull = DocumentSyncResponse( + documents: [makeSyncedDoc(id: "d1", title: "v2", updatedAt: "2026-08-02T00:00:00Z")], + lastSyncAt: "c2") + api.nextPushCursor = "2026-08-02T12:00:00Z" + await store.pushOutbox() + XCTAssertFalse(store.syncConflicts.isEmpty) + store.dismissSyncConflicts() + XCTAssertTrue(store.syncConflicts.isEmpty) + } + + private func makeSyncedDoc(id: String, title: String, content: String? = nil, + updatedAt: String) -> Document { + Document(id: id, title: title, content: content, folderId: nil, isPublic: false, + createdAt: "2026-07-01T00:00:00Z", updatedAt: updatedAt) + } + // MARK: - Helpers private func makeMessage(id: String) -> Message { @@ -128,3 +253,65 @@ final class AppDataStoreTests: XCTestCase { createdAt: "2026-01-01T00:00:00Z", updatedAt: nil) } } + +/// Every sync call throws — simulates being offline. +private final class FailingSyncAPI: DocumentSyncAPI, @unchecked Sendable { + private(set) var pushCalled = false + private(set) var pullCalled = false + + func documentSync(lastSyncAt: String?) async throws -> DocumentSyncResponse { + pullCalled = true + throw APIError.network(URLError(.notConnectedToInternet)) + } + + func pushDocumentSync(operations: [SyncOperation]) async throws -> String { + pushCalled = true + throw APIError.network(URLError(.notConnectedToInternet)) + } +} + +/// Records calls and returns canned success responses. +private final class RecordingSyncAPI: DocumentSyncAPI, @unchecked Sendable { + let pushCursor: String + let pullResponse: DocumentSyncResponse + private(set) var pushCalled = false + private(set) var pullCalled = false + private(set) var pushedOperations: [SyncOperation] = [] + + init(pushCursor: String, pullResponse: DocumentSyncResponse) { + self.pushCursor = pushCursor + self.pullResponse = pullResponse + } + + func pushDocumentSync(operations: [SyncOperation]) async throws -> String { + pushCalled = true + pushedOperations = operations + return pushCursor + } + + func documentSync(lastSyncAt: String?) async throws -> DocumentSyncResponse { + pullCalled = true + return pullResponse + } +} + +/// A sequenceable mock: `nextPull` is served on the next pull (nil → throws, so a +/// stray debounced push is a harmless no-op); `nextPushCursor` is returned on the +/// next push, and the pushed ops are captured. +private final class ControllableSyncAPI: DocumentSyncAPI, @unchecked Sendable { + var nextPull: DocumentSyncResponse? + var nextPushCursor: String = "cursor-push" + private(set) var lastPushedOps: [SyncOperation] = [] + + func documentSync(lastSyncAt: String?) async throws -> DocumentSyncResponse { + guard let pull = nextPull else { + throw APIError.network(URLError(.notConnectedToInternet)) + } + return pull + } + + func pushDocumentSync(operations: [SyncOperation]) async throws -> String { + lastPushedOps = operations + return nextPushCursor + } +} diff --git a/InterlinedListTests/ServiceTests/DocumentSyncConflictTests.swift b/InterlinedListTests/ServiceTests/DocumentSyncConflictTests.swift new file mode 100644 index 0000000..90aa929 --- /dev/null +++ b/InterlinedListTests/ServiceTests/DocumentSyncConflictTests.swift @@ -0,0 +1,133 @@ +import XCTest +@testable import InterlinedList + +final class DocumentSyncConflictTests: XCTestCase { + + private func doc(_ id: String, title: String = "Notes", content: String? = nil, + folderId: String? = nil, isPublic: Bool? = nil, + updatedAt: String? = nil, deletedAt: String? = nil) -> Document { + Document(id: id, title: title, content: content, folderId: folderId, + isPublic: isPublic, updatedAt: updatedAt, deletedAt: deletedAt) + } + + /// 2026-08-02 12:00:00 UTC → stable local-date suffix for title assertions. + private var fixedDate: Date { + var c = DateComponents() + c.year = 2026; c.month = 8; c.day = 2; c.hour = 12 + c.timeZone = TimeZone.current + return Calendar(identifier: .gregorian).date(from: c) ?? Date(timeIntervalSince1970: 0) + } + + // MARK: - detectConflicts + + func test_detectConflicts_dirtyAndServerNewer_isConflict() { + let delta = DocumentSyncResponse( + documents: [doc("d1", updatedAt: "2026-08-02T00:00:00Z")], lastSyncAt: "c1") + let conflicts = DocumentSyncConflict.detectConflicts( + delta: delta, dirtyIds: ["d1"], baselines: ["d1": "2026-08-01T00:00:00Z"]) + XCTAssertEqual(conflicts.map(\.id), ["d1"]) + XCTAssertEqual(conflicts.first?.serverDocument.updatedAt, "2026-08-02T00:00:00Z") + } + + func test_detectConflicts_notDirty_isNoConflict() { + let delta = DocumentSyncResponse( + documents: [doc("d1", updatedAt: "2026-08-02T00:00:00Z")], lastSyncAt: "c1") + let conflicts = DocumentSyncConflict.detectConflicts( + delta: delta, dirtyIds: [], baselines: ["d1": "2026-08-01T00:00:00Z"]) + XCTAssertTrue(conflicts.isEmpty) + } + + func test_detectConflicts_serverNotNewerThanBaseline_isNoConflict() { + let delta = DocumentSyncResponse( + documents: [doc("d1", updatedAt: "2026-08-01T00:00:00Z")], lastSyncAt: "c1") + let conflicts = DocumentSyncConflict.detectConflicts( + delta: delta, dirtyIds: ["d1"], baselines: ["d1": "2026-08-01T00:00:00Z"]) + XCTAssertTrue(conflicts.isEmpty, "equal updatedAt is not strictly newer") + } + + func test_detectConflicts_dirtyWithNoBaseline_isNoConflict() { + // A purely local create the server hasn't acknowledged has no shared + // history to diverge from. + let delta = DocumentSyncResponse( + documents: [doc("d1", updatedAt: "2026-08-02T00:00:00Z")], lastSyncAt: "c1") + let conflicts = DocumentSyncConflict.detectConflicts( + delta: delta, dirtyIds: ["d1"], baselines: [:]) + XCTAssertTrue(conflicts.isEmpty) + } + + func test_detectConflicts_serverTombstoneForDirty_isNoConflict() { + // Deleted-elsewhere while locally dirty: the merge protects the local doc; + // it is not treated here as a content conflict-copy. + let delta = DocumentSyncResponse( + documents: [doc("d1", updatedAt: "2026-08-02T00:00:00Z", deletedAt: "2026-08-02T00:00:00Z")], + lastSyncAt: "c1") + let conflicts = DocumentSyncConflict.detectConflicts( + delta: delta, dirtyIds: ["d1"], baselines: ["d1": "2026-08-01T00:00:00Z"]) + XCTAssertTrue(conflicts.isEmpty) + } + + func test_detectConflicts_serverMissingUpdatedAt_isNoConflict() { + let delta = DocumentSyncResponse(documents: [doc("d1", updatedAt: nil)], lastSyncAt: "c1") + let conflicts = DocumentSyncConflict.detectConflicts( + delta: delta, dirtyIds: ["d1"], baselines: ["d1": "2026-08-01T00:00:00Z"]) + XCTAssertTrue(conflicts.isEmpty) + } + + func test_detectConflicts_multipleRows_onlyDivergentDirtyOnesReturned() { + let delta = DocumentSyncResponse(documents: [ + doc("dirtyNewer", updatedAt: "2026-08-02T00:00:00Z"), + doc("dirtySame", updatedAt: "2026-08-01T00:00:00Z"), + doc("cleanNewer", updatedAt: "2026-08-02T00:00:00Z"), + ], lastSyncAt: "c1") + let conflicts = DocumentSyncConflict.detectConflicts( + delta: delta, + dirtyIds: ["dirtyNewer", "dirtySame"], + baselines: ["dirtyNewer": "2026-08-01T00:00:00Z", + "dirtySame": "2026-08-01T00:00:00Z", + "cleanNewer": "2026-08-01T00:00:00Z"]) + XCTAssertEqual(conflicts.map(\.id), ["dirtyNewer"]) + } + + // MARK: - makeConflictCopy + + func test_makeConflictCopy_hasNewIdDistinctFromOriginal() { + let server = doc("d1", title: "Trip Plan", updatedAt: "2026-08-02T00:00:00Z") + let copy = DocumentSyncConflict.makeConflictCopy(server: server, date: fixedDate, newId: "new-uuid") + XCTAssertEqual(copy.document.id, "new-uuid") + XCTAssertNotEqual(copy.document.id, server.id) + } + + func test_makeConflictCopy_titleIsSuffixedWithDate() { + let server = doc("d1", title: "Trip Plan") + let copy = DocumentSyncConflict.makeConflictCopy(server: server, date: fixedDate, newId: "n1") + XCTAssertEqual(copy.document.title, "Trip Plan (conflicted copy 2026-08-02)") + } + + func test_makeConflictCopy_carriesServerContentFolderAndVisibility() { + let server = doc("d1", title: "T", content: "server body", + folderId: "f9", isPublic: true, updatedAt: "2026-08-02T00:00:00Z") + let copy = DocumentSyncConflict.makeConflictCopy(server: server, date: fixedDate, newId: "n1") + XCTAssertEqual(copy.document.content, "server body") + XCTAssertEqual(copy.document.folderId, "f9") + XCTAssertEqual(copy.document.isPublic, true) + } + + func test_makeConflictCopy_emitsCreateOpMatchingCopy() { + let server = doc("d1", title: "T", content: "body", folderId: "f9", + isPublic: false, updatedAt: "2026-08-02T00:00:00Z") + let copy = DocumentSyncConflict.makeConflictCopy(server: server, date: fixedDate, newId: "n1") + XCTAssertEqual(copy.operation.op, .create) + XCTAssertEqual(copy.operation.type, .document) + XCTAssertEqual(copy.operation.data.id, "n1") + XCTAssertEqual(copy.operation.data.title, copy.document.title) + XCTAssertEqual(copy.operation.data.content, "body") + XCTAssertEqual(copy.operation.data.folderId, "f9") + XCTAssertEqual(copy.operation.data.isPublic, false) + } + + func test_conflictCopyTitle_isDeterministicForDate() { + XCTAssertEqual( + DocumentSyncConflict.conflictCopyTitle(original: "X", date: fixedDate), + "X (conflicted copy 2026-08-02)") + } +} diff --git a/InterlinedListTests/ServiceTests/DocumentSyncMergeTests.swift b/InterlinedListTests/ServiceTests/DocumentSyncMergeTests.swift new file mode 100644 index 0000000..968bf63 --- /dev/null +++ b/InterlinedListTests/ServiceTests/DocumentSyncMergeTests.swift @@ -0,0 +1,147 @@ +import XCTest +@testable import InterlinedList + +final class DocumentSyncMergeTests: XCTestCase { + private func doc(_ id: String, title: String = "t", folderId: String? = nil, + updatedAt: String? = nil, deletedAt: String? = nil) -> Document { + Document(id: id, title: title, folderId: folderId, updatedAt: updatedAt, deletedAt: deletedAt) + } + + private func folder(_ id: String, name: String = "n", parentId: String? = nil, + updatedAt: String? = nil, deletedAt: String? = nil) -> DocumentFolder { + DocumentFolder(id: id, name: name, parentId: parentId, updatedAt: updatedAt, deletedAt: deletedAt) + } + + func test_apply_emptyDelta_isNoOpExceptCursor() { + let state = DocumentSyncState(folders: [folder("f1")], documents: [doc("d1")], lastSyncAt: "cursor0") + let delta = DocumentSyncResponse(folders: [], documents: [], lastSyncAt: "cursor1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(merged.folders.map(\.id), ["f1"]) + XCTAssertEqual(merged.documents.map(\.id), ["d1"]) + XCTAssertEqual(merged.lastSyncAt, "cursor1") + } + + func test_apply_nilDeltaCursor_keepsExistingCursor() { + let state = DocumentSyncState(folders: [], documents: [], lastSyncAt: "cursor0") + let delta = DocumentSyncResponse(folders: [], documents: [], lastSyncAt: nil) + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(merged.lastSyncAt, "cursor0") + } + + func test_apply_insertsNewDocument() { + let state = DocumentSyncState(folders: [], documents: [doc("d1")], lastSyncAt: nil) + let delta = DocumentSyncResponse(documents: [doc("d2")], lastSyncAt: "c1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(Set(merged.documents.map(\.id)), ["d1", "d2"]) + } + + func test_apply_upsertsChangedDocumentInPlace() { + let state = DocumentSyncState(folders: [], documents: [doc("d1", title: "old"), doc("d2")], lastSyncAt: nil) + let delta = DocumentSyncResponse(documents: [doc("d1", title: "new", updatedAt: "2026-07-31T00:00:00Z")], lastSyncAt: "c1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(merged.documents.count, 2) + let d1 = merged.documents.first { $0.id == "d1" } + XCTAssertEqual(d1?.title, "new") + XCTAssertEqual(d1?.updatedAt, "2026-07-31T00:00:00Z") + // Order is preserved: d1 stays at its original index. + XCTAssertEqual(merged.documents.first?.id, "d1") + } + + func test_apply_removesTombstonedDocumentById() { + let state = DocumentSyncState(folders: [], documents: [doc("d1"), doc("d2")], lastSyncAt: nil) + let delta = DocumentSyncResponse(documents: [doc("d1", deletedAt: "2026-07-31T00:00:00Z")], lastSyncAt: "c1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(merged.documents.map(\.id), ["d2"]) + } + + func test_apply_tombstoneForUnknownDocument_isNoOp() { + let state = DocumentSyncState(folders: [], documents: [doc("d1")], lastSyncAt: nil) + let delta = DocumentSyncResponse(documents: [doc("d99", deletedAt: "2026-07-31T00:00:00Z")], lastSyncAt: "c1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(merged.documents.map(\.id), ["d1"]) + } + + func test_apply_insertsUpdatesAndDeletesFolders() { + let state = DocumentSyncState(folders: [folder("f1"), folder("f2", name: "old")], documents: [], lastSyncAt: nil) + let delta = DocumentSyncResponse( + folders: [ + folder("f2", name: "renamed"), + folder("f3"), + folder("f1", deletedAt: "2026-07-31T00:00:00Z"), + ], + lastSyncAt: "c1" + ) + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(Set(merged.folders.map(\.id)), ["f2", "f3"]) + XCTAssertEqual(merged.folders.first { $0.id == "f2" }?.name, "renamed") + } + + func test_apply_cursorAdvancesToDeltaValue() { + let state = DocumentSyncState(folders: [], documents: [], lastSyncAt: "2026-07-30T00:00:00Z") + let delta = DocumentSyncResponse(documents: [doc("d1")], lastSyncAt: "2026-07-31T00:00:00Z") + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(merged.lastSyncAt, "2026-07-31T00:00:00Z") + } + + // MARK: - Slice 3: protectingIds + + func test_apply_protectedDocument_keepsLocalVersion() { + let state = DocumentSyncState(folders: [], documents: [doc("d1", title: "local edit")], lastSyncAt: nil) + let delta = DocumentSyncResponse( + documents: [doc("d1", title: "server version", updatedAt: "2026-08-02T00:00:00Z")], + lastSyncAt: "c1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state, protectingIds: ["d1"]) + XCTAssertEqual(merged.documents.first { $0.id == "d1" }?.title, "local edit") + } + + func test_apply_protectedDocumentTombstone_keepsLocalVersion() { + let state = DocumentSyncState(folders: [], documents: [doc("d1", title: "local")], lastSyncAt: nil) + let delta = DocumentSyncResponse( + documents: [doc("d1", deletedAt: "2026-08-02T00:00:00Z")], lastSyncAt: "c1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state, protectingIds: ["d1"]) + XCTAssertTrue(merged.documents.contains { $0.id == "d1" }, "protected doc is not tombstoned") + } + + func test_apply_nonProtectedDocuments_stillMergeWhenSomeProtected() { + let state = DocumentSyncState(folders: [], documents: [doc("d1", title: "local")], lastSyncAt: nil) + let delta = DocumentSyncResponse(documents: [ + doc("d1", title: "server", updatedAt: "2026-08-02T00:00:00Z"), + doc("d2", title: "other", updatedAt: "2026-08-02T00:00:00Z"), + ], lastSyncAt: "c1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state, protectingIds: ["d1"]) + XCTAssertEqual(merged.documents.first { $0.id == "d1" }?.title, "local") + XCTAssertEqual(merged.documents.first { $0.id == "d2" }?.title, "other") + } + + func test_apply_foldersAreNeverProtected() { + let state = DocumentSyncState(folders: [folder("f1", name: "old")], documents: [], lastSyncAt: nil) + let delta = DocumentSyncResponse(folders: [folder("f1", name: "new")], lastSyncAt: "c1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state, protectingIds: ["f1"]) + XCTAssertEqual(merged.folders.first { $0.id == "f1" }?.name, "new") + } + + func test_apply_preservesOutboxLocalStatesAndBaselines() { + var state = DocumentSyncState(documents: [doc("d1")], lastSyncAt: "c0") + state.outbox = [SyncOperation(op: .update, type: .document, data: SyncOpData(id: "d1", title: "x"))] + state.localStates = ["d1": .dirty] + state.baselines = ["d1": "2026-08-01T00:00:00Z"] + let delta = DocumentSyncResponse(documents: [doc("d2")], lastSyncAt: "c1") + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(merged.outbox.count, 1) + XCTAssertEqual(merged.localStates["d1"], .dirty) + XCTAssertEqual(merged.baselines["d1"], "2026-08-01T00:00:00Z") + } + + func test_apply_deletedRowNeverAppearsInMergedState() { + // Full-state pull (no cursor) may include a tombstone for a row we never had. + let state = DocumentSyncState() + let delta = DocumentSyncResponse( + folders: [folder("f1")], + documents: [doc("d1"), doc("d2", deletedAt: "2026-07-31T00:00:00Z")], + lastSyncAt: "c1" + ) + let merged = DocumentSyncMerge.apply(delta: delta, to: state) + XCTAssertEqual(merged.documents.map(\.id), ["d1"]) + XCTAssertFalse(merged.documents.contains { $0.deletedAt != nil }) + } +} diff --git a/InterlinedListTests/ServiceTests/DocumentSyncOutboxTests.swift b/InterlinedListTests/ServiceTests/DocumentSyncOutboxTests.swift new file mode 100644 index 0000000..ee629fa --- /dev/null +++ b/InterlinedListTests/ServiceTests/DocumentSyncOutboxTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import InterlinedList + +final class DocumentSyncOutboxTests: XCTestCase { + + private func createOp(_ id: String, title: String = "t", content: String? = nil, + folderId: String? = nil, isPublic: Bool? = nil) -> SyncOperation { + SyncOperation(op: .create, type: .document, + data: SyncOpData(id: id, folderId: folderId, title: title, + content: content, isPublic: isPublic)) + } + + private func updateOp(_ id: String, title: String? = nil, content: String? = nil, + folderId: String? = nil, isPublic: Bool? = nil) -> SyncOperation { + SyncOperation(op: .update, type: .document, + data: SyncOpData(id: id, folderId: folderId, title: title, + content: content, isPublic: isPublic)) + } + + private func deleteOp(_ id: String) -> SyncOperation { + SyncOperation(op: .delete, type: .document, data: SyncOpData(id: id)) + } + + // MARK: - Enqueue basics + + func test_enqueue_create_appendsOpAndMarksDirty() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1", title: "Hello"), into: &state) + XCTAssertEqual(state.outbox.count, 1) + XCTAssertEqual(state.outbox.first?.op, .create) + XCTAssertEqual(state.localStates["d1"], .dirty) + } + + func test_enqueue_distinctIds_keepsBothOps() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1"), into: &state) + DocumentSyncOutbox.enqueue(createOp("d2"), into: &state) + XCTAssertEqual(state.outbox.count, 2) + XCTAssertEqual(Set(state.outbox.map { $0.data.id }), ["d1", "d2"]) + } + + // MARK: - Coalescing: create then updates → single create + + func test_enqueue_createThenUpdate_collapsesToSingleCreateWithLatestFields() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1", title: "Draft", content: "a"), into: &state) + DocumentSyncOutbox.enqueue(updateOp("d1", title: "Final", content: "b"), into: &state) + XCTAssertEqual(state.outbox.count, 1) + let op = state.outbox.first + XCTAssertEqual(op?.op, .create, "a create absorbs the update — the row was never on the server") + XCTAssertEqual(op?.data.title, "Final") + XCTAssertEqual(op?.data.content, "b") + XCTAssertEqual(state.localStates["d1"], .dirty) + } + + func test_enqueue_createThenPartialUpdate_mergesUntouchedFields() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1", title: "Title", content: "Body", isPublic: false), into: &state) + // Only isPublic changes; title/content must survive the merge. + DocumentSyncOutbox.enqueue(updateOp("d1", isPublic: true), into: &state) + XCTAssertEqual(state.outbox.count, 1) + let op = state.outbox.first + XCTAssertEqual(op?.op, .create) + XCTAssertEqual(op?.data.title, "Title") + XCTAssertEqual(op?.data.content, "Body") + XCTAssertEqual(op?.data.isPublic, true) + } + + // MARK: - Coalescing: update then update → single update + + func test_enqueue_updateThenUpdate_collapsesToSingleUpdate() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(updateOp("d1", title: "One"), into: &state) + DocumentSyncOutbox.enqueue(updateOp("d1", title: "Two"), into: &state) + XCTAssertEqual(state.outbox.count, 1) + XCTAssertEqual(state.outbox.first?.op, .update) + XCTAssertEqual(state.outbox.first?.data.title, "Two") + } + + func test_enqueue_updateThenUpdate_mergesDistinctFields() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(updateOp("d1", title: "One"), into: &state) + DocumentSyncOutbox.enqueue(updateOp("d1", content: "Body"), into: &state) + XCTAssertEqual(state.outbox.count, 1) + let op = state.outbox.first + XCTAssertEqual(op?.data.title, "One") + XCTAssertEqual(op?.data.content, "Body") + } + + // MARK: - Coalescing: delete of a not-yet-synced create cancels both + + func test_enqueue_deleteOfUnsyncedCreate_cancelsBoth() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1"), into: &state) + DocumentSyncOutbox.enqueue(deleteOp("d1"), into: &state) + XCTAssertTrue(state.outbox.isEmpty, "a create+delete of the same never-synced row is a no-op") + XCTAssertNil(state.localStates["d1"]) + } + + func test_enqueue_deleteOfCreateThenUpdate_cancelsBoth() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1"), into: &state) + DocumentSyncOutbox.enqueue(updateOp("d1", title: "edited"), into: &state) + DocumentSyncOutbox.enqueue(deleteOp("d1"), into: &state) + XCTAssertTrue(state.outbox.isEmpty) + XCTAssertNil(state.localStates["d1"]) + } + + // MARK: - Coalescing: delete of a synced/updated row → single delete + + func test_enqueue_deleteOfUpdate_collapsesToSingleDelete() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(updateOp("d1", title: "edited"), into: &state) + DocumentSyncOutbox.enqueue(deleteOp("d1"), into: &state) + XCTAssertEqual(state.outbox.count, 1) + XCTAssertEqual(state.outbox.first?.op, .delete) + XCTAssertEqual(state.outbox.first?.data.id, "d1") + XCTAssertEqual(state.localStates["d1"], .deleted) + } + + func test_enqueue_deleteOfSyncedRow_appendsDelete() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(deleteOp("d1"), into: &state) + XCTAssertEqual(state.outbox.count, 1) + XCTAssertEqual(state.outbox.first?.op, .delete) + XCTAssertEqual(state.localStates["d1"], .deleted) + } + + // MARK: - Push payload + + func test_pushPayload_returnsCurrentOutbox() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1"), into: &state) + DocumentSyncOutbox.enqueue(createOp("d2"), into: &state) + let payload = DocumentSyncOutbox.pushPayload(from: state) + XCTAssertEqual(payload.map { $0.data.id }, ["d1", "d2"]) + } + + // MARK: - Clear after push + + func test_clearOutbox_removesPushedOpsAndSettlesStates() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1"), into: &state) + DocumentSyncOutbox.enqueue(deleteOp("d2"), into: &state) + let pushed = DocumentSyncOutbox.pushPayload(from: state) + DocumentSyncOutbox.clearOutbox(pushed, from: &state) + XCTAssertTrue(state.outbox.isEmpty) + XCTAssertEqual(state.localStates["d1"], .synced, "pushed create becomes synced") + XCTAssertNil(state.localStates["d2"], "pushed delete is forgotten") + } + + func test_clearOutbox_keepsOpsQueuedAfterTheSnapshot() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1"), into: &state) + let pushed = DocumentSyncOutbox.pushPayload(from: state) + // A new edit to a different id arrives while the push is in flight. + DocumentSyncOutbox.enqueue(createOp("d2"), into: &state) + DocumentSyncOutbox.clearOutbox(pushed, from: &state) + XCTAssertEqual(state.outbox.map { $0.data.id }, ["d2"], "only the pushed op is cleared") + XCTAssertEqual(state.localStates["d1"], .synced) + XCTAssertEqual(state.localStates["d2"], .dirty) + } + + // MARK: - dirtyIds + + func test_dirtyIds_reflectsPendingCreatesAndUpdatesOnly() { + var state = DocumentSyncState() + DocumentSyncOutbox.enqueue(createOp("d1"), into: &state) + DocumentSyncOutbox.enqueue(updateOp("d2", title: "x"), into: &state) + DocumentSyncOutbox.enqueue(deleteOp("d3"), into: &state) + XCTAssertEqual(state.dirtyIds, ["d1", "d2"]) + } +} diff --git a/blog-post/blog-post.md b/blog-post/blog-post.md new file mode 100644 index 0000000..f684880 --- /dev/null +++ b/blog-post/blog-post.md @@ -0,0 +1,99 @@ +# Meet InterlinedList: A Native iOS App for Sharing Messages, Lists, and Docs + +I've been heads-down on a project I'm genuinely excited to finally start talking about: **InterlinedList**, a native iOS app that sits on top of the [interlinedlist.com](https://interlinedlist.com) backend. If you've followed along here for a while, you know I have a soft spot for two things — clean, well-architected software, and the messy, human act of *sharing what you're working on*. InterlinedList is where those two collide. + +So let's take a walk through it. I'll show you the three things the app is really built around — **messages**, **lists**, and **documents** — and then, because it's the little details that make an app feel like *yours*, I'll wrap up with a quick note on flipping the whole thing between light and dark. + +Grab a coffee. Here we go. + +## The one-sentence version + +InterlinedList is a social space for people who think in lists. You post short messages to a feed, you keep structured lists (with real, typed columns — not just checkboxes), and you write longer-form documents in Markdown. Follow people, watch their lists, join organizations, cross-post out to the wider social web if you want to. It's built in pure SwiftUI, targets iOS 17+, and leans on nothing but Apple's own frameworks — no third-party dependency sprawl to babysit. + +The app opens into four simple sections along the top — **Home**, **Lists**, **Documents**, and your **Profile** — plus an envelope for direct messages and a bell for notifications. That's the whole map. Let's dig into the three that matter most. + +## Messages: the feed you actually post to + +The **Home** tab is your feed. It's the beating heart of the app and the fastest way to get a feel for it. + +![The InterlinedList home feed on iOS](01-feed.png) +*The Home feed — messages from you and the people you follow, all in one scroll.* + +Tap the compose button and you get a genuinely capable little editor: + +- **Write your post** with a live character counter so you always know how much room you've got left. +- **Public or private** with a single toggle — decide per-post whether the world sees it or just you. +- **Attach photos and video** right from the composer. +- **Tag it** so it's findable later. +- **Schedule it** for later if you're not ready to ship it this second. + +![The InterlinedList compose screen](02-compose.png) +*The composer: a public/private toggle, photo and video attachments, tags, and scheduling — all in one sheet.* + +Once a post is out in the feed, it behaves the way you'd expect a modern social surface to behave. You can **reply** and follow the thread, **repost** something worth boosting, **edit** your own posts, and give a post a **dig** — my favorite little bit of the vocabulary here, InterlinedList's take on the "like." Link previews get pulled in automatically, images and video render inline, and long posts collapse behind a tidy "Read more." + +And if you're a subscriber, there's a nice power-user flourish: **cross-posting**. From the composer you can fan a single message out to Mastodon, Bluesky, LinkedIn, and X at the same time it publishes on InterlinedList. Write once, land everywhere. (There's even a "post the link as a first comment" option for the LinkedIn crowd who care about that sort of thing — and yes, I see you, I *am* one of you.) + +Oh, and that envelope icon up top? That's your **direct messages** — private one-to-one threads, with an unread badge so you don't miss anything. + +## Lists: structured, nested, and genuinely useful + +Here's where InterlinedList earns its name. A **list** here isn't just a stack of bullet points — it's a small, structured thing with **typed columns you define yourself**. + +Head to the **Lists** tab, tap the **+**, and you can spin up a **New List** or a **New Folder**. Folders nest, lists live inside folders, and lists can even nest inside *other* lists — so you can model something as loose as "books to read" or as involved as a little multi-level catalog without leaving the app. + +![The InterlinedList Lists tab on iOS](03-lists.png) +*The Lists tab — folders and structured lists, nested however you like.* + +The part I love is the **schema editor**. Every list has a schema — a set of properties (columns) with real types, labels, ordering, required/optional flags, help text, the works. Long-press any list and you'll find **Rename / Edit**, **Edit Schema**, and **Delete**. Define your columns once and every row you add follows that shape. It's the difference between a note that *says* it's organized and data that actually *is*. + +A few more things worth knowing: + +- **Public lists** — flip a list public and it's shareable and browsable beyond your own account. +- **Watchers** — people can watch a list and follow along as it changes. +- **Connections** — lists can be linked to one another, so related collections stay related. + +It's the closest thing I've found to "a tiny database that doesn't feel like a database," and it lives right in your pocket. + +## Documents: Markdown for the longer thoughts + +Sometimes a message is too short and a list is the wrong shape. That's what **Documents** are for. + +The **Documents** tab is your space for longer-form writing, all in **Markdown**. Tap the **+** and you can create a **New Document**, start from a **Template**, or make a **New Folder** to keep things tidy. Just like lists, document folders nest, so you can build up a real little library instead of a flat pile of files. + +![The InterlinedList Documents tab on iOS](04-documents.png) +*The Documents tab — Markdown docs and folders for the longer-form thinking.* + +Under the hood each document is Markdown, edited in-app and rendered cleanly for reading — headers, formatting, inline images and all. Documents can be **public or private**, they support **collaborators** so you're not writing in a silo, and subscribers get **templates** to copy a good starting structure into a fresh doc instead of staring at a blank page. + +Feed for the quick stuff, lists for the structured stuff, documents for the thought-out stuff. Three tools, one app, and they all speak to the same account. + +## Oh — and a quick note so you can change the theme as you desire, to either your system, light, or dark! + +Because of course you can. I wasn't about to ship an app in 2026 that forces one appearance on you. InterlinedList respects your **system** setting by default, but if you'd rather nail it to **light** or **dark** regardless of what the OS is doing, that's a two-second change. Here's how. + +### Setting the theme in InterlinedList + +1. Open the app and tap your **Profile** (the avatar on the far right of the top bar). +2. Tap the **gear / Settings** icon in the top-left of the Profile screen. +3. Under the **Appearance** section at the top, you'll see a **Theme** picker. +4. Choose one of: + - **System** — follow whatever your iPhone is set to (this is the default, and it'll switch with your device automatically, sundown included). + - **Light** — always light, no matter what iOS is doing. + - **Dark** — always dark, same deal. + +![The InterlinedList Settings screen with the Appearance theme picker](05-settings-theme.png) +*Settings → Appearance → Theme — pick System, Light, or Dark and the whole app follows.* + +That's it. The change applies **instantly** — the entire app re-tints the moment you tap. Because InterlinedList saves your choice to your **account** rather than just this one device, your preference **follows you**: sign in on another iPhone and it comes right along with you. + +One small design note I'll own up to, since this blog is as much about the *how* as the *what*: "System" is stored as *no explicit preference*. When you pick Light or Dark, the app hands SwiftUI a `preferredColorScheme` and locks it in; when you pick System, it steps out of the way entirely and lets iOS drive. Small detail, but it's the kind of thing that makes an app feel like it's cooperating with the platform instead of fighting it. + +## Wrapping up + +That's the quick tour: **messages** for the feed, **lists** for the structured stuff, **documents** for the long-form, and a theme picker so the whole thing looks the way *you* want it to. There's plenty more under the surface — organizations, following and follow-requests, notification preferences, moderation controls, OAuth sign-in — but this is the core, and honestly it's the core I use every day. + +I'll be writing more about how it's put together on the inside (the SwiftUI architecture, the API client, the caching layer, all the parts I nerd out about) in follow-up posts. For now, go make a list, write a doc, post something to the feed — and set it to dark mode while you're at it. + +Cheers, +Adron diff --git a/subscription-permissions-update.md b/subscription-permissions-update.md deleted file mode 100644 index d56e685..0000000 --- a/subscription-permissions-update.md +++ /dev/null @@ -1,225 +0,0 @@ -# subscription-permissions-update — direction change - -**Date:** 2026-06-24 -**Status:** Pending review. Do not act on this doc until signed off. - -## The new principle - -The iOS app is a **free** app. **No subscription UI, no billing UI, no paywall -copy, no "subscribe" call-to-action appears anywhere in the iOS app.** - -- **Free features** are visible to every authenticated user. -- **Subscriber-only features** are visible **only** when the logged-in - user's `customerStatus` indicates an active subscription. To free - users, those features simply **do not exist** — no button, no menu - item, no toggle, no error message about subscriptions. -- All subscription management (sign-up, upgrade, billing, plan changes, - cancellation) happens **on the web** at `interlinedlist.com`. The iOS - app never links to it, never opens it via `SFSafariViewController`, - never mentions it. - -### Why this matters - -1. **App Store rules.** Apple's guideline 3.1.3(a) requires that - digital-goods sales go through Apple's In-App Purchase. By having - zero subscription surface in iOS, we don't bump into that rule at - all — no review risk. -2. **Simpler app.** No paywall flows, no plan-catalog endpoint, no - Stripe handoff, no "manage subscription" screen. The whole Phase 3 - "subscriber CTA" track disappears. -3. **The website is the source of truth** for commerce. Users who want - to upgrade do it where they already have a browser session, payment - method on file, etc. - -## What this changes from the current plan - -### Roadmap docs (`GAP-NEXT-STEPS.md`, `GAP-ENDPOINTS.md`) - -- `GAP-NEXT-STEPS.md` Phase 3 — **drop "Subscriber CTA" item entirely.** - Phase 3 keeps avatar upload, orgs lookup, delete account; that's it. -- `GAP-NEXT-STEPS.md` Phase 4 — change "disable + show paywall" pattern - to "**hide entirely**." Cross-post toggles, schedule picker, image/ - video pickers all disappear from the compose UI for non-subscribers. - No paywall message, no disabled-but-tappable state. -- `GAP-NEXT-STEPS.md` Phase 12 (Settings) — **drop "Subscription status - + manage subscription" row entirely.** No row, no link-out. -- `GAP-ENDPOINTS.md` §B1 — **delete the gap.** We no longer need a - `/api/subscriptions/plans` endpoint from the backend because we'll - never render plan info on iOS. -- `GAP-ENDPOINTS.md` summary table — remove the "Subscriptions (Stripe)" - row, or mark as "intentionally not used by iOS." - -### Code that exists today and needs to change - -The audit found subscription-aware copy / handling in **6 files**. -Each needs to change from "show paywall" → "hide feature." - -| File:line | Today | New direction | -|---|---|---| -| `Views/ListsView.swift:215, 223–224` | Static `paywallMessage = "Creating folders requires a subscription. You can subscribe at interlinedlist.com."` shown on 403 from `POST /api/folders` | **Hide the "New Folder" button entirely** when `authState.user?.isSubscriber != true`. Drop the `paywallMessage` constant. Keep the 403 catch arm but surface a generic "Couldn't create folder" — the button shouldn't have been reachable anyway. | -| `Views/ComposeView.swift:170–193` | Cross-post buttons (`M`/`BS`/`in`) rendered as `.disabled(true)` placeholders, visible to everyone | **Wrap the cross-post HStack** in `if user.isSubscriber { ... }`. Same for the X button to be added in Phase 4. | -| `Views/ComposeView.swift:138–169` | Image (`PhotosPicker`) + video pickers rendered to everyone, fail on upload with 403 | **Wrap each picker** in `if user.isSubscriber { ... }`. Don't render the picker for free users. | -| `Views/ComposeView.swift:297, 314` | Hardcoded strings `"Video upload requires an active subscription."` / `"Image upload requires an active subscription."` on 403 | **Delete these branches** — pickers are now hidden for free users, so 403 isn't reachable from normal flow. Generic "Upload failed" handles the unexpected case. | -| `Views/DocumentsView.swift:527, 625` | `errorMessage = "Requires active subscription."` on 403 from document folder create/update | **Hide "Create folder" / "Move to folder" UI** for free users. Same generic error fallback for the unreachable case. | -| `Views/ScheduledMessagesView.swift:78` | `errorMessage = "Scheduled posts require an active subscription."` on 403 | **Hide the calendar/schedule entry point in `FeedView.swift:200–201`** for free users. The `ScheduledMessagesView` itself never renders for them. Schedule-time picker in `ComposeView` likewise hidden. | -| `Views/FeedView.swift:200–201` | Calendar toolbar button always visible | Wrap in `if user.isSubscriber { ... }` so the entry point disappears. | -| `Services/AppDataStore.swift:95–96` | On 403 from documents: `documentsError = "Requires active subscription."` | **Stay silent on 403.** Free users have full documents access — a 403 here would mean something else broke. Surface as a generic load failure. | -| `Views/NotificationsView.swift:104` | Already silent on subscriber-only endpoint failure | **Leave as-is** — already follows the new pattern. | - -### Wire model — no changes needed - -- `User.customerStatus: String?` and `User.isSubscriber: Bool` stay - exactly as they are. They're the input to the new visibility gates. - -### Tests to update - -| Test file | Change | -|---|---| -| `APIClientListFolderTests.test_createListFolder_subscriberOnly403_surfacesServerMessageVerbatim` | Keep — verifies the client-side error decoding still works. The fact that the button is hidden in the UI doesn't invalidate the network-layer contract. | -| `APIClientVideoUploadTests` (subscriber 403 case) | Same — keep. | -| `UserModelTests.test_isSubscriber_*` (5 cases) | Keep — `isSubscriber` is now the only gating predicate; coverage stays valuable. | -| New tests to add | `ListsView_hidesCreateFolderButtonForFreeUser` style — but these are SwiftUI view tests, which we don't have infrastructure for. Verify visibility via the existing `ListSchemaDraft` refactor pattern: extract visibility predicates if needed, unit-test those. | - -## Subscriber-only feature inventory - -Cross-referenced from `/help/api` docs. iOS will gate visibility on -each of these by `authState.user?.isSubscriber == true`: - -| Feature | Endpoint | Today's iOS surface | New behavior | -|---|---|---|---| -| Create list folder | `POST /api/folders` | "New Folder" button + paywall on 403 | Button hidden | -| Update / delete list folder | `PUT/DELETE /api/folders/:id` | Edit / delete actions | Hidden (only folders user created exist; degraded subscribers see them but can't edit) | -| Create document folder | `POST /api/documents/folders` | Same as list folder | Same — hidden | -| Image upload | `POST /api/messages/images/upload` | Picker visible to all | Picker hidden | -| Video upload | `POST /api/messages/videos/upload` | Same | Same — hidden | -| Scheduled post | `scheduledAt` on `POST /api/messages` + `/api/messages/scheduled` | Calendar button in FeedView toolbar + sheet | Hidden | -| Edit scheduled post | `PATCH /api/messages/:id` | Phase 4 (not yet built) | Build with hide-not-disable from day one | -| Cross-post to Mastodon | `mastodonProviderIds[]` | Disabled placeholder | Hidden | -| Cross-post to Bluesky | `crossPostToBluesky` | Disabled placeholder | Hidden | -| Cross-post to LinkedIn | `crossPostToLinkedIn` | Disabled placeholder | Hidden | -| Cross-post to X | `crossPostToTwitter` | Not built yet | Build with hide-not-disable | -| Folder-based document `folderId` move | `PATCH /api/documents/:id` with `folderId` | Move-to-folder UI | Hidden (no folders to move into for free users) | - -## Edge cases - -### 1. Degraded subscribers (was paid, now free) - -A user could have created folders, scheduled posts, or set up -cross-posting while subscribed, then let their subscription lapse. The -server still has their data. - -**Resolved direction:** hide everything they don't have access to. -Their data isn't deleted — the server still honors it — but the iOS -UI surfaces nothing. - -- **Existing folders:** filter out from the displayed tree. Lists that - were inside those folders surface at root. Implementation point: in - `ListsView` and `DocumentsView`, gate the folder fetch + render on - `authState.user?.isSubscriber == true`. -- **Scheduled posts:** calendar entry point in `FeedView` toolbar is - hidden. `ScheduledMessagesView` is unreachable. Server still fires - the scheduled posts on time — that's a feature, not a bug. -- **Existing cross-post identities:** Phase 2 identity-management UI - is itself gated on subscriber status. If a free user has linked - identities from when they were a subscriber, the management UI - doesn't render and they see no indication. (Backend continues to - honor whatever credentials exist on the account record.) - -### 2. Unauthenticated state - -Doesn't matter — login is required for almost everything anyway. The -visibility gate is `authState.user?.isSubscriber == true`, which is -`false` for nil user. - -### 3. Server changes `customerStatus` mid-session - -`AuthState` refreshes the user on launch and on certain events. The UI -re-evaluates `isSubscriber` on every view body re-render, so the gates -re-apply automatically when `@EnvironmentObject` updates. - -### 4. Network flake — user object can't be fetched - -`authState.user` is nil. Subscriber gates evaluate false. User sees -free features only. If they were a subscriber, they're temporarily -demoted UI-wise — acceptable for transient state; on next successful -`currentUser()` the gates re-open. - -### 5. App Store review angle - -The simpler the iOS app's relationship to subscriptions, the lower the -review risk. The cleanest stance for review notes if asked: -> "InterlinedList iOS is a free companion app to a web service. Some -> advanced features are unlocked for users who have an active -> subscription managed entirely on the web at interlinedlist.com. The -> iOS app does not sell, offer, or link to any subscription, and does -> not display pricing or sign-up flows." - -That stance is **trivially true** under the new direction — there's -literally nothing subscription-related in the iOS bundle to review. - -## What this means for the immediate roadmap - -### Phase 1 (already shipped) - -- Most of Phase 1 is fine. **One change**: `ListsView` paywall message - needs to be removed and the "New Folder" button gated. Small follow-up. -- `User.customerStatus` decoding stays. `isSubscriber` predicate stays. -- The 403 paywall test in `APIClientListFolderTests` stays (verifies - network-layer error decoding). - -### Phase 2 (auth surface — about to dispatch) - -- No changes. Auth flows aren't subscription-aware. - -### Phase 3 (profile / account management) - -- **Drop the "Subscriber CTA on profile" item.** -- Keep: avatar upload, orgs lookup, delete account. -- Net result: Phase 3 becomes even smaller than originally planned. - -### Phase 4 (compose parity) - -- All subscriber-only controls (cross-post, scheduling, image/video - pickers) **hide for free users** instead of disable+paywall. -- Remove the "show paywall message style" criterion. -- Net result: simpler, fewer states to design. - -### Phase 12 (Settings) - -- Drop the "Subscription status + manage subscription" row. -- Net result: smaller Settings screen. - -### Backend gap §B1 - -- **Delete.** No `/api/subscriptions/plans` endpoint needed from - backend. Lower the backend team's pending-asks pile. - -## Resolved decisions (2026-06-24) - -1. **Degraded subscribers**: hide everything they don't have access to, - including any folders / scheduled posts / cross-post connections - they created when subscribed. Server still honors what's there - (scheduled posts fire on schedule); iOS just doesn't expose any UI - for it. Existing folders that contain lists are not shown — lists - inside them appear at root. -2. **Empty-state copy**: **strict silence**. No "available with - subscription" text anywhere. This is also the Apple-policy-aligned - stance for a free iOS app that doesn't offer IAP. -3. **Help / About links**: opening `interlinedlist.com/help/*` via - `SFSafariViewController` is fine. Help isn't commerce. -4. **Onboarding / About**: **no mention of subscriptions anywhere in - the iOS bundle** at this time. No first-launch tooltip, no About - text, nothing. - -## Suggested execution order once approved - -1. **Update roadmap docs first** (`GAP-NEXT-STEPS.md` and - `GAP-ENDPOINTS.md`) to match this direction. One commit. -2. **Phase 1 cleanup** — remove `ListsView` paywall string, hide "New - Folder" button for free users. One commit. -3. **Phase 4 prep pass** — even though we're not building Phase 4 - yet, hide the image/video pickers and the calendar button **now** - so the app stops surfacing 403s to free users. One commit. -4. **Delete the now-unreachable error branches** in `DocumentsView`, - `ComposeView`, `ScheduledMessagesView`, `AppDataStore`. One commit. -5. *Then* dispatch Phase 2 + Phase 3 (revised) as parallel sub-agents. diff --git a/the-gaps-access.md b/the-gaps-access.md new file mode 100644 index 0000000..def01c0 --- /dev/null +++ b/the-gaps-access.md @@ -0,0 +1,190 @@ +# the-gaps-access.md — Access-Control Gap Analysis + +**Scope:** Does a user only get the things they have access to based on **subscription**, **role**, or **approved/cleared status**? This reviews the iOS client (`InterlinedList/`) and cross-references the backend at `~/Codez/interlinedlist/` for the source-of-truth enforcement. + +**Date:** 2026-08-15 · **Reviewer:** automated audit (Claude) + +--- + +## 1. Executive summary + +**The backend enforces every access rule server-side** (verified — see §2). Non-subscribers, non-owners, wrong-role users, and unverified-email users all receive `403 Forbidden` from the API regardless of what the app does. **There is no unauthorized-data-exposure vulnerability here.** Defense-in-depth is intact. + +The gaps are therefore **client-side correctness / consistency / UX**, plus **one App Store-review risk**: + +- The client uses three separate access axes — **subscription** (`isSubscriber`), **role** (`OrgRole`, `WatcherRole`), and **cleared status** (`emailVerified`) — but applies them **inconsistently**. One premium surface (list-watcher management) isn't gated at all, while its exact twin (document collaborators) is. +- Entitlement state (`customerStatus`) is **cached and rarely refreshed**, so it goes stale when the subscription changes on the web (where billing lives). +- Authorization `403`s surface the **raw backend message**, and the app's intended friendly/typed 403 handling is **dead code** — a correctness bug and a potential IAP-steering issue if that text contains upsell copy. + +None of these leak data. All of them can make a paying/entitled user see the wrong thing, or make a non-entitled user tap controls that always fail. + +### Severity legend +Severity reflects **product/UX correctness and store-review risk**, not data security (all paths are server-enforced). + +| ID | Gap | Severity | +|----|-----|----------| +| G1 | List-watcher management not subscriber-gated (inconsistent with documents) | **Medium** | +| G2 | Stale subscription/entitlement state — no refresh on foreground | **Medium** | +| G3 | Authorization 403s surface raw backend text; friendly/typed handling is dead code | **Medium** (store-review) | +| G4 | Client can't distinguish owned vs shared lists (`UserList` has no owner/role field) | **Low** (latent) | +| G5 | Free owners over-restricted from removing document collaborators | **Low** | +| G6 | Email-verification gating coverage is uneven (media upload not pre-gated) | **Low** | +| G7 | `WatchersListView` 401 handling deviates from the app's 401 contract | **Low** | + +--- + +## 2. The access-control model (as built) + +### Three axes + one clearance flag + +| Axis | Source of truth (client) | Where derived | Backend enforcement | +|------|--------------------------|---------------|---------------------| +| **Subscription** | `User.customerStatus` → `User.isSubscriber` (prefix `"subscriber"`) — `Models/User.swift:37` | `GET /api/user` | `isSubscriber(customerStatus)` guard → `403` on sharing, folders, media, cross-post, scheduling, GitHub-backed lists | +| **Org role** | `Organization.userRole` → `OrgRole` (member<admin<owner) — `Models/Organization.swift:9-31` | `GET /api/organizations/...` | `hasPermission(role, required)` hierarchy → `403` | +| **Sharing role** | `WatcherRole` (watcher<collaborator<manager) — `Models/ListWatcher.swift:10-49` | watcher/collaborator endpoints | `getListAccess()` tiers → `403`; watcher-list endpoints are owner-only | +| **Cleared status** | `User.emailVerified` | `GET /api/user` | `!user.emailVerified` → `403` on posting + media upload; follow-request approval; email-invite claim requires matching verified email | + +### Backend enforcement — confirmed (source of truth) + +Verified in the backend repo. Representative guards: + +- `lib/subscription/is-subscriber.ts` — `customerStatus === 'subscriber' || startsWith('subscriber:')`. +- Subscriber-gated `→403`: `POST /api/lists` (create), `POST /api/{lists,documents}/[id]/share-links`, `POST /api/lists/[id]/watchers` (named user), `POST /api/documents/[id]/collaborators`, `PUT .../watchers/[userId]`, `PUT .../collaborators/[userId]`, `POST /api/folders`, `POST /api/documents/folders`, `POST /api/messages/{images,videos}/upload`, and `POST /api/messages` when it carries cross-post/images/video/schedule. +- Role-tiered `→403`: read = watcher, row writes = collaborator, schema/metadata edits = manager; watcher/collaborator **list & role-change** endpoints are **owner-only**. +- Email-verified `→403`: `POST /api/messages`, media upload, list-invite claim. +- Follow/private: pending→accepted request model; blocking prevents follow/push/reply; private orgs require admin invite. + +**Conclusion:** The server correctly rejects any request that the client's UI gating would have prevented. Everything below is about the **client** presenting the wrong affordances. + +--- + +## 3. Gaps + +### G1 — List-watcher management is not subscriber-gated (Medium) + +**The app gates the identical feature two different ways.** Document collaborators, share-links, and share-invites all hide their *create* controls for non-subscribers: + +- `Views/DocumentCollaboratorsView.swift:23` — `canManage = authState.user?.isSubscriber == true` (hides Add + role menu + remove). +- `Views/ShareLinksSheet.swift:29` — `canCreate = isSubscriber` (hides "Create link"). +- `Views/ShareInvitesSheet.swift:33` — `canInvite = isSubscriber` (hides "Send invite"). + +But **`Views/WatchersListView.swift` has no subscriber gate at all.** The "Add watcher" toolbar button (`:45-48`) is always shown, and the role-change `Menu` (`:142-161`) is always active. The backend requires a subscription for `POST /api/lists/[id]/watchers` (named user) and `PUT /api/lists/[id]/watchers/[userId]`. + +**Impact:** A non-subscribing **owner** of a list (the Lists tab shows only owned lists — see §4) sees a fully functional-looking watcher manager. Every "add" (`WatchersListView.swift:280-291`) and every role change (`:101-112`) returns `403`, and the error path surfaces the **raw backend message** (`self.error = msg`). Confusing, and off-brand versus the rest of the app which hides-not-paywalls. + +**Fix:** Mirror `DocumentCollaboratorsView`: introduce `canManage = authState.user?.isSubscriber == true`, gate the toolbar "Add watcher" button and the role-change menu on it. Viewing/removing existing watchers can stay available to any owner (the GET and DELETE are not subscriber-gated server-side). + +--- + +### G2 — Stale subscription/entitlement state; no foreground refresh (Medium) + +`isSubscriber` is derived from `User.customerStatus`, which is cached in `AuthState.user` and only re-fetched on: +- launch/login/OAuth (`Services/AuthState.swift:33,52,76`), +- `handleUnauthorized()` re-validation (`:126`), +- `refreshUser()` — which is **only called from the email-verify deep-link handlers** (`InterlinedListApp.swift:93,105`). + +There is **no `scenePhase == .active` refresh** and no refresh before presenting premium UI. Per `CLAUDE.md`, subscriptions/sharing are managed on the **web** (iOS ships as a free app with no IAP). + +**Impact:** +- **Cancel/refund/expiry on web** → the app keeps `isSubscriber == true`, keeps showing premium controls, and every premium action `403`s (into the raw-text path of G3) until the next relaunch/login. +- **Subscribe on web while the app is backgrounded** → the app keeps `isSubscriber == false` and keeps premium features hidden — the user paid but can't see what they bought until relaunch. + +**Fix:** Refresh the user on foreground (`.onChange(of: scenePhase)` → `.active` → `authState.refreshUser()`), and immediately after returning from any web billing/settings link. Cheap `GET /api/user`; keeps entitlement fresh. + +--- + +### G3 — Authorization 403s surface raw backend text; typed handling is dead code (Medium, store-review) + +`checkResponse` maps **any 4xx that has a JSON `{"error": …}` body** to `APIError.server(msg)` — *not* to `APIError.status(403)`: + +```swift +// Services/APIClient.swift:1748-1758 +if http.statusCode == 401 { throw APIError.status(401) } +if http.statusCode >= 400 { + if let err = try? decoder.decode(ErrorResponse.self, from: data) { throw APIError.server(err.error) } + throw APIError.status(http.statusCode) +} +``` + +The backend's `forbidden(msg)` returns `{"error": msg}`, so **403s arrive as `.server(msg)`**. Every `catch APIError.status(403)` in the app is therefore effectively **unreachable**, and the raw backend string is shown instead: + +- `Views/ComposeView.swift:719-722` — `.server(message)` catch fires first (shows raw text); the friendly `catch APIError.status(403) { "You may need to verify your email…" }` never runs. +- `Views/ShareInvitesSheet.swift:167-171` — the `catch APIError.status(403)` neutral-message branch is dead; a body-bearing 403 falls to the generic catch → "Could not send this invite." (harmless here, but the intent is broken). +- `Views/WatchersListView.swift`, `Views/DocumentCollaboratorsView.swift`, `Views/CreateListView.swift` — the `.server(msg)` path shows the backend string verbatim. + +**Impact:** +1. **Correctness:** intended friendly copy never appears; users see server phrasing. +2. **App Store Review risk (Guideline 3.1.1):** if any subscriber-gate 403 message contains upsell/"Subscribe" copy, that external-purchase steering is rendered inside the app. The team already worried about this (see the deliberate comments at `ComposeView.swift:60-63` and `uploadVideo` at `:663-666`), but the raw-text path defeats it wherever a stale-entitlement 403 slips through (G2). + +**Fix:** Add a typed forbidden case (e.g. map `403` to `APIError.forbidden(msg)` in `checkResponse`, distinct from generic `.server`). Then views can convert authorization failures to **neutral in-app copy** and never surface raw upsell text. At minimum, stop relying on the currently-dead `.status(403)` branches. + +--- + +### G4 — Client can't distinguish owned vs shared lists (Low, latent) + +`UserList` (`Models/List.swift:122-171`) carries **no `userId` / owner / role field**. `ListDetailView`'s toolbar shows **Manage watchers / Share / Invite** unconditionally (`Views/ListsView.swift:492-515`) with no ownership gate — it *can't* gate, because the model has no ownership signal. + +**Currently masked:** `GET /api/lists` returns **owner-only** lists (verified: `getUserLists` filters by `userId` with no watcher/`OR` clause), so the Lists tab never contains shared-in lists and the toolbar is only ever seen by owners. **No live defect today.** + +**Why it's still a gap:** it's a latent inconsistency. If the list index is ever broadened to include shared/watched lists (the self-watch-public-lists feature makes this plausible), non-owners would suddenly see owner-only management controls that `403` (`GET /api/lists/[id]/watchers` is owner-only). The org side already avoids this by carrying the role in the model. + +**Fix:** Add an owner/role field to `UserList` (e.g. `myRole: WatcherRole?` or `isOwner: Bool`) and gate the management toolbar on it — even though it's harmless today, it future-proofs the surface and lets you drop the "presented from a list the user owns" assumption documented at `WatchersListView.swift:8-10`. + +--- + +### G5 — Free owners over-restricted from removing document collaborators (Low, inverse gap) + +`DocumentCollaboratorsView.canManage = isSubscriber` (`:23`) hides **Remove** (`:82-90`) as well as Add/role-change. But the backend only subscriber-gates *add* and *role-change* — an **owner can remove a collaborator regardless of subscription** (DELETE is owner-only, not subscriber-gated). + +**Impact:** a lapsed/non-subscribing document owner **cannot remove a collaborator in-app** — a case of denying access the user *does* have. They'd have to use the web. + +**Fix:** Split the gate — `Remove` on `isOwner` (always, for an owned doc), `Add`/`role-change` on `isSubscriber`. + +--- + +### G6 — Email-verification gating coverage is uneven (Low) + +Cleared-status gating is applied in some places but not others: + +- **Gated:** posting (post button disabled — `ComposeView.swift:152`, footer `:154-157`); DM attachments (`DMThreadView.swift:37` — `canAttach = emailVerified`). +- **Not pre-gated:** the subscriber compose **media controls** are shown to a subscriber with **unverified** email, but `POST /api/messages/{images,videos}/upload` requires verified email → `403`. That lands in `uploadVideo`'s generic catch (`ComposeView.swift:662-670`) as a generic failure. + +**Impact:** minor — a subscriber-but-unverified user can start an image/video upload that fails late instead of the control being hidden/disabled up front. + +**Fix:** Fold `isEmailVerified` into the media entry-point gates (image/video pickers), consistent with the post button. + +--- + +### G7 — `WatchersListView` 401 handling deviates from the app's 401 contract (Low, robustness) + +`changeRole` and `remove` in `WatchersListView.swift:101-124` catch only `APIError.server` and the generic case — they **do not** catch `APIError.status(401)` and route it through `authState.handleUnauthorized()`. This violates the documented 401 contract (`CLAUDE.md`: "Don't log out on a feature-endpoint 401 — route through `handleUnauthorized()`"). Every other management view (`DocumentCollaboratorsView`, `OrganizationMembersView`, `ShareLinksSheet`, `ShareInvitesSheet`) handles it. + +**Fix:** Add `catch APIError.status(401) { authState.handleUnauthorized() }` to both methods. + +--- + +## 4. What's implemented correctly (keep / use as the pattern) + +- **Server-side enforcement is complete** (§2). No client gap here is a data-exposure vulnerability. +- **Org role management is the reference implementation.** `OrganizationMembersView.canManage(_:)` (`Views/OrganizationsView.swift:230-235`) checks `myRole >= .admin`, refuses to demote the **last owner**, and blocks admins from managing owners. The model carries the role (`Organization.userRole` → `OrgRole`), so gating is data-driven, not navigation-assumed. **This is the pattern the list/document sharing surfaces (G1, G4) should adopt.** +- **Share-link / invite creation is hidden, not paywalled**, while view/revoke stays available to any owner (`ShareLinksSheet.swift:70`, `ShareInvitesSheet.swift:82`). Correct "free-app" posture. +- **Compose subscriber features are hidden entirely** (no disabled-but-tappable controls, no IAP steering) — `ComposeView.swift:60-66,104,127`. Matches the documented iOS-free-app direction. +- **Lists tab is owner-scoped** (`GET /api/lists` returns owned-only), which is why G4 is latent rather than live. +- **Compose org-author picker is role-filtered** to owner/admin before posting-as-org (`ComposeView.swift:558-565`), matching the backend's `role in ['owner','admin']` check. +- **Public browse surfaces are read-only** (`PublicListDetailView`, `PublicDocumentsView`); access to private content is deferred to the server. + +--- + +## 5. Prioritized remediation + +| Priority | Gap | Action | Effort | +|----------|-----|--------|--------| +| 1 | **G1** | Add `isSubscriber` gate to `WatchersListView` Add button + role menu (mirror `DocumentCollaboratorsView`) | S | +| 2 | **G3** | Introduce typed `APIError.forbidden`; map subscriber/authorization 403s to neutral in-app copy; drop dead `.status(403)` branches | S–M | +| 3 | **G2** | Refresh `authState.user` on `scenePhase == .active` and after web billing links | S | +| 4 | **G5** | Split document-collaborator gate: `Remove` on owner, `Add`/role-change on subscriber | S | +| 5 | **G6** | Gate compose media pickers on `isEmailVerified` too | S | +| 6 | **G7** | Route `WatchersListView` 401s through `handleUnauthorized()` | XS | +| 7 | **G4** | Add owner/role field to `UserList`; gate `ListDetailView` management toolbar on it (future-proofing) | M | + +**Bottom line:** No user can *obtain* data or actions the backend doesn't authorize — the server gates every axis. The work here is making the client's affordances match entitlement **consistently** (G1), keep entitlement **fresh** (G2), and present authorization failures **cleanly and store-safely** (G3). diff --git a/the-gaps.md b/the-gaps.md index 59bb490..c532d33 100644 --- a/the-gaps.md +++ b/the-gaps.md @@ -1,104 +1,240 @@ # The Gaps — InterlinedList iOS ↔ interlinedlist.com -Single source of truth for **(a)** the iOS↔web feature/parity gaps the iOS team -owns, and **(b)** the backend/API work the `interlinedlist.com` team owns to -unblock mobile. Merged from the two former gap docs — the backend-asks audit and -the iOS work list — now consolidated here. - -**Prepared:** 2026-07-18 (live read-only API probes) -**Merged & updated:** 2026-07-22 - -**Sources:** -1. The shipped iOS client's `APIClient.swift` — a production HTTP client that - actually calls these endpoints. -2. The public docs at `https://interlinedlist.com/help/api/*` (all detail pages - read verbatim 2026-07-18; targeted re-read 2026-07-22 for lists). -3. **Live read-only probes** against production (2026-07-18) using the - `messenger@interlinedlist.com` test account (a **subscriber**). Probes were - GETs, `OPTIONS` (for `Allow`-header verb detection), and the login POST only — - **no data was mutated.** - -**Owner/status legend:** -**[BACKEND]** the API blocks the mobile client (Bearer rejected) · -**[DOC]** docs are wrong/incomplete · **[iOS]** the client is wrong (we fix it) · -**[VERIFY]** still needs a write-test or a non-subscriber account. -Status: **OPEN** · **RESOLVED** · **BLOCKED** (needs a backend change first). - -> Scope note: this is the **iOS** app. The web site lists an "iOS App (Coming -> Soon)" and a separate "macOS App (Coming Soon)"; no native macOS target exists -> in this repo. +Single source of truth for the iOS↔web feature/parity gaps and the plan to close +them. **This is a full rewrite (2026-07-31)** — the landscape changed materially +since the 2026-07-18/22 assessment (see [What changed](#what-changed-since-2026-07-22)). + +**Prepared:** 2026-07-31 · **Re-verified against the live API:** 2026-08-15 (see the +[re-verification callout](#re-verification--2026-08-15-live-bearer-probe--backend-source) below) +**Method (this pass):** cross-checked four sources, with the backend source as ground truth: +1. **The backend source** at `~/Codez/interlinedlist/app/api/**` (Next.js route + handlers — the authoritative contract). Auth model read directly from each + route's helper: `getCurrentUserOrSyncToken` = **Bearer OR session** (mobile-OK); + `getCurrentUser` (no sync-token) = **session-only**. Subscriber gates read from + `isSubscriber(...)` / `forbidden("Subscribe to …")` calls. +2. **The live docs** at `https://interlinedlist.com/help/api/*` (all 21 pages, + read 2026-07-31). +3. **The shipped iOS client** — `APIClient.swift` (78 endpoint methods) + the 41 + Views / 12 Models. +4. **Live read-only Bearer probes** (2026-07-31) with the `messenger` test account + (`.env`, a **subscriber**): a `POST /api/auth/sync-token` login + GETs only, no + mutations. Confirmed the source findings against production — see + [Live evidence](#live-verification-evidence-2026-07-31). **Caveat:** `messenger` + is a subscriber, so the **free-user gating path** (what a non-subscriber sees for + list/doc create, media compose) is still the one thing not observable — that + read comes from source only. + +**Legend:** ✅ at parity · ◑ partial · ❌ missing · 🔴 broken · — n/a · +🟢 backend Bearer-ready (buildable now) · ⛔ backend-blocked (session-only) · +💲 subscriber-gated write (hide for free users, never paywall) + +> **⏱ Re-verification — 2026-08-15 (live Bearer probe + backend source).** Two +> findings materially change the list below: +> 1. **A5 is DELIVERED** — `GET /api/lists` returns **`folderId`** (live-verified). +> **Superseded same day:** iOS then **removed the list-folder concept entirely** +> (product decision — lists organize via parent/child `parentId` nesting only). +> `ListFolder`, `folderId`, and the `/api/folders` client calls were deleted, so the +> delivered `folderId` field is now simply unused by iOS. See Phase 14 (reverted). +> 2. **X2 is stale — tag discovery now exists.** `/api/tags/trending` and +> `/api/tags/autocomplete` shipped on the backend (both `getCurrentUserOrSyncToken`, +> live-verified **200** with real data). iOS has **no consumer** → reclassified +> from "non-feature" to a **new buildable gap (G13)**. +> +> Also new, Bearer-ready, and unconsumed on iOS: **`GET /api/link-metadata`** (live +> composer link-preview → **G14**). Everything else below is unchanged from the +> 2026-08-14 pass. Full method notes at [Part VII](#part-vii--coverage--method-notes). --- -## Part I — The one systemic finding +## ▶ THE GAP LIST (prioritized) -**Several whole feature areas reject Bearer tokens and only accept a session -cookie.** The iOS app is **Bearer-only** (it has no cookie jar), so these -features are simply unreachable from mobile. Confirmed live (401 with a valid -Bearer) and/or stated in the API's own docs: +Every open gap, most-actionable first. IDs are used throughout the plan below. -| Area | Endpoint(s) | Bearer? | Consequence for iOS | -|---|---|---|---| -| CSV Exports | `GET /api/exports/*` | ❌ 401 (confirmed live) | Export feature is **dead** in the shipped app (D4/F1) | -| GitHub | `GET/POST/PATCH /api/github/*` | ❌ (docs: "not accepted") | GitHub-backed lists & issues **cannot be built** for iOS (§2.1/F3) | -| LinkedIn targets | `GET/PUT /api/linkedin/posting-targets` | ❌ 401 (confirmed live) | LinkedIn org/target picker **cannot be built** for iOS (§2.3/F2) | +### Tier 0 — Defects in already-shipped features (fix now; one `APIClient` PR) + +| ID | Gap | Root cause (source-verified) | Effort | +|----|-----|------------------------------|--------| +| **D1** 🔴 | Edit profile / settings / default-visibility silently fail | `updateProfile`/`updateUserSettings` send **POST** `/api/user/update`; route exports **only `PATCH`** → 405 | XS | +| **D2** 🔴 | Editing a posted message fails | `editMessage` sends **PUT** `/api/messages/:id`; route exports **`PATCH`** (GET/PATCH/DELETE, no PUT) → 405 | XS | +| **D3** 🔴 | Marking one notification read fails | `markNotificationRead` sends **PUT** `/api/notifications/:id/read`; route exports **only `PATCH`** → 405 | XS | + +### Tier 1 — New / unblocked features, Bearer-ready, high value + +| ID | Gap | Backend status | Effort | +|----|-----|----------------|--------| +| **G1** ❌ | **Direct Messages** (1:1 DMs, threads, image attach, unread badge, near-realtime) | 🟢 `/api/dm/*` (10 routes), **free** | **L** | +| **G2** ❌ | **Sharing** — tokenized share-links + document collaborators for lists & docs | 🟢 `/api/{lists,documents}/:id/share-links`, `/collaborators` · 💲 create | **M** | +| **G3** ❌ | **Document templates** — "Start from template" | 🟢 `/api/documents/templates` (free read) · 💲 `from-template` | **S** | +| **G4** ❌ | **GitHub integration** — GitHub-backed lists + issues (biggest single web feature iOS lacks) | 🟢 `/api/github/*` (**now Bearer** — was blocked) | **L** | +| **G5** ◑ | **LinkedIn posting-target picker** in the composer | 🟢 `/api/linkedin/{targets,posting-targets}` (**now Bearer** — was blocked) | **S** | +| **G6** ◑ | **People search / discovery** — find & open other users | 🟢 `/api/users/search`, `/api/users/lookup` | **S** | +| **G7** ◑ | **Muted-users management UI** (API wired, no screen) | 🟢 `/api/user/mutes` (already called) | **XS** | +| **G8** ✅◑ | **CSV Exports** — was dead (D4), backend now accepts Bearer → **verify it works** | 🟢 `/api/exports/*` (**now Bearer**) | **XS** | +| **G13** ✅ | **Tag discovery / trending** — trending-tags list + `#` autocomplete (was X2 "non-feature"; backend shipped it) | 🟢 `/api/tags/{trending,autocomplete}` (Bearer) · **✅ SHIPPED 2026-08-15** (Phase 16) | **S** | +| **G14** ✅ | **Composer live link-preview** — OG card while typing a post (read side already renders previews on *posted* messages) | 🟢 `/api/link-metadata?url=` (Bearer, rate-limited 30/60s) · **✅ SHIPPED 2026-08-15** (Phase 16) | **XS** | + +### Tier 2 — Larger systems / lower urgency + +| ID | Gap | Backend status | Effort | +|----|-----|----------------|--------| +| **G9** ❌ | **Offline document sync** (delta sync + full tree) | 🟢 `/api/documents/sync`, `/api/documents/tree` | **L** | +| **G10** ◑ | **Content deep links / Universal Links** (profiles, lists, docs, threads) | 🟢 client-side; ⛔ AASA needs a backend/hosting change | **M** | +| **G11** ❌ | **Live document presence** (collaborative cursors) | 🟢 `/api/documents/:id/presence` (heartbeat+poll) | **M** | +| **G12** ❌ | **Active-sessions management** (list & revoke logins) | 🟢 `/api/user/sessions`, `/api/user/sessions/:id` | **S** | + +### Backend-blocked or non-existent — document, do NOT build + +| ID | Item | Why it's not an iOS build | +|----|------|---------------------------| +| **X1** ⛔ | **Multi-account switching** | `/api/auth/{accounts,switch,remove-account}` are **session-cookie-only** (confirmed in source); the Bearer-only client has no cookie jar. Escalate if wanted. | +| **X2** ⚠️ | **Tag discovery / trending** | **SUPERSEDED 2026-08-15 — the backend now ships it.** `/api/tags/trending` (window day/week/month, limit ≤100) + `/api/tags/autocomplete?q=` (prefix, limit ≤50) both exist and accept Bearer (live-verified 200). No longer a non-feature → **promoted to G13**. The in-body `#hashtag` → `tag:` feed filter is the *display* side; G13 adds discovery. | +| **X3** — | **General realtime channel** | No SSE/WebSocket endpoint exists. "Realtime" is achieved by **polling**: DM `/updates` and doc `/presence`. Adopt those per-feature (G1/G11); there is nothing global to build. | -**The single highest-value thing the backend can do for mobile parity is add -Bearer-token support to these endpoints** (see Prompt A). Everything else is -smaller. +### Out of scope — web-only by design (must never ship on iOS) + +Billing/Stripe (App Store Guideline 3.1.1 — no price/upgrade/pay copy or links) · +dashboard & front-wall layout persistence · engagement stats · web widgets +(bike-share/markets/news) · admin console · `materialize` / `architecture-aggregates` +(internal) · **X4 — Generative AI BYO-keys** (OpenAI/Anthropic/Gemini keys stored +via `PATCH /api/user/update`, consumed only by the web integrations page / +`architecture-aggregates`; **no iOS-side consumer**, so defer). These are correct +absences, not gaps. + +--- + +## Progress log (execution) + +Live implementation status — updated as work lands on `dev` (uncommitted unless noted). + +| Phase | Items | Status | +|---|---|---| +| **1 — correctness + quick wins** | **D1/D2/D3** verb fixes (POST/PUT→PATCH, +tests) · **G7** Muted-users screen (`MutedUsersView`, linked from Settings) · **G8** exports un-hidden + `list-data-rows` 4th type | ✅ **Done** 2026-07-31 — build green, 68 affected tests pass; also fixed 3 pre-existing stale doc-image/`folderId` tests to match backend (`file`, `folderId`) | +| **2 — Direct Messages (G1)** | `DirectMessage` models + 11 `APIClient` methods + `AppDataStore.dmUnreadCount` + envelope-badge in `MainTabView` + `MessagesInboxView` + `DMThreadView` (near-realtime `/updates` poll, image attach, block/not-mutual states) + profile "Message" button | ✅ **Done** 2026-07-31 — build green; 32 DM tests pass; ios-review fixed a duplicate-`navigationDestination` bug | +| **3 — small additions** | **G3** document templates ("Start from template", subscriber-gated, `documentTemplates()` + `createDocumentFromTemplate`) · **G6** people search (`searchUsers` + `FindPeopleView`, linked from Profile › Social) | ✅ **Done** 2026-07-31 — build green; template + search + model tests pass | +| **4 — LinkedIn target picker (G5)** | Rewrote `LinkedInTarget` to the real union `{kind,pageId?,personalPageId?}` (dropped wrong `organizationId`); `LinkedInPostingTarget` + `linkedInPostingTargets()`; multi-select in `ComposeView` (subscriber + LinkedIn identity) mapping into `linkedInTargets` on post, personal-fallback on fetch failure | ✅ **Done** 2026-07-31 — build green; 18 new tests pass | +| **5 — Sharing (G2)** | `ShareLink` + reusable `ShareLinksSheet` (create/copy/revoke, roles) for lists & documents; `DocumentCollaborator` + `DocumentCollaboratorsView` (reused `WatcherRole`); 8 `APIClient` methods wired into list + document detail | ✅ **Done** 2026-07-31 — build green; 26 sharing tests + full suite (604) pass, no regressions | +| **6 — GitHub-backed lists (G4)** | `UserList`+`source`/`githubRepo`/`githubMeta`; `GitHubRepo`/`GitHubIssue`; `githubRepos()`/`githubIssues()`/`refreshList()`/`createList(githubSource:)`; repo picker in `CreateListView` (gated on `AuthState.hasGitHubIdentity` + subscriber) + Refresh/meta on list detail | ✅ **Done** 2026-07-31 — build green; 50 tests pass. Residual: in-app GitHub *linking* still blocked (backend ask **A1**), so path verified via unit tests, not live | +| **7 — Active sessions (G12)** | `UserSession` + `userSessions()`/`revokeSession()`; `SessionsView` ("Where you're signed in") in Settings | ✅ **Done** 2026-07-31 — build green; 9 tests pass | +| **8 — Offline doc sync, Slice 1 (G9)** | `documentSync(lastSyncAt:)` delta pull + pure `DocumentSyncMerge` (upsert/tombstone) + `SyncCache` + flag-gated `AppDataStore` path (`ILOfflineDocSync`, default on); online path untouched when off. **Read-only slice** — write/push (Slice 2) + conflict-copy (Slice 3) deferred | ✅ **Done** 2026-07-31 — build green; 649-test suite passes. *(Standalone `Offline-Document-Sync-Design.md` since removed; the slice notes here + the shipped `DocumentSync*` sources are the record.)* | +| **9 — Deep links + share actions (G10)** | `ILWebURL` canonical permalinks; pure `AppDeepLink.parse` (custom-scheme + `https://interlinedlist.com`); `message(id:)` + `MessageLinkView`; "Share link" on message/profile/list/document; auth links preserved | ✅ **Done** 2026-07-31 — build green; 681-test suite passes. Inbound routing for profiles+messages; **Universal Links (https→app) still need backend AASA (ask A2)**; list/doc inbound + shared-token resolution are follow-ons | +| **10 — Offline doc sync, Slice 2 (G9)** | `SyncOperation`/outbox + coalescing (`DocumentSyncOutbox`), `pushDocumentSync` (→ `{lastSyncAt}`, 429→`rateLimited`), optimistic writes + push-then-pull `syncCycle`, `NetworkReachability` reconnect + foreground/refresh/debounced triggers, flag-branched `DocumentsView` w/ pending-sync glyph. **LWW** (per-op errors are swallowed server-side → push-then-pull reconcile); flag-off online path unchanged | ✅ **Done** 2026-08-02 — build green; 725-test suite passes (+33). **Conflict-copy is Slice 3** (seam marked `// TODO(slice 3)`) | +| **11 — Offline doc sync, Slice 3 (G9)** | **Conflict-copy** — `DocumentSyncConflict` (dirty id whose server `updatedAt` beats baseline → keep local live + preserve server version as "(conflicted copy <date>)"), `DocumentSyncMerge.apply(protectingIds:)`, **cycle reordered to pull-first-then-push** (see server's version before a local push clobbers it), baselines in `DocumentSyncState`, dismissible banner in `DocumentsView` | ✅ **Done** 2026-08-02 — build green; 747-test suite passes (+22). **G9 COMPLETE (slices 1–3).** Offline writes are no longer LWW → the default-on `ILOfflineDocSync` flag is safe; PR #13 mergeable | +| **12 — editor UX** | Markdown format toolbar (H1–H6 dropdown, icon Write/Preview) in `MarkdownEditor` | ✅ **Done** 2026-08-13 (`40b5850`) — additive; not a tracked gap | +| **13 — email share-invites (extends G2)** | `ShareInvite` model + `ShareInvitesSheet` (email-invite lists & documents by role) + watcher search | ✅ **Done** 2026-08-13 (`2ae9e20`) | +| **14 — lists into folders** | ~~`folderId` on create/update list, folder picker + move-to-folder + `RenameFolderView`; split `parentId` vs `folderId`~~ | ↩️ **Reverted 2026-08-15** — the list-folder concept was removed by product decision. Lists organize only via parent/child nesting (`parentId`); `ListFolder`, `folderId`, and the `/api/folders` client calls are gone. `ListTreeNode.buildTree(lists:)` now nests purely on `parentId` | +| **15 — API re-verification (this pass)** | Live Bearer re-probe + backend-source re-read (2026-08-15): confirmed **A5 shipped** (`folderId` in list GET), surfaced **G13** (tags trending/autocomplete now exist — was X2) and **G14** (`/api/link-metadata` composer preview). No code changes — doc-only update | ✅ **Done** 2026-08-15 | +| **16 — G13/G14 + G10 doc deep links (this session)** | **G13**: `TrendingTag`/`TagSuggestion` + `trendingTags()`/`tagAutocomplete()`; server-wide **Trending** strip & `#`-autocomplete wired into `FeedView`'s existing `tagFilter`. **G14**: `linkMetadata(url:)` + live OG preview card in `ComposeView` (`NSDataDetector` URL detection, 400 ms debounce, 429/`failed`→no card). **G10 (documents)**: `.document(id:)` + `.sharedDocument(token:)` deep links → `DocumentLinkView` / read-only `SharedDocumentView` + `resolveSharedDocument()`; parser routes `/documents/:id` and `/documents/shared/:token` (custom-scheme + https) | ✅ **Done** 2026-08-15 — app builds green; **787-test unit suite passes** (+31 new: 12 tags, 9 link-metadata, 6 resolver, +4 parse) | +| — | **iOS-only tail of A1/A2** — merge staged `feat/github-oauth-universal-links` (1 commit ahead of `dev`: flips GitHub `supportsNativeAuth→true` + Associated Domains entitlement); enable the Apple-portal Associated Domains capability & regen profile · **G10 list follow-ons** *(list permalink inbound is **backend-limited** — a bare `/lists/:id` carries no owner username the list-detail endpoint requires; list share-token resolve deferred behind the active lists refactor)* · **G11 presence** *(optional)* | ⏳ remaining | + +**Whole-tree gate (2026-07-31, after the fix pass):** full unit suite **694 tests, +0 failures** (E2E excluded). All work builds and passes together; Phases 1–9 +committed as clean per-feature commits (`APIClient.swift` hunk-split per phase; +`project.pbxproj` registration consolidated in one `build:` commit), G9 Slice 1 and +G10 each their own commit, plus a fix commit. + +**Interactive smoke-test (2026-07-31):** logged in as `@messenger` (subscriber) and +exercised all new surfaces. **6/7 PASS** (DM inbox, people search, sessions, muted +users, share actions, GitHub-list gating, document sharing — no crashes). Two real +defects found and **fixed**: **D-1** DM new-message recipient tap didn't open the +thread (fragile `onChange`-after-dismiss nav → now `navigationDestination(item:)` + +full-width tappable row, matching the verified-good `FindPeopleView` pattern); +**D-2** (pre-existing) `FollowStatus` decode mismatch — API returns +`{status,isFollowing,isPending}`, client wanted `{following,followedBy,pendingRequest}` +→ remapped defensively (also fixed the latent nested `follow.status` shape on +`POST /api/follow/:id`). Plus a resource-aware share-copy nit. **D-1's literal +runtime tap wasn't re-driven** — the UI-automation tooling wasn't available in the +re-check session — but the fix mirrors the interactively-confirmed people-search +flow and is unit-/build-verified; a manual tap-test is the one residual check. + +**Shipped this session (parity closed):** defects **D1/D2/D3** · **G1** Direct +Messages · **G2** Sharing · **G3** Templates · **G4** GitHub-backed lists · **G5** +LinkedIn target picker · **G6** People search · **G7** Muted-users UI · **G8** +Exports (verified live) · **G12** Active sessions. **Remaining:** G9 (needs design), +G10 (needs backend AASA), G11 (optional), + backend asks **A1** (GitHub mobile +linking) and **A2** (Universal-Links AASA). + +**Note on sequencing:** `APIClient.swift` (one 69 KB class) is the shared bottleneck — +every remaining feature appends methods to it, so feature agents are **serialized** +(one cohesive feature per agent, build verified between each) rather than run in +parallel, to avoid clobbering that file. --- -## Part II — iOS work (what we build / fix) - -### II.0 — Confirmed defects to fix first (found via live probe) - -Each is a **client bug** (or a backend-auth blocker) in already-shipped -functionality. Backend-side detail is in Part III (F-items). **Status column -re-verified against `APIClient.swift` on 2026-07-22 — all still OPEN.** - -| # | Symptom | Root cause | Fix | Status | -|---|---|---|---|---| -| D1 (↔F8) | Editing profile / theme / default-visibility / avatar-from-URL silently fails | `POST /api/user/update`; server allows **only `PATCH`** (405), and expects camelCase | Switch the three `/api/user/update` calls to `patchCamel` | **OPEN** — still `post` (`APIClient.swift:229,789,804`) | -| D2 (↔F9) | Editing a posted message fails | `editMessage` uses `PUT /api/messages/:id`; server allows **`PATCH`, not `PUT`** (405) | Switch `editMessage` to `patchCamel` (`{content, publiclyVisible}`) | **OPEN** — still `put` (`APIClient.swift:358`) | -| D3 (↔F10) | Marking a notification read fails | `markNotificationRead` uses `PUT /api/notifications/:id/read`; server allows **only `PATCH`** (405) | Switch to a `PATCH` request (empty body) | **OPEN** — still `put` (`APIClient.swift:726`) | -| D4 (↔F1/F11) | CSV export always fails | `exportCSV` sends Bearer; `/api/exports/*` is **session-cookie-only** (401) | Backend-blocked (Prompt A). Until then, hide the Export UI | **OPEN/BLOCKED** — still Bearer (`APIClient.swift:831`) | -| D5 (↔F12) | Push may never arrive | `registerPushDevice` sends body `{token}`; docs specify `{deviceToken}` | Confirm handler field; likely rename `token` → `deviceToken` | **OPEN/VERIFY** — sends `token` (`APIClient.swift:1167–1170`) | - -**Prompt for Claude — fix D1/D2/D3 (verb + casing):** -> In `InterlinedList/Services/APIClient.swift`, three writes use the wrong HTTP -> method (confirmed against production via `OPTIONS` Allow headers): -> 1. `updateProfile`, `updateUserSettings`, and `applyAvatarUrl` all call -> `post("/api/user/update", …)`. The server allows **only `PATCH`** and expects -> a **camelCase** body. Change them to `patchCamel`. Keep the `{user?}`-unwrap. -> 2. `editMessage` calls `put("/api/messages/:id", …)`; the server allows `PATCH` -> not `PUT`. Change it to `patchCamel` with `{ content, publiclyVisible }`. -> 3. `markNotificationRead` calls `put("/api/notifications/:id/read", …)`; the -> server allows only `PATCH`. Change it to a `PATCH` request (empty body). -> Update the `MockURLSession` unit tests to assert `PATCH` and the camelCase body -> keys. Do NOT change `patchScheduledMessage` (already correct). Then smoke-test -> in the simulator: edit profile, edit a message, mark a notification read. - -**Prompt for Claude — D4 (exports) + D5 (push):** -> 1. Exports: `exportCSV` sends a Bearer token, but `/api/exports/*` rejects Bearer -> (401, confirmed live) — session-cookie-only. Until the backend adds Bearer -> support (Prompt A / F1), **hide the Export entry point** and leave a -> `// TODO: re-enable when /api/exports accepts Bearer` note. -> 2. Push: `registerPushDevice`/`unregisterPushDevice` send `{ token }`; the docs -> specify `{ deviceToken }`. Confirm the handler field (ask backend or -> write-test), rename the client field to `deviceToken` if needed, and add a -> unit test asserting the body key. This is a silent-failure path — prioritize. - -### II.1 — Conventions every iOS prompt assumes (from `CLAUDE.md`) +## What changed since 2026-07-22 + +The prior doc's headline finding — *"exports, GitHub, and LinkedIn reject Bearer, +so they're unreachable from mobile"* — is **fully resolved**. The backend team +shipped the old "Prompt A." Source-verified today: every one of those areas now +authenticates through `getCurrentUserOrSyncToken` (Bearer or session). + +| Old item | Then | Now (source-verified 2026-07-31) | +|----------|------|-----------------------------------| +| D4 / F1 — CSV exports reject Bearer | 🔴 dead | ✅ **Bearer accepted** — all 4 `/api/exports/*` use `getCurrentUserOrSyncToken`. iOS already sends Bearer → **should work**; just verify (G8). | +| §2.1 / F3 — GitHub rejects Bearer | ⛔ blocked | 🟢 **Bearer accepted** — `getGitHubIssuesContext` resolves the user via `getCurrentUserOrSyncToken`. Now buildable (G4). | +| §2.3 / F2 — LinkedIn targets reject Bearer | ⛔ blocked | 🟢 **Bearer accepted** — `/api/linkedin/{targets,posting-targets,sync-pages}`. Now buildable (G5). | +| D5 / F12 — push field `token` vs `deviceToken` | ⚠️ verify | ✅ **Non-issue** — docs + handler use `token`; iOS already sends `token`. Closed. | +| F5 — no Moderation docs | ❌ missing | ✅ `/help/api/moderation` now exists; endpoints Bearer-accepted; iOS already ships block/mute/report. At parity. | +| §2.9 — weather/geo as list column types? | ❓ | ✅ **No** — `/api/weather`, `/api/location`, `/api/widgets/*` are backend data helpers, not list column types. Not a lists gap. | +| F13 — is document create subscriber-only? | ⚠️ verify | ✅ **Confirmed subscriber-only** — `POST /api/documents` → `forbidden("Subscribe to create documents.")`. Hide create for free users (💲). | +| §2.6 — do multi-account routes take Bearer? | ❓ | ⛔ **No** — session-only (X1). Reclassified from "investigate" to backend-blocked. | +| §2.2 — probe for a trending endpoint | ❓ | ~~None exists (X2)~~ → **Now exists (2026-08-15):** `/api/tags/{trending,autocomplete}` shipped, Bearer-accepted, live-verified 200. Promoted to **G13**. | + +And three genuinely **new** feature areas shipped on the backend since the last +pass, all Bearer-ready: **Direct Messages** (G1), **Sharing/share-links** (G2), +and **document collaborators / templates / sync / presence** (G2/G3/G9/G11). + +**Net:** the parity story flipped from *"blocked on the backend"* to *"a stack of +self-contained iOS builds we can do today."* Three tiny verb fixes are the only +regressions left in shipped code. + +--- + +## Live-verification evidence (2026-07-31) + +Read-only Bearer probes with `messenger` (subscriber) against production. Every +new/unblocked area returned exactly what the source predicted — a **401 would mean +Bearer rejected**; none did. + +| Gap | Endpoint (GET) | Live result | Reading | +|---|---|---|---| +| **G8** | `/api/exports/{messages,lists,list-data-rows,follows}` | **200** — real CSV rows | Exports **work over Bearer** (old D4 blocker gone) | +| **G1** | `/api/dm` · `/api/dm/unread-count` · `/api/dm/recipients` | **200** `{items:[]}` · `{count:0}` · `{recipients:[…]}` | DMs live; `/recipients` returns the mutual-follow set | +| **G1** | `/api/dm/thread/adron` | **200** `{items:[], olderCursor, isMutual:true, isBlocked:false, otherUser:{…}}` | Exact thread shape confirmed | +| **G3** | `/api/documents/templates` | **200** `{folderCreated, templatesFolderId, templates:[…]}` | Real templates returned | +| **G9** | `/api/documents/sync` | **200** `{folders, documents, lastSyncAt}`; folder rows carry `deletedAt` | Real delta-sync contract (`lastSyncAt` cursor + tombstones) | +| **G9** | `/api/documents/tree` | **200** nested `{folders:[{…, documents:[…]}]}` | One-call hierarchy | +| **G5** | `/api/linkedin/{posting-targets,targets}` | **200** `{targets:[{kind:"personal", label, avatarUrl, …}]}` | LinkedIn targets **work over Bearer** (old F2 blocker gone) | +| **G4** | `/api/github/repos` | **400** `"GitHub account not linked"` (NOT 401) | Bearer **auth passes**; only identity-linking remains (ask A1) | +| **G6** | `/api/users/search?q=…` | **200** `{users:[…]}` | People search works (note: `/users/lookup` wants a different param than `username=`) | +| **G12** | `/api/user/sessions` | **200** `{sessions:[{deviceLabel:"CLI", isCurrent, lastUsedAt, …}]}` | Session list/revoke live | +| **G2** | `/api/lists/:id/share-links` · `/api/documents/:id/{share-links,collaborators}` | **200** `{shareLinks:[]}` · `{collaborators:[], pagination}` | Owner reads work over Bearer | + +**Two things surfaced by the live `GET /api/user` that the source scan hadn't:** +- **`githubDefaultRepo`** — a user setting (validated `owner/repo`) for the default + repo of GitHub-backed lists. Belongs with **G4**; expose it in the GitHub-list UI. +- **`hasOpenaiApiKey` / `hasAnthropicApiKey` / `hasGeminiApiKey`** — a **"Generative + AI" BYO-key** feature (web `Settings › GenerativeAISection`, keys set via + `PATCH /api/user/update`). The keys are consumed **only** by the web + `integrations` page and the internal `architecture-aggregates` tooling — there is + **no generative endpoint in the core app**. So it's a **web-only integrations + feature with no iOS-side consumer** → **out of scope** (X4), not a parity gap. iOS + could add key-entry fields cheaply once the D1 `PATCH /api/user/update` fix lands, + but nothing on iOS would use them, so defer. + +--- + +## Part I — Conventions every iOS prompt assumes (from `CLAUDE.md`) - **Encoders:** `get` / `postCamel` / `putCamel` / `patchCamel` for camelCase bodies (most endpoints); `post`/`put`/`patch` for snake_case. Check the existing method before adding one — mismatches fail **silently** server-side. - **401 contract:** never log out on a feature-endpoint 401; route through `authState.handleUnauthorized()` (re-validates against `GET /api/user`). -- **Subscriber gating = hide, never disable/paywall.** Gate on +- **Subscriber gating = HIDE, never disable/paywall.** Gate on `authState.user?.isSubscriber == true`. **No** billing/upgrade/price copy and - **no** link to interlinedlist.com to pay (App Store Guideline 3.1.1). + **no** link to interlinedlist.com to pay (Guideline 3.1.1). When a write 💲-gate + fires anyway (403 `"Subscribe to …"`), fail gracefully — don't surface the raw + server string as a paywall. - **Standards:** no comments unless the "why" is non-obvious; no force-unwrap; `@MainActor` over `DispatchQueue.main.async`; `.accessibilityLabel` on every control; a `#Preview` in every View file. @@ -106,456 +242,472 @@ re-verified against `APIClient.swift` on 2026-07-22 — all still OPEN.** the `xcodeproj` Ruby gem. - **Tests:** add `MockURLSession` unit tests for every new/changed `APIClient` method (assert method, path, encoder casing, decode of happy + error paths). +- **Public-browse namespace split is real and load-bearing** — do NOT normalize: + messages are `/api/user/:username/messages` (singular `user`); lists & documents + are `/api/users/:username/…` (plural). The wrong one 404s. -### II.2 — Parity matrix +--- -Legend: ✅ at parity · ◑ partial · ❌ missing · 🔴 broken · — n/a +## Part II — Confirmed 💲 subscriber-gated writes (gating map) + +Same backend for web and iOS, so these aren't parity *gaps* — but every new/edited +iOS write below must respect them (hide the affordance for non-subscribers). Read +directly from source: + +| Write | Gate | +|-------|------| +| `POST /api/messages` (plain text) | **Free** ✅ | +| `POST /api/messages` **with images / video / cross-post / schedule** | 💲 `"Subscribe to unlock images, video, cross-posting, and scheduled posts."` | +| `POST /api/lists` (create list) | 💲 `"Subscribe to create lists."` | +| `POST /api/documents` (create doc) | 💲 `"Subscribe to create documents."` | +| `POST /api/documents/from-template`, `…/templates/seed-defaults` | 💲 | +| `POST /api/{lists,documents}/:id/share-links` (create link) | 💲 | +| `POST/PUT /api/documents/:id/collaborators*`, `POST/PUT /api/lists/:id/watchers*` | 💲 | +| `POST /api/organizations` (create org) | 💲 | +| Revoke share-link (`DELETE …/share-links/:token`), DM send, templates read | **Free** ✅ | + +> **Action item (gating audit):** verify the shipped composer already hides +> image/video/cross-post/schedule affordances for non-subscribers, and that +> `CreateListView` / document-create are hidden for free users. If not, that's a +> Guideline 3.1.1 risk to fix alongside Tier 0. + +--- + +## Part III — Parity matrix (current) | Web capability | iOS | Notes | |---|---|---| -| Email/password + OAuth (Mastodon, Bluesky, LinkedIn, Twitter) | ✅ | GitHub sign-in intentionally hidden | -| Feed, link previews, dig, reply, delete, search | ✅ | | -| **Edit a posted message** | 🔴 | D2 — uses unsupported `PUT` (405) | -| Compose: image/video, scheduled, cross-post, repost, post-as-org | ✅ | multi-image (≤8) + drag-reorder + normalization shipped; post-as-org = Phase 15 | -| **Edit profile / settings / avatar-from-URL** | 🔴 | D1 — uses unsupported `POST` (405) | -| **Mark notification read** | 🔴 | D3 — uses unsupported `PUT` (405) | -| Lists: CRUD, folders, schema editor, rows, connections, watchers | ✅ | create-list schema contract fixed 2026-07-22 (F15) | -| Documents: CRUD, folders, search, inline images, public reader | ✅ | inline images = Phase 10; free-user create gate unverified (§2.9/F13) | -| Follow graph, organizations, notifications (tray/prefs), moderation, push | ✅ | push field name unverified (D5) | -| **CSV exports** | 🔴 | D4 — `/api/exports/*` rejects Bearer (session-only) | -| **GitHub-backed lists / GitHub issues** | ❌ | §2.1 — backend-blocked (Bearer rejected, confirmed) | -| **Tag discovery / trending** | ◑ | §2.2 — can filter by tag; no discovery UI; endpoint unconfirmed | -| **LinkedIn org/target picker** | ◑ | §2.3 — backend-blocked (targets are session-only, confirmed) | -| **Document templates** | ❌ | §2.4 — endpoint confirmed live over Bearer; ready to build | -| **Content deep links / universal links** | ❌ | §2.5 — only auth callbacks routed | -| **Multi-account switching** | ❌ | §2.6 — web has `/api/auth/accounts`,`/switch`,`/remove-account`; iOS single-account | -| **Realtime updates** | ❌ | §2.7 — no realtime endpoint found; poll/refresh only | -| **Offline document sync** | ❌ | §2.8 — `/api/documents/sync` exists & is Bearer-accessible (confirmed); buildable | -| Utility widgets (weather/geolocation) | ❓ | §2.9 — confirm if user-facing list column types | -| Admin console | — | not an app feature | -| Subscription/billing UI | — | web-only by design; must never appear on iOS | - -### II.3 — The gaps, prioritized - -#### Tier A — Ready to build now (backend confirmed working over Bearer) - -**§2.4 Document templates** `Small` ✅ backend-ready. -Confirmed live: `GET /api/documents/templates` → `{ folderCreated, -templatesFolderId, templates:[{id,title,…}] }` over Bearer (200). Web also has -`POST /api/documents/from-template` (subscriber-only) and -`POST /api/documents/templates/seed-defaults`. iOS status: ❌ new docs start blank. -> Add `APIClient.documentTemplates()` returning `[DocumentTemplate]` (inspect the -> live response for exact keys). In the create-document flow in `DocumentsView`, -> add an optional "Start from template" picker that prefills title/content. If you -> wire `POST /api/documents/from-template`, gate on `isSubscriber` and hide -> otherwise. Add `MockURLSession` tests and a `#Preview`. - -**§2.5 Content deep links / universal links** `Small–Medium`. -`InterlinedListApp.handleDeepLink` only routes `reset-password`, `verify-email`, -`verify-email-change`, `oauth`. No content permalinks, no Universal Links. -> Extend `AppRouter` + `InterlinedListApp.handleDeepLink` to route content -> permalinks: user profiles, public lists -> (`/api/users/:username/lists/:id` → `PublicListDetailView`), public documents, -> single message threads. Support both `interlinedlist://…` and -> `https://interlinedlist.com/…` via `.onOpenURL`. Mind the confirmed -> public-browse namespace split: messages are `/api/user/:username/messages` -> (singular) while lists/documents are `/api/users/:username/…` (plural) — the -> wrong one 404s. Universal Links also need the backend to publish -> `apple-app-site-association` + the Associated Domains entitlement (**record that -> dependency in this doc, Part III**); the custom-scheme path works without it. -> Add a share action (web permalink) on message/list/profile. - -**§2.8 Offline document sync** `Large` ✅ backend-ready (contract exists). -Confirmed live: `GET /api/documents/sync` → `{ folders:[…], … }` over Bearer -(200) — the same delta-sync contract the web's `il-sync` CLI uses. -> Inspect the full `GET`/`POST /api/documents/sync` contracts against production -> (revision/`updatedAt` fields, delta vs full-body, conflict signals) and record -> in this doc (Part III). Then design (and land a minimal slice of) offline doc -> editing: cache edits in `DataCache`, replay via the sync endpoint on reconnect, -> resolve conflicts last-writer-wins or with a simple prompt. Feature-flag it. -> Ship the read-then-queue slice first, then two-way sync. - -#### Tier B — Backend-blocked (endpoints reject Bearer; need a backend change first) - -**§2.1 GitHub-backed lists & GitHub integration** `Large` ⛔ BACKEND (confirmed). -Web lists are "local or **GitHub-backed**"; `/api/github/*` covers repos, issues, -labels, assignees. Confirmed blocker: every `/api/github/*` endpoint requires a -session cookie — "Bearer tokens are not accepted" (F3). Biggest single parity gap. -> Do NOT build UI yet — backend-blocked. Confirm with the backend owner when -> `/api/github/*` will accept Bearer (Prompt A / F3). Meanwhile produce an -> implementation plan only: models + `APIClient` methods for `GET /api/github/repos` -> and `GET /api/github/issues?repo=owner/repo`, a repo picker in `CreateListView` -> for GitHub-backed lists, and a read-only issues view. Escalate the Bearer-auth -> requirement as the gating dependency. - -**§2.3 LinkedIn org/target picker** `Small` ⛔ BACKEND (confirmed). -`GET /api/linkedin/posting-targets` → **401 to Bearer** (session-only, per docs + -live probe). The `linkedInTargets` field on `POST /api/messages` already works; we -just can't fetch the target list on mobile. Response shape is known: +| Email/password + OAuth (Mastodon, Bluesky, LinkedIn, X) | ✅ | GitHub sign-in still hidden (see G4) | +| Feed: previews, dig, reply, delete, search, scheduled, cross-post, post-as-org | ✅ | | +| **Edit a posted message** | 🔴 | **D2** — client sends unsupported `PUT` | +| **Edit profile / settings** | 🔴 | **D1** — client sends unsupported `POST` | +| **Mark one notification read** | 🔴 | **D3** — client sends unsupported `PUT` | +| Lists: CRUD, schema DSL, rows, connections, watchers, parent/child nesting | ✅ | list create is 💲; **list folders removed 2026-08-15** — lists nest via `parentId` only | +| Documents: CRUD, folders, search, inline images, public reader | ✅ | doc create is 💲 (confirmed) | +| **Document templates** | ❌ | **G3** — Bearer-ready | +| **Document collaborators / share-links** | ❌ | **G2** — iOS has list *watchers* only | +| **List / document tokenized share-links** | ❌ | **G2** | +| Follow graph, orgs, notifications, moderation (block/mute/report), push | ✅ | mute has no list UI (**G7**) | +| **CSV exports** | ◑ | **G8** — wired; was dead (Bearer), now unblocked — verify | +| **Direct Messages** | ❌ | **G1** — entirely absent | +| **People search / discovery** | ◑ | **G6** — only list-scoped user search exists | +| **GitHub-backed lists / issues** | ❌ | **G4** — now Bearer-ready | +| **LinkedIn org/target picker** | ◑ | **G5** — `linkedInTargets` posts; can't fetch targets yet | +| **Offline document sync** | ❌ | **G9** | +| **Content deep / universal links** | ◑ | **G10** — profile/message/**document**/**doc-share-token** routed (Phase 16); list permalinks backend-limited (no owner in URL); Universal Links pending A2 iOS merge | +| **Live doc presence (cursors)** | ❌ | **G11** | +| **Active-sessions management** | ❌ | **G12** | +| Multi-account switching | ⛔ | **X1** — session-only backend | +| **Tag discovery / trending** | ✅ | **G13** — trending strip + `#` autocomplete in FeedView (Phase 16) | +| **Composer live link-preview** | ✅ | **G14** — live OG card in ComposeView (Phase 16) | +| Realtime channel (SSE/WS) | — | **X3** — none; polling only | +| Billing, layouts, engagement, widgets, admin | — | web-only by design | + +--- + +## Part IV — Implementation plan + +Ordered to match the gap list. Each item lists the **endpoints + auth + gating**, +the **models / `APIClient` methods** to add, the **UI**, and a **ready-to-paste +prompt**. All new `APIClient` methods use `Bearer` and the camelCase helpers unless +noted, and every one needs `MockURLSession` tests + a `#Preview`. + +### Tier 0 — Defect fixes (do first) + +All three are verb mismatches confirmed against the route handlers today +(`/api/user/update` → PATCH-only; `/api/messages/:id` → GET/PATCH/DELETE, no PUT; +`/api/notifications/:id/read` → PATCH-only). + +> **Prompt — fix D1/D2/D3 (one PR):** +> In `InterlinedList/Services/APIClient.swift`: +> 1. `updateProfile` and `updateUserSettings` call `post("/api/user/update", …)`. +> The route exports **only `PATCH`** and expects a **camelCase** body. Switch +> both to `patchCamel`. Keep the `{user?}`-unwrap. +> 2. `editMessage` calls `put("/api/messages/:id", …)`; the route has no `PUT` +> (GET/PATCH/DELETE only). Switch to `patchCamel` with `{ content, +> publiclyVisible }`. +> 3. `markNotificationRead` calls `put("/api/notifications/:id/read", …)`; route is +> `PATCH`-only. Switch to a `PATCH` (empty body). +> Update `MockURLSession` tests to assert `PATCH` + camelCase keys. Do NOT touch +> `patchScheduledMessage` (already correct) or `updateNotificationPreference` +> (already `PATCH`). Smoke-test in the sim: edit profile, edit a message, mark one +> notification read. + +**Closed (no work):** D4 exports now accept Bearer (→ verify under **G8**); D5 push +field is `token` and already matches. + +--- + +### Tier 1 + +#### G1 — Direct Messages `Large` 🟢 free, top priority + +The largest single missing surface, fully Bearer-ready and free. Contract +(`getCurrentUserOrSyncToken` on every route; participants must **mutually follow** +and neither may block the other): + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/dm?folder=inbox\|sent\|deleted&cursor=` | List a DM folder (cursor paginated) | +| POST | `/api/dm` | Send `{ recipientId, body(1–10000), imageUrls[] }` → `{ message }` | +| GET | `/api/dm/:id` | Fetch one message | +| POST | `/api/dm/:id/read` | Mark received message read (recipient only) → `{ updated }` | +| POST | `/api/dm/:id/trash` · `/restore` | Soft-delete / undo (per-side) → `{ ok }` | +| GET | `/api/dm/recipients` | Users you may DM (mutual-follow set) | +| GET | `/api/dm/thread/:username` | Full conversation (`items`, `olderCursor`, `isMutual`, `isBlocked`, `otherUser`) | +| GET | `/api/dm/thread/:username/updates?after=:msgId` | **Incremental poll** — new messages only; auto-marks read | +| GET | `/api/dm/unread-count` | `{ count }` for a tab badge | +| POST | `/api/dm/images/upload` | multipart `file` → `{ url }` (verified-email-gated, not 💲) | + +Error contract to surface gracefully: 400 `self_message`/`invalid_body`, 404 +`recipient_not_found`, 403 `blocked` / `not_mutual`. + +- **Models:** `DirectMessage`, `DMThread`, `DMFolder`, `DMRecipient`, `DMUnreadCount`. +- **APIClient:** `directMessages(folder:cursor:)`, `sendDirectMessage(recipientId:body:imageUrls:)`, + `dmThread(username:)`, `dmThreadUpdates(username:after:)`, `markDMRead(id:)`, + `trashDM(id:)`, `restoreDM(id:)`, `dmRecipients()`, `dmUnreadCount()`, + `uploadDMImage(_:)`. +- **UI:** new `MessagesInboxView` (a fifth tab or a Profile entry), `DMThreadView` + (chat bubbles, markdown, image attach), a compose-DM flow seeded from + `/recipients` or from a profile's "Message" button. Add a DM unread badge + (poll `/unread-count`; refresh on tab focus + `AppDataStore` prefetch). While a + thread is open, poll `/updates?after=<lastId>` on a `@MainActor` timer (~3–5 s, + backoff when backgrounded) — this is the "near-realtime" story (no WS to build). +- **Add a "Message" affordance** on `UserProfileView` when `isMutual`. + +> **Prompt — Direct Messages (ship in slices):** Slice 1 read-only: models + +> `dmThread`/`directMessages`/`dmUnreadCount`, an inbox list, a read-only thread, +> and an unread badge. Slice 2: `sendDirectMessage` + composer + `/updates` +> polling. Slice 3: image attach (`uploadDMImage`), trash/restore, and the +> profile "Message" button gated on `isMutual`. Handle the 403 `not_mutual` / +> `blocked` states with clear empty-state copy, never a crash. Tests for every +> `APIClient` method; `#Preview` per view. + +#### G2 — Sharing: share-links + document collaborators `Medium` 🟢 (💲 create) + +iOS ships per-person **list watchers** but nothing for **document collaborators** +or **tokenized share-links** on either resource. Roles are uniform: +`watcher`(Viewer) / `collaborator`(Editor) / `manager`(Admin). Only the **owner** +may create/list/revoke links. + +| Method | Path | Auth | Gate | +|---|---|---|---| +| GET/POST | `/api/lists/:id/share-links` | Bearer | POST 💲 | +| DELETE | `/api/lists/:id/share-links/:token` | Bearer | free | +| GET/POST | `/api/documents/:id/share-links` | Bearer | POST 💲 | +| DELETE | `/api/documents/:id/share-links/:token` | Bearer | free | +| GET/POST/PUT/DELETE | `/api/documents/:id/collaborators[/:userId]` | Bearer | write 💲 | +| GET | `/api/documents/:id/collaborators/users` | Bearer | free (candidate search) | +| GET | `/api/{lists,documents}/shared/:token[/data]` | optional | anon read (resolver) | + +- **Models:** `ShareLink { token, role, url, expiresAt, createdAt, revokedAt }`, + `DocumentCollaborator`, reuse `WatcherRole`. +- **APIClient:** `listShareLinks(kind:id:)`, `createShareLink(kind:id:role:expiresAt:)`, + `revokeShareLink(kind:id:token:)`; `documentCollaborators(id:)`, + `addDocumentCollaborator(id:userId:role:)`, `setDocumentCollaboratorRole(...)`, + `removeDocumentCollaborator(...)`, `searchDocumentCollaboratorCandidates(id:q:)`. +- **UI:** a reusable `ShareSheet` (create link at a role, copy `url`, revoke) hung + off list detail and document detail; a `DocumentCollaboratorsView` mirroring + `WatchersListView`. Create actions hidden for non-subscribers; revoke stays. +- **Also handle inbound links** in G10 (`/shared/:token` resolver → read-only + view + "Claim" when signed in; claim is **session-only**, so on iOS a claimed + Editor/Admin link may need the web — note that limitation in the UI). + +> **Prompt — Sharing:** Add the share-link `APIClient` methods + `ShareLink` model +> and a `ShareSheet` for lists and documents (create/copy/revoke, roles). Add +> `DocumentCollaboratorsView` modeled on `WatchersListView`. Gate create/modify on +> `isSubscriber` (hide, don't paywall). Tests + previews. + +#### G3 — Document templates `Small` 🟢 (read free, create 💲) + +`GET /api/documents/templates` → `{ folderCreated, templatesFolderId, +templates:[{id,title,…}] }` (free). `POST /api/documents/from-template` (💲) +creates from one; `POST /api/documents/templates/seed-defaults` (💲) seeds the set. + +- **Model:** `DocumentTemplate`. **APIClient:** `documentTemplates()`, + `createDocumentFromTemplate(templateId:…)`, `seedDefaultTemplates()`. +- **UI:** in the create-document flow, an optional "Start from template" picker + that prefills title/content; call `from-template` when chosen (hidden for free + users, since create itself is 💲). + +#### G4 — GitHub integration `Large` 🟢 **now unblocked** + +Was the single biggest backend-blocked gap; `/api/github/*` now authenticates via +`getGitHubIssuesContext` → `getCurrentUserOrSyncToken` (**Bearer OK**), requiring a +**linked GitHub identity**. + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/github/repos` | Repos for the linked account | +| GET/POST | `/api/github/issues?repo=owner/repo` | List / create issues | +| PATCH | `/api/github/issues/:owner/:repo/:number` | Edit labels/assignees | +| POST | `/api/github/issues/:owner/:repo/:number/comments` | Comment | +| GET | `/api/github/repos/:owner/:repo/{assignees,labels,next-issue-number}` | Metadata | + +**Live-confirmed:** `GET /api/github/repos` over Bearer returned **400 "GitHub +account not linked"** (not 401) — i.e. Bearer auth *passes*; the endpoint only +lacked a linked identity on the test account. + +Two sub-gaps: (a) **GitHub OAuth sign-in is still hidden** on iOS because the web +GitHub callback sets a cookie and redirects to `/dashboard` (no custom-scheme +handoff) — linking a GitHub identity from mobile still needs a backend mobile +branch on the callback, so **identity-linking remains blocked even though the data +endpoints are open**. (b) Once an identity is linked (e.g. via web), the data +endpoints work over Bearer. + +- **Plan:** build models (`GitHubRepo`, `GitHubIssue`, `GitHubLabel`, + `GitHubAssignee`) + `APIClient` methods; add a **repo picker in `CreateListView`** + for GitHub-backed lists and a read-only **issues view**; create-issue-from-message. + Surface the **`githubDefaultRepo`** user setting (from `GET /api/user`, set via + the D1-fixed `PATCH /api/user/update`, validated `owner/repo`) as the default in + that picker. Guard the whole surface on "has a linked GitHub identity" (from + `/api/user/identities`) and show an explainer when absent. +- **Escalate to backend:** add a mobile branch to `/auth/github/callback` (custom + scheme + `?token=`) so iOS users can *link* GitHub without the web — the last + remaining GitHub blocker. + +#### G5 — LinkedIn posting-target picker `Small` 🟢 **now unblocked** + +`GET /api/linkedin/posting-targets` (Bearer, **live-confirmed 200** with real +targets, e.g. `{ kind:"personal", label:"Adron Hall", avatarUrl, … }`) → `{ targets:[{ kind: personal|orgPage|personalPage, label, pageId|personalPageId, -linkedInPageId, enabled }], orgScopeMissing }`; posting uses `pageId`/ -`personalPageId` in `linkedInTargets`. -> Backend-blocked: `/api/linkedin/posting-targets` rejects Bearer. Once the backend -> adds Bearer support (Prompt A / F2), add `APIClient.linkedInPostingTargets()` -> returning `[LinkedInTarget]` (map `pageId`/`personalPageId` into the existing -> `linkedInTargets` field). In `ComposeView`, when the LinkedIn toggle is on and -> the user is a subscriber, show a target picker + the existing "link as first -> comment" toggle. Hide for non-subscribers / no LinkedIn identity. - -**§2.2 Tag discovery / trending** `Small` ❓ needs discovery. -"Hashtag organization and discovery" is marketed, but the client only supports a -`tag` filter (`messages(tag:)`). No trending-tags endpoint is confirmed. -> Probe for a trending/known-tags endpoint against production with the E2E `.env` -> token (`GET /api/tags/trending`, `/api/tags`, `/api/messages/tags`). Record the -> real path/shape in this doc (Part III). If one exists, add -> `APIClient.trendingTags()`, a model, a "Discover" surface in `FeedView`, and make -> in-body `#hashtags` tap through to the existing `tag:` feed. If none exists, -> document as backend-blocked and stop. Tests + `#Preview`. - -**§2.7 Realtime updates** `Large` ⛔ likely BACKEND. -No realtime endpoint appears in the docs and none was probed. Poll/refresh only. -> Discovery only: determine whether production exposes a realtime channel -> (`/api/stream`, `/api/events`, `/ws`, SSE `text/event-stream`). Record in this -> doc (Part III). If present, propose a `RealtimeService` (`@MainActor`, -> reconnect/backoff) layered on the existing cache-then-refresh model. Build -> nothing until confirmed; feature-flag when built. - -#### Tier C — Smaller / investigate - -**§2.6 Multi-account switching** `Small`. -Web auth docs list `GET /api/auth/accounts`, `POST /api/auth/switch`, -`POST /api/auth/remove-account` (and `?all=true` logout) — a cached multi-account -switcher. iOS is single-account. -> Confirm those three accept Bearer (probe with the E2E token). If so, add an -> account switcher to `SettingsView`/profile: list cached accounts, switch active -> account (swap the Keychain token + `AuthState`), remove an account. Gate behind -> having >1 account. Tests + `#Preview`. - -**§2.9 Document creation gate + utility widgets** `Investigate`. -- **Documents gate:** docs mark `POST /api/documents` **subscriber-only**, but the - iOS product direction assumes documents are free. Our probe account is a - subscriber, so unverified. Test with a **non-subscriber** account: if creation - 403s for free users, hide the create-document UI for them; if it succeeds, the - docs are wrong (F13). -- **Utility widgets:** `/help/api/utility-endpoints` exists (weather, geolocation, - image-proxy, oauth-metadata). Investigate whether weather/geolocation are - user-facing **list column types** on the web; if so, `ListSchemaEditorView` is - missing those column types (a real lists-parity gap). Findings only this pass. - -### II.4 — Suggested execution order - -1. **Defects D1–D5** — broken shipped features. Fix D1/D2/D3 immediately (one - `APIClient` PR), confirm D5 (push field), hide the export UI (D4) until the - backend unblocks it. Highest priority. -2. **Backend asks** — file Prompt A with the backend team: Bearer support on - `/api/exports/*`, `/api/linkedin/*`, `/api/github/*`. Unblocks D4 + §2.1 + §2.3 - in one move. -3. **Ready-now features** — §2.4 (templates), §2.5 (deep links); then §2.8 - (offline doc sync — contract confirmed Bearer-accessible). -4. **After Bearer unblock** — §2.1 (GitHub-backed lists, biggest win) and §2.3 - (LinkedIn target picker). -5. **Discovery-dependent** — §2.2 (tags), §2.6 (multi-account), §2.7 (realtime), - §2.9 (doc gate + utility widgets). - -### II.5 — Housekeeping (doc drift, from prior assessment) - -Two "deferred" phases actually shipped and should read as done in the tracking -docs (already reflected in the parity matrix above): - -| Phase | Old status | Reality | +linkedInPageId, enabled }], orgScopeMissing }`. `PUT` updates prefs; `POST +/sync-pages` refreshes. +The composer's `linkedInTargets` field already posts correctly — iOS just couldn't +*fetch* the list before. + +- **APIClient:** `linkedInPostingTargets()`, `updateLinkedInTargets(_:)`, + `syncLinkedInPages()`. **Model:** `LinkedInTarget`. +- **UI:** in `ComposeView`, when the LinkedIn cross-post toggle is on and the user + is a subscriber with a LinkedIn identity, show a target picker (map + `pageId`/`personalPageId` into `linkedInTargets`) + the existing "link as first + comment" toggle. Hide otherwise. + +#### G6 — People search / discovery `Small` 🟢 + +Backend has `GET /api/users/search?q=` and `GET /api/users/lookup?username=` +(Bearer). iOS only has *list-scoped* user search (`searchWatcherCandidates`) — no +way to find or open an arbitrary user. Add `searchUsers(q:)` + a search field that +routes results into `UserProfileView`. Small but high-utility (currently you can +only reach a profile via the feed). + +#### G7 — Muted-users management UI `XS` 🟢 + +`mutedUsers()` / `muteUser()` / `unmuteUser()` are already wired; only a screen is +missing. Add `MutedUsersView` mirroring `BlockedUsersView` and link it from +`SettingsView` next to Blocked Users. + +#### G8 — Verify CSV exports now work `XS` 🟢 + +`exportCSV` already sends Bearer; the four `/api/exports/*` routes now accept it — +**live-confirmed 200 with real CSV** for all four (`messages`, `lists`, +`list-data-rows`, `follows`) on 2026-07-31. **Un-hide the export entry point** (if +it was hidden per the old D4 plan), remove any `// TODO: re-enable when …` note, and +smoke-test in-app. Note the client knows only 3 of 4 — add `list-data-rows`. + +#### G13 — Tag discovery / trending `Small` 🟢 ✅ SHIPPED 2026-08-15 (Phase 16) + +Backend shipped two Bearer endpoints (both `getCurrentUserOrSyncToken`), **live-verified 2026-08-15**: + +| Method | Path | Shape | |---|---|---| -| **10 — Inline document image upload** | Tier 1, unchecked | ✅ Done (`uploadDocumentImage`; `DocumentsView.swift`) | -| **15 — Post as organization** | Tier 1, unchecked | ✅ Done (`postMessage(organizationId:)`; `ComposeView.swift`) | +| GET | `/api/tags/trending?window=day\|week\|month&limit=` (default week, ≤100) | `{ tags:[{ tag, count, lastUsedAt }] }` | +| GET | `/api/tags/autocomplete?q=<prefix>&limit=` (≤50; leading `#` stripped; `q` required) | `{ tags:[{ tag, count }] }` | -> Docs-only follow-up: in `App-Store-Deployment.md`, move Phase 10 and Phase 15 -> into "What's Already Shipped" and add defects D1–D5 to the tracking docs as bugs -> (they affect already-shipped functionality). +Both scope to **public** messages only. iOS already filters the feed by `tag:` in-body, +but has no discovery surface. ---- +- **Models:** `TrendingTag { tag, count, lastUsedAt }`, `TagSuggestion { tag, count }`. +- **APIClient:** `trendingTags(window:limit:)`, `tagAutocomplete(q:limit:)` (plain `get`, + camelCase decode). `MockURLSession` tests for both + the 400 empty-`q` path. +- **UI:** a trending-tags strip/section (e.g. on the feed or a discovery screen) whose + taps drive the existing `tag:` feed filter; `#`-autocomplete suggestions in the + compose field and/or the feed search box. All read-only, **free** — no gating. + +#### G14 — Composer live link-preview `XS` 🟢 ✅ SHIPPED 2026-08-15 (Phase 16) -## Part III — Backend / API team asks - -### III.0 — Findings at a glance (all CONFIRMED unless noted) - -| # | Owner | Finding | Status | Evidence | -|---|---|---|---|---| -| F1 | **BACKEND** | `GET /api/exports/*` rejects Bearer (session-only) | OPEN | Live: 401 w/ valid Bearer + no-auth | -| F2 | **BACKEND** | `GET/PUT /api/linkedin/posting-targets` rejects Bearer | OPEN | Live: 401; docs say "Auth: Session" | -| F3 | **BACKEND** | All `/api/github/*` reject Bearer | OPEN | Docs: "Bearer tokens are not accepted" | -| F4 | **DOC** | Messages page **auth column is unreliable** — `/api/messages/:id/replies` marked "Session" but returns **200 with no auth** | OPEN | Live: 200 Bearer + 200 no-auth | -| F5 | **DOC** | **No Moderation docs section exists**, but report/block/mute are live | OPEN | Live: `GET /api/user/blocks` & `/mutes` → 200 (Bearer) | -| F6 | **DOC** | `POST /api/user/organizations` documented as "Create" but client uses it to **join** | OPEN | Client sends `{organizationId}`; docs say "create" | -| F7 | **DOC** | Document **folder path-scoping** has no warning — root routes silently ignore folders | OPEN | Docs omit the caveat; causes silent data-loss | -| F8 (↔D1) | **iOS** | Client uses `POST /api/user/update`; server allows **only `PATCH`** → 405 | iOS OPEN | Live OPTIONS `Allow: OPTIONS, PATCH` | -| F9 (↔D2) | **iOS** | Client uses `PUT /api/messages/:id`; server allows **`PATCH`, not `PUT`** → 405 | iOS OPEN | Live OPTIONS `Allow: …, PATCH` | -| F10 (↔D3) | **iOS** | Client uses `PUT /api/notifications/:id/read`; server allows **only `PATCH`** → 405 | iOS OPEN | Live OPTIONS `Allow: OPTIONS, PATCH` | -| F11 (↔D4) | **iOS** | Client sends Bearer to exports (see F1) → export dead | iOS OPEN/BLOCKED | Live 401 | -| F12 (↔D5) | **VERIFY** | Push body field: docs say **`deviceToken`**, client sends **`token`** → likely silent push failure | OPEN | Not write-tested; verb/path confirmed | -| F13 | **VERIFY** | `POST /api/documents` documented **Subscriber-only**; iOS assumes documents **free** | OPEN | Subscriber account couldn't observe free path | -| F14 | **DOC** | `crossPostResults[].platform` can be omitted (crashed a strict decoder) | iOS-patched; doc OPEN | Client made it optional | -| F15 | **iOS** | `POST /api/lists` sent DSL **string** + read `list` key; server wants a DSL **object** + returns `data` | **RESOLVED 2026-07-22** | `createList` now sends object, decodes `data` (`APIClient.swift:422–427`) | -| F16 | **DOC** | Docs previously showed `POST /api/lists` `"schema":"string"`; now corrected to object form | **RESOLVED 2026-07-22** | `/help/api/lists`, `/help/api/lists-dsl` now object; response envelope still undocumented (residual) | - -### III.1 — [BACKEND] Endpoints that reject Bearer and block the mobile client - -**F1 — CSV Exports are session-only (confirmed).** -``` -GET /api/exports/messages [Bearer] -> 401 {"error":"Unauthorized"} -GET /api/exports/lists [Bearer] -> 401 -GET /api/exports/follows [Bearer] -> 401 -GET /api/exports/list-data-rows [Bearer] -> 401 -GET /api/exports/messages [no auth] -> 401 -``` -Docs correctly state exports don't accept Bearer — so the docs are right and the -iOS export feature is broken (it sends Bearer and always 401s). To make export -work on mobile, **add Bearer support to `/api/exports/*`**. (Your docs also list a -`list-data-rows` export the client doesn't know about — we may add it once auth -works.) - -**F2 — LinkedIn posting-targets are session-only (confirmed).** -``` -GET /api/linkedin/posting-targets [Bearer] -> 401 -``` -Docs (`/help/api/linkedin-integration`) confirm "Auth: Session" for -`/api/linkedin/targets`, `/api/linkedin/posting-targets` (GET+PUT), and -`/api/linkedin/sync-pages`. The composer's `linkedInTargets` field on -`POST /api/messages` already works, but iOS **can't fetch the target list**. -**Add Bearer support to the LinkedIn targets endpoints** and the picker becomes -buildable. (Response shape `{targets:[{kind, label, pageId|personalPageId, -linkedInPageId, enabled}], orgScopeMissing}` is documented — exactly what we need.) - -**F3 — GitHub integration is session-only.** -Docs (`/help/api/github-integration`): every `/api/github/*` endpoint requires a -session cookie ("Bearer tokens are not accepted") plus a linked GitHub identity, -and these "power features like GitHub-backed lists" — a headline web feature iOS -can't reach. **Add Bearer support to `/api/github/*`** to unblock native -GitHub-backed lists. - -### III.2 — [DOC] Documentation fixes - -**F4 — Audit the Messages page auth column (it's demonstrably wrong).** -`/help/api/messages` marks `GET /api/messages/:id/replies`, `POST /:id/dig`, -`DELETE /:id/dig`, and `PATCH /:id` as **Session**. But live: -``` -GET /api/messages/:id/replies [Bearer] -> 200 -GET /api/messages/:id/replies [no auth] -> 200 (effectively public for public messages) -``` -Re-audit the whole column against the actual middleware — where Bearer works, say -"Session or Bearer"; where public, say "Public." Treat the whole column as suspect -given `replies` was mislabeled. - -**F5 — Add a Moderation section (endpoints exist and are live).** -No `/help/api/moderation` page exists. These are live and Bearer-accepted (Apple -requires them for our app): -``` -GET /api/user/blocks?limit=1 [Bearer] -> 200 {"blockedUsers":[],"pagination":{...}} -GET /api/user/mutes?limit=1 [Bearer] -> 200 {"mutedUsers":[],"pagination":{...}} -``` -The client also uses (writes, not probed): `POST /api/messages/:id/report`, -`POST /api/users/:id/report`, `POST|DELETE /api/users/:id/block`, -`POST|DELETE /api/users/:id/mute`. Document all of them (paths, bodies — reports -take `{reason, detail?}` — auth, response shapes) and link from the index. - -**F6 — Clarify `POST /api/user/organizations`: create vs. join.** -`/help/api/users-and-profile` documents it as "Create new organization." The iOS -client calls it with `{ organizationId }` to **join** an existing org (it creates -orgs via `POST /api/organizations`). Clarify the real semantics (create/join/ -body-dependent) and the canonical join route. `GET /api/user/organizations` (list -my orgs) — confirmed live 200 — is documented now; good. - -**F7 — Add a folder path-scoping warning to Documents / Document Folders.** -Neither page warns that: -- `GET /api/documents` returns **only root docs** and **ignores `?folderId`**. -- `POST /api/documents` **always creates at root** (no `folderId` field). -- Only `PATCH /api/documents/:id` accepts `folderId` (to move). - -Using the root routes for folder content **silently drops docs to root**. A -prominent callout on both pages would save every future integrator this bug. - -**F14 — Document the `crossPostResults` shape (mark `platform` optional).** -On `POST /api/messages` with cross-posting, response `crossPostResults[]` entries -sometimes **omit `platform`** (observed with Bluesky), which crashed a strict -decoder. Document the shape and which fields are optional, or always emit -`platform`. - -### III.3 — [iOS] Verb/auth mismatches we will fix (your docs are right) - -Client bugs confirmed via live `OPTIONS` `Allow` headers — fixed on our side (see -D1–D4). Listed so you know (a) your docs are correct and (b) if you'd rather accept -the client's verb too, that's an option. - -| Endpoint | Server allows | Client sends | Result | Our fix | -|---|---|---|---|---| -| `/api/user/update` | `OPTIONS, PATCH` | `POST` | 405 → profile/settings/avatar writes fail | switch to `patchCamel` | -| `/api/messages/:id` (edit) | `…, PATCH` (no `PUT`) | `PUT` | 405 → message edit fails | switch to `patchCamel` | -| `/api/notifications/:id/read` | `OPTIONS, PATCH` | `PUT` | 405 → mark-read fails | switch to `PATCH` | -| `/api/exports/*` | session cookie only | Bearer | 401 → export dead | needs your F1 fix | - -> Accepting **both** `POST` and `PATCH` on `/api/user/update`, and **both** `PUT` -> and `PATCH` on the edit routes, would make the API more forgiving — not required; -> we'll align the client regardless. - -### III.4 — [VERIFY] Two items we couldn't confirm read-only - -**F12 — Push registration body field (`token` vs `deviceToken`).** -Path and verb are correct (`POST /api/push/register`, `DELETE /api/push/unregister` -— confirmed via `Allow`). But docs show the body field as **`deviceToken`** while -the client sends **`token`** (`APIClient.swift:1167–1170`). If the handler reads -`deviceToken`, iOS device tokens are **silently dropped** (no error, no push). -Confirm the handler field name (we'll rename) or accept `token` as an alias. -Highest-impact silent-failure candidate remaining. - -**F13 — Is document creation subscriber-only or free?** -`/help/api/documents` marks `POST /api/documents` (and image upload, template -creation) **Subscriber only**. The iOS product direction assumes documents are -**free**. Our test account is a subscriber, so we couldn't observe the free path. -Confirm the real gate. If subscriber-only, free iOS users silently can't create -docs and we must hide that UI; if free, drop the "Subscriber only" label. - -### III.5 — Confirmed-CORRECT (please do NOT "fix" these) - -Live-verified that docs and client already agree: -- `POST /api/user/delete` — `Allow: OPTIONS, POST` ✓ -- `POST /api/user/avatar/from-url`, `POST /api/user/avatar/upload` ✓ -- `POST /api/notifications/mark-all-read` ✓; `GET /api/notifications` requires - `scope=tray` (400 without) ✓ -- `GET /api/user/notification-preferences` + `PATCH` ✓ -- `GET /api/user/identities`, provider `status` for LinkedIn/Twitter only ✓ -- Public-browse namespace split is **real and load-bearing** (do not "normalize"): - - `GET /api/user/:username/messages` (singular `user`) → 200; plural form → **404** - - `GET /api/users/:username/lists` / `/lists/:id/data` / `/documents` (plural - `users`) → 200; singular form → **404** - - Recommend documenting the split explicitly — it's a footgun even though intentional. -- `GET /api/documents/templates`, `GET /api/documents/sync` — live 200 over Bearer ✓ - -### III.6 — Ready-to-paste prompts for the site/API team - -**Prompt A — Add Bearer support to the session-only feature areas (highest value).** -> Our iOS app authenticates with Bearer tokens only (no session cookie). Live -> probing on 2026-07-18 confirmed these return 401 to a valid Bearer: -> `GET /api/exports/{messages,lists,follows,list-data-rows}` and -> `GET /api/linkedin/posting-targets`; and the docs state `/api/github/*` also -> reject Bearer. In the API, extend the auth middleware for `/api/exports/*`, -> `/api/linkedin/*`, and `/api/github/*` to accept `Authorization: Bearer <token>` -> the same way `/api/messages` and `/api/user` already do. If any must stay -> session-only for a security reason, document that explicitly and tell us so we -> can drop those features from mobile. Then update each page's Auth column. - -**Prompt B — Fix the Messages auth column and add a Moderation section.** -> 1. On `/help/api/messages`, the Auth column is wrong: `GET /api/messages/:id/replies` -> is marked "Session" but returns 200 with a Bearer token AND with no auth at -> all. Re-audit every row against the actual middleware and correct the column -> ("Session or Bearer" / "Public"), especially `replies`, `dig`, `undig`, -> `PATCH /:id`. -> 2. Add a **Moderation** docs page. These are live: `GET /api/user/blocks`, -> `GET /api/user/mutes`, `POST /api/messages/:id/report`, -> `POST /api/users/:id/report`, `POST|DELETE /api/users/:id/block`, -> `POST|DELETE /api/users/:id/mute`. Document paths, bodies (reports take -> `{reason, detail?}`), auth, and response shapes; link it from the index. - -**Prompt C — Clarify org-join and add the doc-folder warning.** -> 1. `/help/api/users-and-profile` documents `POST /api/user/organizations` as -> "Create new organization," but our client posts `{ organizationId }` to it to -> JOIN an existing org (it creates via `POST /api/organizations`). Clarify the -> real behavior and document the canonical join route. -> 2. On `/help/api/documents` and `/help/api/document-folders`, add a prominent -> warning: `GET /api/documents` ignores `?folderId` and `POST /api/documents` -> always writes to root; to create/list inside a folder you MUST use -> `/api/documents/folders/:id/documents`. The root routes silently drop docs to -> root. - -**Prompt D — Confirm two ambiguous contracts.** -> 1. Push: does the `POST /api/push/register` / `DELETE /api/push/unregister` -> handler read `deviceToken` or `token`? Our client sends `token`; your docs -> show `deviceToken`. If it reads `deviceToken`, our tokens are silently dropped -> — accept `token` as an alias or tell us to rename. -> 2. Documents: is `POST /api/documents` truly subscriber-only (as documented) or -> free? It changes whether our iOS app must hide document creation from free -> users. Confirm against the handler. - -**Prompt E — Document the crossPostResults response shape.** -> On `POST /api/messages` with cross-posting, document the exact shape of -> `crossPostResults[]` and mark optional fields — in particular `platform` is -> sometimes omitted (observed for Bluesky), which crashed a strict client decoder. -> Either always include `platform` or document it as optional. - -### III.7 — Addendum: the `POST /api/lists` schema contract (2026-07-22) - -A real user hit **`400 "Invalid Schema: DSL must be an object"`** creating a list. -Root-causing surfaced one client bug (F15, fixed) and one doc problem the backend -had already largely fixed (F16). - -**F15 — [iOS, RESOLVED] client sent a DSL *string* and read the wrong response key.** -The shipped client built `schema` as the legacy comma-separated DSL **string** -(`"Title:text, Author:text"`) and decoded the created list from a **`list`** key. -The server (`validateDSLSchema` in `lib/lists/dsl-parser.ts`) requires `schema` to -be a DSL **object** and returns the created list under **`data`**. Fixed -2026-07-22 — `createList` now sends the object and decodes `data` -(`APIClient.swift:422–427`): -```jsonc -// POST /api/lists — request body the client now sends -{ - "title": "Books to Read", - "isPublic": true, - "schema": { // object, NOT "Title:text, Author:text" - "name": "Books to Read", - "fields": [ - { "key": "title", "type": "text", "label": "Title", "displayOrder": 0, - "required": false, "visible": true } - ] - } -} -// 201 response — list is under `data` -{ "message": "List created successfully", "data": { "id": "lst_…", "properties": [ … ] } } -``` - -**F16 — [DOC, RESOLVED] the docs used to show the string form; residual response gap.** -As of 2026-07-18 both `/help/api/lists` and canonical `docs/api-reference.md` -documented `POST /api/lists` with **`"schema": "string"`** — exactly the request -the server rejects. Re-checked 2026-07-22: fixed. `/help/api/lists`, the new -dedicated **`/help/api/lists-dsl`** reference, and `docs/api-reference.md:2709` all -now show the DSL **object**. Two small residuals: -1. **Response body isn't documented.** The `POST /api/lists` section still lists - only status codes (201/400/401/403), so the `{ message, data: { … } }` envelope - is undocumented. A one-line example would prevent the next client from decoding - the wrong key. -2. **Subscriber gate.** The route is `[Subscriber]`-only and returns - `403 "Subscribe to create lists."` for non-subscribers (same family as F13). - Flagged as **[VERIFY, iOS]** to confirm our app surfaces that 403 gracefully. +`GET /api/link-metadata?url=<http(s) url>` (Bearer; rate-limited 30/60 s → 429 with +`Retry-After`) returns a terminal `{ link: LinkMetadataItem }` (OG title/description/ +image, `fetchStatus: success|failed`) so the composer can render a WYSIWYG preview +**before** posting. iOS already decodes `LinkMetadataItem` and renders previews on +*posted* messages (`LinkPreviewBlock` in `MessageDetailView`) — this only adds the +**compose-time** card. + +- **APIClient:** `linkMetadata(url:)` (debounce on the caller side; treat 429/`failed` + as "no card", never a hard error). +- **UI:** in `ComposeView`, detect the first URL as the user types and show a compact + preview card (or its placeholder on `failed`). Free; no gating. --- -## Part IV — Coverage & method notes +### Tier 2 + +#### G9 — Offline document sync `Large` 🟢 + +`GET/POST /api/documents/sync` is a delta-sync contract (Bearer; emits +`RateLimit-*` headers; `lastSyncAt` param) and `GET /api/documents/tree` returns +the full hierarchy in one call. **Live-confirmed shape:** `GET /sync` → +`{ folders, documents, lastSyncAt }`, folder/doc rows carrying `createdAt`, +`updatedAt`, and **`deletedAt`** (soft-delete tombstones) — so `lastSyncAt` is the +delta cursor and deletions replicate cleanly; `tree` → nested +`{ folders:[{ …, documents:[…] }] }`. Plan: (1) confirm the `POST /sync` write +contract (conflict signals) against production; (2) cache doc edits in `DataCache`, +replay via `POST /sync` on reconnect, +resolve last-writer-wins with a simple conflict prompt; (3) feature-flag it and +ship read-then-queue first, two-way second. `documents/tree` is a cheap early win +(fewer round-trips building the folder tree). + +#### G10 — Content deep links / Universal Links `Medium` + +`InterlinedListApp.handleDeepLink` routes only `reset-password`, `verify-email`, +`verify-email-change`, `oauth`. Extend `AppRouter` + `.onOpenURL` to route content +permalinks: profiles, public lists (`/api/users/:username/lists/:id` → +`PublicListDetailView`), public documents, message threads, **and inbound +share-links** (`/{lists,documents}/shared/:token` → read-only resolver view from +G2). Support both `interlinedlist://…` and `https://interlinedlist.com/…`. Add a +"Share" action (web permalink) on message/list/doc/profile. +**Backend dependency:** Universal Links need the site to publish +`apple-app-site-association` + the Associated Domains entitlement — file that ask; +the custom-scheme path works without it. Share-link **claim** is session-only, so +Editor/Admin claims may still require web (note in UI). + +#### G11 — Live document presence `Medium` (optional) + +`POST/DELETE /api/documents/:id/presence` is a heartbeat+poll cursor-sync (no WS). +Low urgency for a phone; consider a lightweight "N people viewing" indicator before +full collaborative cursors. Build only after G2/G9. + +#### G12 — Active-sessions management `Small` 🟢 + +`GET /api/user/sessions` (list web+mobile logins) + `DELETE /api/user/sessions/:id` +(revoke). A security-hygiene screen in `SettingsView` ("Where you're signed in" → +revoke). Nice-to-have; pairs well with account-security UI. -**Docs pages read verbatim (2026-07-18):** `/help/api` and all detail pages — -`authentication`, `users-and-profile`, `public-profiles`, `messages`, `following`, -`lists`, `list-folders`, `documents`, `document-folders`, `notifications`, -`push-notifications`, `exports`, `organizations`, `github-integration`, -`linkedin-integration`, `utility-endpoints`, `administration`. (No `moderation` -page — that's F5.) +--- -**Re-review 2026-07-22 (targeted):** `/help/api`, `/help/api/lists`, the new -`/help/api/lists-dsl` page, and `docs/api-reference.md` §`POST /api/lists`, -prompted by a live `400 "Invalid Schema: DSL must be an object"` (Part III.7). +## Part V — Suggested execution order + +1. **Tier 0 (D1–D3)** — one `APIClient` PR; broken shipped features. Same PR: run + the **gating audit** (Part II) so no 💲 write is exposed to free users. +2. **G8 exports verify** + **G7 muted-users UI** — near-zero-effort wins that close + two matrix rows immediately. +3. **G1 Direct Messages** — biggest new surface; ship in 3 slices (read → send → + attach/trash). Highest user-visible parity gain. +4. **G3 templates**, **G5 LinkedIn picker**, **G6 people search** — small, + self-contained, independently shippable. +5. **G2 Sharing** — share-links + document collaborators (pairs with G10 inbound). +6. **G4 GitHub** — big win; data endpoints are ready now. File the + `/auth/github/callback` mobile-branch ask in parallel (identity-linking blocker). +7. **G9 offline sync**, **G10 deep/universal links**, **G12 sessions**, + **G11 presence** — larger/infra, lower urgency. +8. **X1/X2/X3** — no build; revisit only if the backend adds Bearer multi-account, + a tags endpoint, or a realtime channel. -**Live probe (read-only) on 2026-07-18, account `messenger` (subscriber):** login -(POST sync-token), ~30 GETs, and `OPTIONS` verb-detection on 15 routes. No writes. +--- + +## Part VI — Backend / API team asks (remaining) + +Most prior asks are done (exports/GitHub/LinkedIn Bearer, moderation docs, push +field). What's left: + +- **A1 — GitHub identity linking from mobile. ✅ DELIVERED (deployed + verified live + 2026-08-14).** `/auth/github/{authorize,callback}` now carry the mobile branch + (mints a sync token → `interlinedlist://oauth/callback?token=…`); live probe: + authorize accepts the mobile `redirect_uri` (307 → github.com) and rejects invalid + ones. Residual is **iOS-only**: merge the staged `feat/github-oauth-universal-links` + branch (**1 commit ahead of `dev`**, still unmerged as of 2026-08-15; flips GitHub + `supportsNativeAuth → true`). *(The standalone `Backend-Asks-A1-A2.md` spec has been + removed; the summary here is authoritative.)* +- **A2 — Universal Links assets. ✅ DELIVERED (deployed + Apple-CDN-verified + 2026-08-14).** `GET /.well-known/apple-app-site-association` serves the AASA JSON + (200, `application/json`, no auth); Apple's CDN copy is cached (200). Residual is + **iOS-only**: enable the Associated Domains capability in the Apple portal + regen + the profile, then merge the staged entitlement (same `feat/github-oauth-universal-links` + branch as A1). +- **A3 — Bearer for multi-account?** `/api/auth/{accounts,switch,remove-account}` + are session-only. If mobile multi-account is desired, expose a Bearer-compatible + switch (or per-account sync-tokens the client caches). Otherwise confirm it stays + web-only and we'll drop it from scope (X1). +- **A4 — Doc residuals (low priority).** `POST /api/lists` response envelope + (`{ message, data }`) still isn't documented; the doc-folder path-scoping caveat + and the public-browse singular/plural namespace split are still worth a callout + for future integrators. +- **A5 — List GET returns `folderId`. ✅ DELIVERED, then WITHDRAWN on the iOS side + (2026-08-15).** The list **GET** does emit `folderId` (`getUserLists` uses Prisma + `include`). However, iOS has **removed the list-folder concept entirely** by product + decision: lists organize only via parent/child nesting (`parentId`), not folders. The + client no longer decodes `folderId`, no longer calls the `/api/folders` CRUD + endpoints, and no longer sends `folderId` on list create/update. **No backend change + is requested** — the `folderId` column and `/api/folders/*` routes are simply unused + by the iOS client now (backend cleanup optional; the web app never had folder UI). +- **A6 — Doc the new endpoints (low priority).** `/api/tags/{trending,autocomplete}` + (G13) and `/api/link-metadata` (G14) shipped but aren't yet in `/help/api/*`; add + pages so future integrators (and this doc) can rely on the published contract. -**Not tested (would require writes or a different account):** push body-field -behavior (F12/D5), create-vs-join semantics of `POST /api/user/organizations` -(F6), free-user document-creation gate (F13/§2.9), `dig`/`undig` auth (F4). We're -happy to run authorized write-tests for any of these. +--- -**Recommendation:** diff Part III against the OpenAPI/route table. The -client-contract items (III.3) and the Bearer-rejection items (III.1) are where a -real, shipped consumer already diverges from the platform today. +## Part VII — Coverage & method notes + +- **Backend source read (2026-07-31):** enumerated `~/Codez/interlinedlist/app/api/**` + route handlers; auth model derived per-route from the `getCurrentUserOrSyncToken` + (Bearer|session) vs `getCurrentUser` (session-only) helper; subscriber gates from + `isSubscriber` / `forbidden("Subscribe to …")`. Verb regressions (D1–D3) and the + GitHub/exports/LinkedIn Bearer status were each confirmed by reading the exact + route exports and auth helper — not by probing. +- **Docs read (2026-07-31):** all 21 `/help/api/*` pages, including the three new + ones — **Direct Messages**, **Sharing**, **Moderation**. +- **iOS read (2026-07-31):** `APIClient.swift` (78 methods), 41 Views, 12 Models. + **Re-read 2026-08-15:** `APIClient.swift` is now **148 methods**, ~52 Views, 19 + Models (reflecting G1–G14/G9 work landing on `dev`). +- **Live re-probe (2026-08-15):** fresh `POST /api/auth/sync-token` login + read-only + GETs. **Confirmed:** `GET /api/lists` returns `folderId` (A5 done); `/api/tags/trending` + and `/api/tags/autocomplete` return **200** with real data (G13 — supersedes X2); + `/api/link-metadata` route reads Bearer (G14). Backend source re-scanned for new + top-level `app/api/*` dirs since 2026-07-31 — new ones are `tags/` (G13), + `link-metadata/` (G14), and `limits/` (a public, no-auth media/message-size caps + endpoint — informational, not a parity gap; iOS could adopt it instead of hardcoding + upload limits). No new mutations were performed. +- **Live probe (2026-07-31):** `messenger` (subscriber) via `POST + /api/auth/sync-token` + read-only GETs (no mutations). Confirmed 200/Bearer on + exports, DM (incl. `/thread/:username`, `/recipients`, `/unread-count`), + templates, sync, tree, LinkedIn targets, people search, sessions, and share-link/ + collaborator reads; GitHub repos returned 400 "not linked" (auth passed). See + [Live evidence](#live-verification-evidence-2026-07-31). +- **Still not observable (needs a write or a non-subscriber account):** the + **free-user gating path** — what a non-subscriber sees for list/doc create and + media/cross-post/schedule compose (source says 💲-gated; `messenger` is a + subscriber so can't see the 403 path) — plus the `POST /api/documents/sync` write/ + conflict contract (G9) and the GitHub mobile-link OAuth flow (blocked, ask A1). No + mutations were performed on the live account. ## Bottom line -Core product is at parity **except for the shipped features that regressed to -broken** (Part II.0, D1–D5) — fixing those is the immediate priority and mostly a -one-file `APIClient` change. Beyond that, full web parity needs the backend to -**accept Bearer tokens on exports/GitHub/LinkedIn** (one systemic fix unblocking -three features), then a handful of self-contained additions (templates, deep -links, multi-account, tag discovery) and two larger systems (offline doc sync — -now confirmed buildable; realtime — still needs a backend endpoint). +The backend caught up: **exports, GitHub, and LinkedIn now accept Bearer**, and DMs ++ sharing + templates + sync shipped — all mobile-buildable. The only broken +shipped code is three one-line verb fixes (**D1–D3**). Everything else is additive +and self-contained. The highest-leverage new build is **Direct Messages (G1)**; the +highest-leverage unblock is **GitHub-backed lists (G4)**. The only true dead-ends +for mobile are **multi-account** (session-only) and a **general realtime channel** +(none exists — poll instead). *(Tag discovery is no longer a dead-end — see the +2026-08-15 update below.)* + +> **Update 2026-08-14.** The plan above has been executed. **D1–D3 are fixed** and +> **G1–G10 + G12 all shipped** (see Progress log; G9 complete through Slice 3). What's +> left: **G11** presence (optional); **G10 follow-ons** (list/doc inbound + shared-token +> resolve); the **iOS-only** tail of A1/A2 (merge the staged +> `feat/github-oauth-universal-links` branch; enable the Apple-portal Associated +> Domains capability) now that **both A1/A2 backends are deployed and verified live**; +> and **A5** — the backend list **GET** must return `folderId` before the shipped +> "lists into folders" client UI can display nesting. *(A5 has since been delivered — +> see the 2026-08-15 update below.)* + +> **Update 2026-08-15 (API re-verification).** Live Bearer re-probe + backend-source +> re-read. **A5 is now DELIVERED** — `GET /api/lists` returns `folderId` (verified), +> so **"lists into folders" works end-to-end** (only an iOS build/run confirmation +> remains). Two backend endpoints appeared since the last pass and have **no iOS +> consumer yet**: **G13** tag discovery (`/api/tags/{trending,autocomplete}`, which +> *supersedes X2* — tag trending is no longer a non-feature) and **G14** the composer +> live link-preview (`/api/link-metadata`). Net open work: **G13, G14, G11** (optional), +> **G10 follow-ons**, and the **iOS-only** A1/A2 merge (`feat/github-oauth-universal-links`, +> still 1 commit ahead of `dev`). No shipped code is broken; all remaining items are +> additive.