diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c8e5e6e..5050d9e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -107,7 +107,10 @@ "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" ], "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/Backend-Asks-A1-A2.md b/Backend-Asks-A1-A2.md new file mode 100644 index 0000000..ff4d975 --- /dev/null +++ b/Backend-Asks-A1-A2.md @@ -0,0 +1,122 @@ +# Backend Asks A1 & A2 — GitHub in-app linking + Universal Links + +Hand-off spec for the two remaining backend/ops dependencies that unblock the last +of iOS↔web parity (see `the-gaps.md`). Both are **implemented** (backend PR + a +staged iOS branch); what remains is **deploy + Apple-portal provisioning** — the +steps I can't perform. Prepared 2026-08-03. + +Facts used: iOS `DEVELOPMENT_TEAM = BJA9558E4B`, bundle `com.interlinedlist.app`, +custom scheme `interlinedlist://`, app host `interlinedlist.com`. + +--- + +## A1 — GitHub in-app linking / sign-in (mobile OAuth handoff) + +**Problem.** Every other OAuth provider (Twitter, Mastodon, Bluesky, LinkedIn) +completes on mobile because its `/authorize` stores the mobile `redirect_uri` and +its `/callback` mints a **sync token** and redirects to +`interlinedlist://oauth/callback?token=…`, which `ASWebAuthenticationSession` +captures. **GitHub alone** ignores `redirect_uri` and its callback always sets a +web session cookie + redirects to `/dashboard` — so iOS can neither sign in with +nor *link* GitHub. GitHub-backed lists (already Bearer-ready) are therefore +unreachable for iOS users who linked GitHub only on mobile. + +**Fix (backend) — mirror the Twitter provider exactly.** Helpers already exist: +`isAllowedRedirectUri`/`isMobileRedirectUri` (`lib/auth/pkce.ts`), +`createSyncTokenForUser` (`lib/auth/sync-token.ts`), and `OAuthState.redirectUri` +(`lib/auth/oauth-state.ts`). + +1. `app/api/auth/github/authorize/route.ts` — read `redirect_uri` from the query, + validate with `isAllowedRedirectUri`, and pass it into `setOAuthStateCookie({ …, + redirectUri })` (exactly as `twitter/authorize` does). GitHub's registered + callback URL is unchanged — the mobile `redirectUri` lives only in our state. +2. `app/api/auth/github/callback/route.ts` — add a `buildSuccessResponse(userId, + redirectUri?)` mirroring Twitter's: + ```ts + async function buildSuccessResponse(userId: string, redirectUri?: string) { + if (redirectUri && isMobileRedirectUri(redirectUri)) { + const token = await createSyncTokenForUser(userId, 'Mobile-GitHub'); + const url = new URL(redirectUri); + url.searchParams.set('token', token); + return NextResponse.redirect(url.toString()); // interlinedlist://oauth/callback?token=… + } + const response = NextResponse.redirect(`${APP_URL}/dashboard`); // unchanged web path + response.cookies.set(SESSION_COOKIE_NAME, await createSession(userId), getSessionCookieOptions()); + return response; + } + ``` + Call it on **both** the link-success and sign-in-success branches + (`oauthState.redirectUri` passed through), replacing the two hardcoded + `/dashboard` redirects. The web flow (`redirectUri` absent) is byte-for-byte + unchanged. + +**Fix (iOS) — one line.** `OAuthCoordinator.supportsNativeAuth` currently returns +`self != .github`; change to `true`. `LoginView` and `LinkedIdentitiesView` filter +on this flag, so GitHub sign-in **and** linking light up automatically. + +**Ordering (ops):** the iOS one-liner must ship **only after the backend change +deploys** — otherwise a GitHub button appears but 401s against prod. Staged on the +iOS branch below; hold its merge until deploy. + +--- + +## A2 — Universal Links (`https://interlinedlist.com/…` opens the app) + +**Problem.** iOS G10 already routes content permalinks (profiles `/user/*`, +messages `/message/*`, lists `/lists/*`, documents `/documents/*`) via `.onOpenURL` ++ the pure `AppDeepLink.parse`, and share actions emit `https://interlinedlist.com` +URLs. But an `https` tap only opens the app if (a) the site hosts a valid +**apple-app-site-association (AASA)** and (b) the app carries the **Associated +Domains** entitlement. The custom-scheme path works today; Universal Links do not. + +**Fix (backend) — host the AASA, public + unredirected.** +- Serve `GET https://interlinedlist.com/.well-known/apple-app-site-association` + (200, `Content-Type: application/json`, **no auth, no redirect**) with: + ```json + { + "applinks": { + "apps": [], + "details": [ + { + "appID": "BJA9558E4B.com.interlinedlist.app", + "paths": [ "/user/*", "/message/*", "/lists/*", "/documents/*", + "NOT /api/*", "NOT /dashboard*", "NOT /login*", "NOT /help/*" ] + } + ] + } + } + ``` +- Implemented as a Next route handler at + `app/.well-known/apple-app-site-association/route.ts` returning the JSON with the + right content-type, **plus** a `middleware.ts` early-return so `/.well-known/*` + bypasses auth/redirect (the current matcher would otherwise process it). + +**Fix (iOS) — entitlement.** Add to `InterlinedList/InterlinedList.entitlements`: +```xml +com.apple.developer.associated-domains +applinks:interlinedlist.com +``` +(kept alongside the existing `aps-environment`). No app code change — G10 already +parses https permalinks. + +**Ordering (ops — human):** adding the entitlement makes **signed** device/ +TestFlight/App-Store builds fail unless **Associated Domains is enabled for the App +ID in the Apple Developer portal and the provisioning profile is regenerated**. +(Simulator + the code-signing-disabled CI build are unaffected.) So: enable the +capability in the portal → regen profile → deploy the AASA → then merge the iOS +branch. Verify with Apple's CDN: `https://app-site-association.cdn-apple.com/a/v1/interlinedlist.com`. + +--- + +## Status & what's left for a human + +| Piece | Done | Human/ops step remaining | +|---|---|---| +| A1 backend (github authorize+callback) | ✅ PR | review + **deploy** | +| A1 iOS (`supportsNativeAuth` → true) | ✅ staged branch | merge **after** backend deploy | +| A2 backend (AASA route + middleware) | ✅ PR | review + **deploy**; verify via Apple CDN | +| A2 iOS (Associated Domains entitlement) | ✅ staged branch | enable **Associated Domains capability** in Apple portal + regen profile, then merge | + +After these ops steps, the only remaining parity item is **G11** (live document +presence — optional). Everything else is a documented dead-end (multi-account, +tag discovery, realtime). diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index e126581..d42fc1e 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -7,27 +7,48 @@ 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 */; }; 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 */; }; 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 */; }; + 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 */; }; 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 */; }; 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 +81,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 +93,15 @@ 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 */; }; + 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 +111,16 @@ 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 */; }; + DD652FF47D5509B976706914 /* DocumentSyncMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD5DCAF4C97D6B577F52527 /* DocumentSyncMerge.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 */; }; F93C3A953A0B809D718F9DB5 /* PublicDocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DB21A23AF902D811F28C38 /* PublicDocumentsView.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 +174,53 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLinksSheet.swift; sourceTree = ""; }; 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTruncationTests.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 = ""; }; 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; }; 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 +255,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 +265,14 @@ 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 = ""; }; + 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 = ""; }; + 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 +280,17 @@ 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 = ""; }; 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 +412,12 @@ 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 */, ); path = Models; sourceTree = ""; @@ -356,6 +436,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 +489,14 @@ 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 */, ); path = Views; sourceTree = ""; @@ -450,6 +543,15 @@ 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 */, ); path = APIClientTests; sourceTree = ""; @@ -471,6 +573,12 @@ 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 */, ); path = ModelTests; sourceTree = ""; @@ -482,6 +590,9 @@ 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */, 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */, D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */, + 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */, + D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */, + 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */, ); path = ServiceTests; sourceTree = ""; @@ -651,6 +762,25 @@ 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -706,6 +836,24 @@ 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/InterlinedListApp.swift b/InterlinedList/InterlinedListApp.swift index 8217797..afa7615 100644 --- a/InterlinedList/InterlinedListApp.swift +++ b/InterlinedList/InterlinedListApp.swift @@ -46,38 +46,43 @@ 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 .verifyEmail, .verifyEmailChange: + // These never present a sheet — they run an async side effect in + // handleDeepLink and are never assigned to pendingDeepLink. + EmptyView() } } + // TODO(A2): Universal Links (a tapped https://interlinedlist.com link opening the + // app directly) need the backend to publish apple-app-site-association plus the + // Associated Domains entitlement. `parse` already accepts https permalinks, so + // onOpenURL will route them once that server asset ships. 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: + router.pendingDeepLink = link } } @@ -124,10 +129,73 @@ 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) 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 + } + } + + /// 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 "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..6f067af 100644 --- a/InterlinedList/Models/List.swift +++ b/InterlinedList/Models/List.swift @@ -128,13 +128,42 @@ struct UserList: Identifiable, Codable, Hashable { let createdAt: String let updatedAt: String? let itemCount: Int? + /// "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 the list-in-list hierarchy. // convertFromSnakeCase is bypassed when CodingKeys are present, so use exact JSON keys. + // 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 source, githubRepo, githubMeta + } + + // 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?, folderId: String?, + 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.folderId = folderId + self.isPublic = isPublic + self.createdAt = createdAt + self.updatedAt = updatedAt + self.itemCount = itemCount + self.source = source + self.githubRepo = githubRepo + self.githubMeta = githubMeta } } 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/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/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..faa85da 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 } @@ -415,14 +448,32 @@ 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? + } 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 +547,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,6 +667,32 @@ 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 + } + 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 Response: Decodable { let list: UserList? } @@ -629,6 +747,37 @@ final class APIClient { return (response.lists, response.pagination) } + // 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") + } + + /// 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) + } + + /// 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 func uploadImage(data: Data, mimeType: String) async throws -> String { @@ -713,6 +862,23 @@ 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: - Notifications func notifications() async throws -> NotificationsResponse { @@ -723,7 +889,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 +952,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 +967,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() } @@ -974,6 +1140,79 @@ 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) + } + + 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 +1401,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 +1440,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 +1547,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..5f6a56a 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -23,15 +23,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 +93,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 @@ -82,6 +144,10 @@ final class AppDataStore: ObservableObject { } func refreshDocuments() async { + if offlineDocSyncEnabled { + await refreshDocumentsViaSync() + return + } documentsLoading = documents.isEmpty documentsError = nil defer { documentsLoading = false } @@ -101,6 +167,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 +327,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) { @@ -126,14 +463,34 @@ 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 = [] @@ -149,6 +506,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 } @@ -160,7 +526,17 @@ final class AppDataStore: ObservableObject { 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 } @@ -183,8 +559,23 @@ 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/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index f37a369..f1ecff4 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -41,6 +41,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 @@ -178,6 +183,8 @@ struct ComposeView: View { crossPostLinkedIn = false crossPostTwitter = false selectedMastodonIds = [] + selectedLinkedInTargetIds = Set(enabledLinkedInTargets.map { $0.id }) + linkedInLinkAsFirstComment = false selectedOrgId = nil applyUserDefaults() } @@ -190,6 +197,10 @@ struct ComposeView: View { photoSelection = [] Task { await imageUploader.add(picked) } } + .onChange(of: crossPostLinkedIn) { _, isOn in + guard isOn else { return } + Task { await loadLinkedInTargetsIfNeeded() } + } } } @@ -393,6 +404,9 @@ struct ComposeView: View { Toggle(isOn: $crossPostLinkedIn) { Label("LinkedIn", systemImage: "briefcase") } + if crossPostLinkedIn { + linkedInTargetPicker + } } if hasTwitter { Toggle(isOn: $crossPostTwitter) { @@ -432,6 +446,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 +634,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 +647,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..029ca6d 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,5 @@ private struct ColumnRow: View { #Preview { CreateListView(onCreate: { _ in }) + .environmentObject(AuthState()) } 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/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index b6774ae..3cb9ce2 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,13 @@ 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 showCollaborators = false init(document: Document, onUpdate: @escaping (Document) -> Void, onDelete: @escaping (String) -> Void) { self.document = document @@ -542,6 +665,14 @@ private struct DocumentDetailView: View { ToolbarItem(placement: .topBarTrailing) { Menu { Button("Edit") { showEdit = true } + Button("Share") { showShare = 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 +692,22 @@ private struct DocumentDetailView: View { onUpdate(updated) } } + .sheet(isPresented: $showShare) { + ShareLinksSheet(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 +722,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 +800,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 +859,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 +927,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 +940,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 +975,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 +1010,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 +1032,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/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/ListsView.swift b/InterlinedList/Views/ListsView.swift index 26a510c..3cda776 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -53,6 +53,7 @@ struct ListsView: View { CreateListView { _ in Task { await store.refreshLists() } } + .environmentObject(authState) } .sheet(isPresented: $showCreateFolder) { CreateListFolderView(parentId: nil) { @@ -459,6 +460,9 @@ struct ListDetailView: View { @State private var deletingItem: ListItem? = nil @State private var showDeleteConfirm = false @State private var showWatchers = false + @State private var showShare = false + @State private var isRefreshingGitHub = false + @State private var gitHubRefreshError: String? var body: some View { Group { @@ -478,11 +482,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 @@ -547,14 +554,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 +587,22 @@ struct ListDetailView: View { } .accessibilityLabel("Manage watchers") } + ToolbarItem(placement: .topBarTrailing) { + Button { + showShare = true + } label: { + Image(systemName: "link") + } + .accessibilityLabel("Share list") + } + 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,6 +614,10 @@ 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: $showAddConnection) { NavigationStack { List { @@ -619,6 +662,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) 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/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/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/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/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/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/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/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/ModelTests/AppDeepLinkParseTests.swift b/InterlinedListTests/ModelTests/AppDeepLinkParseTests.swift new file mode 100644 index 0000000..a859075 --- /dev/null +++ b/InterlinedListTests/ModelTests/AppDeepLinkParseTests.swift @@ -0,0 +1,88 @@ +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() { + // Inbound list/document deep links are out of scope this pass. + XCTAssertNil(parse("https://interlinedlist.com/lists/xyz")) + } + + // 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/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/Offline-Document-Sync-Design.md b/Offline-Document-Sync-Design.md new file mode 100644 index 0000000..b71a3d7 --- /dev/null +++ b/Offline-Document-Sync-Design.md @@ -0,0 +1,133 @@ +# Offline Document Sync — Design (G9) + +**Status:** Proposal for review (2026-07-31). Nothing built yet. +**Scope:** Offline-capable **documents** (and document folders). Lists are **not** +covered — the `/api/documents/sync` endpoint is documents-only; lists have no +equivalent sync endpoint, so offline lists are out of scope here. + +## 1. The backend contract (verified from source + live) + +**Pull — `GET /api/documents/sync?lastSyncAt=<iso>`** (Bearer) +→ `{ folders: [...], documents: [...], lastSyncAt: <iso> }`. +- Delta since `lastSyncAt`; **full state** when the param is absent. +- Every folder/document row carries `createdAt`, `updatedAt`, and **`deletedAt`** + (soft-delete tombstone) so deletions replicate. +- `lastSyncAt` in the response is the **new cursor** to persist for the next pull. +- Emits `RateLimit-*` headers (429 when exceeded) — must back off. + +**Push — `POST /api/documents/sync`** (Bearer) +body `{ operations: [ { op: "create"|"update"|"delete", type: "folder"|"document", path, data } ] }`. +- Documents upsert **by client-supplied `id`** (`data:{ id, folderId, title, content, relativePath, isPublic }`); server recomputes `contentHash`. +- `delete` is a soft-delete (`deletedAt`). +- Folders resolve/create by `path`. +- **Last-writer-wins:** the update path overwrites server title/content with **no + version guard** (unlike `PATCH /api/documents/:id`, which does guard). So the sync + endpoint will not reject a stale write — conflict avoidance is the client's job. + +This is the same contract the web `il-sync` CLI uses. + +## 2. Design goals + +1. **Instant, offline reads** — documents render from a local store immediately, + with no network on the hot path; background delta-pull refreshes. +2. **Offline edits** — create/edit/delete while offline; changes queue and replay + on reconnect. +3. **No silent data loss** — because the server is LWW, the client must *detect* + divergence and never quietly clobber a server change the user hasn't seen. +4. **Incremental & flag-gated** — ship a safe read slice first; two-way behind a + feature flag; never regress the current online-only document flow. + +## 3. Proposed architecture + +``` +DocumentsView ─▶ DocumentStore (@MainActor ObservableObject) + │ in-memory folders+documents (source of truth for UI) + ├─▶ SyncCache (on-disk, per-user) ← extends DataCache + │ • folders[], documents[] (with deletedAt) + │ • lastSyncAt cursor + │ • per-doc localState: .synced | .dirty | .deleted + │ • outbox: [SyncOperation] (queued create/update/delete) + └─▶ DocumentSyncEngine (actor) + • pull(): GET /sync?lastSyncAt → merge deltas, advance cursor + • push(): drain outbox → POST /sync {operations} + • runs on: app foreground, DocumentsView appear, reconnect, + and after each local edit (debounced) +``` + +- **`DocumentStore`** replaces the ad-hoc `documents`/`documentFolders` slices in + `AppDataStore` for the documents tab (or wraps them), so the UI binds to one + offline-aware source. +- **`SyncCache`** builds on the existing `DataCache` (JSON under `Caches/ILDataCache/`), + adding the cursor, per-doc state, and the outbox. +- **`DocumentSyncEngine`** is an `actor` (serialize pull/push; no overlapping syncs). + Reachability via `NWPathMonitor`. + +## 4. Sync algorithm (per cycle) + +1. **Push first** (so local intent isn't overwritten by a concurrent pull merge): + drain `outbox` → `POST /sync`. On success, mark those docs `.synced`, clear their + ops. On failure/offline, keep the outbox. +2. **Pull:** `GET /sync?lastSyncAt=<cursor>`. For each returned row: + - `deletedAt != nil` → remove locally (unless locally `.dirty` → conflict, §5). + - else upsert into the store; if the local copy is `.dirty`, **conflict** (§5), + otherwise overwrite and mark `.synced`. +3. Persist the new `lastSyncAt`. Debounce cycles; honor `RateLimit-*` / 429 backoff. + +## 5. Conflict handling (the crux) + +The server is LWW with no guard, so the client must decide. Baseline each local doc +with the `updatedAt` it was last synced at (`baseUpdatedAt`). A conflict = a doc is +locally `.dirty` **and** the pull returns a server `updatedAt` newer than +`baseUpdatedAt` (someone else changed it while we had unsynced edits). + +**Recommended policy (default):** **last-writer-wins by push order, but never +destroy the loser** — on a detected conflict, keep the local edit as the live doc, +and preserve the server version as a **conflict copy** ("Title (conflicted copy +2026-07-31)") so nothing is lost; surface a one-line banner. This mirrors +Dropbox/Notes behavior and is safe without a real merge UI. + +Alternatives (your call — see decision below): (a) **Prompt** on each conflict +(keep mine / keep theirs); (b) **pure LWW** (simplest, silent — not recommended +given data-loss risk); (c) **server-wins** for un-pushed dirties. + +## 6. Slices (ship in this order, each independently valuable + flag-gated) + +- **Slice 1 — Offline read + delta pull** *(Small–Medium, low risk).* + Persist the full doc/folder set via `SyncCache`; render instantly from it; background + `GET /sync` delta refresh with cursor; apply tombstones. Also adopt + `GET /api/documents/tree` for the one-call hierarchy. **No write path, no conflict + logic.** Immediate wins: offline reading + faster document tab. Can ship on its own. +- **Slice 2 — Offline edit queue + push** *(Medium).* + Route create/edit/delete through the `outbox`; optimistic local apply; replay via + `POST /sync` on reconnect; LWW. Feature-flagged. +- **Slice 3 — Conflict detection + resolution** *(Medium).* + Baseline tracking + conflict-copy policy (§5) + banner. Only after Slices 1–2. + +## 7. Testing + +- `MockURLSession`: pull merge (adds/updates/tombstones), cursor advance, push + operation-body shape (`operations[].{op,type,data}`), 429 backoff. +- `SyncCache`/engine unit tests: dirty/synced/deleted transitions, outbox drain, + conflict detection (dirty + newer server `updatedAt` → conflict copy). +- No live network in tests; a manual E2E-style smoke against the test account + (read-only pull only) to confirm the real `lastSyncAt`/tombstone shapes. + +## 8. Risks / open questions + +- **Lists are not covered** — no list sync endpoint; offline stays documents-only. +- **Inline images offline** — image upload needs network; queued docs referencing + not-yet-uploaded images must defer or upload-on-reconnect. Slice 2+ concern. +- **Cache growth** — large document sets; consider a cap / LRU later. +- **`path`/`relativePath` semantics** on folder create ops — confirm against a live + push before Slice 2 (no live writes done yet — would need an authorized write-test + or the free/second account). +- **Interaction with `PATCH /api/documents/:id`** (the online edit path, which *does* + version-guard) vs. the sync push (no guard) — Slice 2 should route document edits + through **one** path (the sync outbox) when the flag is on, to avoid two writers. + +## 9. Decision needed before building + +1. **Conflict policy** — recommended: **conflict-copy** (keep both, never lose data). + Alternatives: prompt / pure-LWW / server-wins. +2. **First slice to build** — recommended: **Slice 1 only** (offline read + delta + pull), review, then decide on Slices 2–3. 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/the-gaps.md b/the-gaps.md index 59bb490..199ba53 100644 --- a/the-gaps.md +++ b/the-gaps.md @@ -1,104 +1,216 @@ # 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 +**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) --- -## 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** | + +### 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 -**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. +| 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** | **No such endpoint exists in the backend** (no `/api/tags*`, no trending route). Not a gap — a non-feature. In-body `#hashtag` → existing `tag:` filter is the only viable slice. | +| **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. | + +### 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. --- -## 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`) +## 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. Design in `Offline-Document-Sync-Design.md` | +| **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 | +| — | **G10 follow-ons** *(list/doc inbound, shared-token resolve, Universal-Links via A2)* · **G11 presence** *(optional)* · backend asks **A1** (GitHub in-app linking) + **A2** (AASA) | ⏳ 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. + +--- + +## 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). Reclassified to non-feature. | + +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 +218,389 @@ 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. + +--- + +## 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. -### II.2 — Parity matrix +--- -Legend: ✅ at parity · ◑ partial · ❌ missing · 🔴 broken · — n/a +## 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: -`{ 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 | -|---|---|---| -| **10 — Inline document image upload** | Tier 1, unchecked | ✅ Done (`uploadDocumentImage`; `DocumentsView.swift`) | -| **15 — Post as organization** | Tier 1, unchecked | ✅ Done (`postMessage(organizationId:)`; `ComposeView.swift`) | +| 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, folders, schema DSL, rows, connections, watchers | ✅ | list create is 💲 | +| 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** — only auth callbacks routed today | +| **Live doc presence (cursors)** | ❌ | **G11** | +| **Active-sessions management** | ❌ | **G12** | +| Multi-account switching | ⛔ | **X1** — session-only backend | +| Tag discovery / trending | — | **X2** — no backend endpoint | +| Realtime channel (SSE/WS) | — | **X3** — none; polling only | +| Billing, layouts, engagement, widgets, admin | — | web-only by design | + +--- -> 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). +## 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. --- -## 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. +### 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 }`. `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`. --- -## 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.) +## 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. -**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). +--- -**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.** Data endpoints accept Bearer, but + `/auth/github/callback` sets a web cookie and redirects to `/dashboard`, so iOS + users can't *link* a GitHub identity. Add a mobile branch (custom scheme + `interlinedlist://oauth/callback?token=…`) like the other providers. This is the + only remaining GitHub blocker (G4). +- **A2 — Universal Links assets.** Publish `apple-app-site-association` for + `interlinedlist.com` so iOS can register Associated Domains and open + `https://` content links natively (G10). +- **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. -**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. +- **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), **tag discovery** (no endpoint), +and a **general realtime channel** (none exists — poll instead).