From 3a4d022ce31102221d3902c45765a9819c584002 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 27 Jun 2026 00:05:23 -0700 Subject: [PATCH 1/2] The settings file, should it even be? --- .claude/settings.local.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dc84215..2dca3fc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -69,7 +69,12 @@ "Bash(plutil *)", "Bash(git stash *)", "Bash(curl -s \"https://interlinedlist.com/api/openapi.json\" -H \"Accept: application/json\" -o openapi.json)", - "Bash(echo \"HTTP fetch exit: $? size: $\\(wc -c < openapi.json\\) bytes\")" + "Bash(echo \"HTTP fetch exit: $? size: $\\(wc -c < openapi.json\\) bytes\")", + "Bash(git rev-list *)", + "Bash(gh auth *)", + "Bash(git ls-remote *)", + "Bash(gh pr *)", + "Bash(git push *)" ], "additionalDirectories": [ "/Users/adron/Codez/interlinedlist-ios/.claude" From 56805c2a2557b5b91c9b34952466c4c102667e8c Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 27 Jun 2026 00:15:14 -0700 Subject: [PATCH 2/2] feat(documents): delete document folders with cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds folder deletion to the Documents tab, plus an unrelated CI fix and a roadmap-doc restructure that were waiting in the tree. - documents: APIClient.deleteDocumentFolder(id:) issues DELETE /api/documents/folders/{id} (server cascades to subfolders/docs); AppDataStore.removeDocumentFolder optimistically drops it from cache. DocumentsView/DocumentFolderView expose swipe + context-menu Delete with a confirmation dialog; failures re-sync so the UI never lies. 401 routes through authState.handleUnauthorized(). Adds APIClient tests for the DELETE path and the 401 case. - ci: build against "generic/platform=iOS Simulator" instead of the pinned "iPhone 16,OS=latest", which no longer exists on the GitHub runner and was failing the build. Generic is correct for a build-only job and immune to future runner image changes. - docs: restructure GAP-NEXT-STEPS around App Store ship-blockers (Phase 14 UGC safety, Phase 0.5 hygiene) and tiered remaining work; document the unverified moderation endpoints in GAP-ENDPOINTS §H; expand GAP-APPLE with pre-submission open questions and push-in-v1 scope. Also strips stray tool markup accidentally written to the end of GAP-NEXT-STEPS. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ios.yml | 2 +- GAP-APPLE.md | 123 +++- GAP-ENDPOINTS.md | 53 +- GAP-NEXT-STEPS.md | 611 ++++++++---------- InterlinedList/Services/APIClient.swift | 13 + InterlinedList/Services/AppDataStore.swift | 1 + InterlinedList/Views/DocumentsView.swift | 84 +++ .../APIClientDocumentsTests.swift | 19 + 8 files changed, 529 insertions(+), 377 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 06b41ae..fe609bd 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -20,7 +20,7 @@ jobs: xcodebuild build \ -scheme InterlinedList \ -project InterlinedList.xcodeproj \ - -destination "platform=iOS Simulator,name=iPhone 16,OS=latest" \ + -destination "generic/platform=iOS Simulator" \ -configuration Debug \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ diff --git a/GAP-APPLE.md b/GAP-APPLE.md index e686b4d..d2b86e4 100644 --- a/GAP-APPLE.md +++ b/GAP-APPLE.md @@ -29,6 +29,85 @@ Legend for each step: Two Info.plist items to fix **before** the first upload — see §3.5. +**Scope decisions baked into this doc (2026-06-27):** the first release +targets **full web parity** and **includes push notifications (Phase 9)**, +so the Push capability/entitlement and an APNs key are now part of the +first ship (§2.3, §3.6, §0.1). The app also still needs the **UGC +safety/moderation** work (report/block/terms gate) before it can pass +review — that's tracked as **Phase 14** in `GAP-NEXT-STEPS.md` and called +out in §5.4 / §11 below. + +--- + +## 0.1 Open questions & info to gather before submission + +These are the unknowns that block a clean submission. Each line says **how +to obtain it**. Resolve them before §7 (archive/upload); none require code. + +**Account & identity** +- [ ] **Membership active + your role.** Confirm the Apple Developer + Program membership is paid/active and you are **Admin** or **App + Manager** on team `BJA9558E4B`. + *How:* ▸ Membership details; + Users and Access shows your role. +- [ ] **Team ID is correct.** This doc assumes `BJA9558E4B`. + *How:* `xcodebuild -showBuildSettings -scheme InterlinedList | grep DEVELOPMENT_TEAM`, + or the portal Membership page. Update every occurrence if it differs. +- [ ] **App name is available.** "InterlinedList" must be globally unique on + the App Store. + *How:* App Store Connect ▸ Apps ▸ "+" ▸ New App — if the name is + taken you'll be told at reservation; have a fallback name ready. + +**Listing metadata (owner decisions needed)** +- [ ] **Primary / secondary category.** Likely **Social Networking**; + confirm the pair. +- [ ] **Age rating answers.** A UGC social app typically lands 17+. Answer + the questionnaire honestly **after Phase 14 lands** (the answers + depend on having reporting/blocking in place). +- [ ] **Demo reviewer account.** A stable **production** email/password + login for App Review (OAuth is awkward for reviewers). + *How:* register one on `interlinedlist.com`; keep it active; put the + creds in the review notes (§9) — **never commit them to the repo**. + +**URLs to verify live (200 OK)** +- [ ] **Privacy policy:** `https://interlinedlist.com/privacy` (mandatory). +- [ ] **Support URL:** confirm the real one (`/help`?) resolves. +- [ ] **Community Guidelines / EULA** for the Phase 14 terms gate: confirm a + published zero-tolerance guidelines page exists (e.g. `/terms`, + `/guidelines`, `/community`). If none exists, the app can present + **Apple's standard EULA** instead — decide which. + *How:* `curl -sI ` or open in a browser; coordinate with + `GAP-ENDPOINTS.md` §H. + +**App Privacy "nutrition label" inputs** +- [ ] **Enumerate collected data → purpose.** From the API request bodies + the app sends: email, display name/bio, user content (posts, lists, + documents), avatar image, user identifier, linked-OAuth identities, + and — with Phase 9 — the **device push token**. Map each to a purpose + (App Functionality / Account Management; the app does no tracking/ads). + *How:* skim `APIClient` request bodies; fill App Store Connect ▸ App + Privacy. Must match what the app actually sends (§11). + +**Push (Phase 9 — now in v1)** +- [ ] **APNs Auth Key (.p8).** The backend needs it to send pushes. + *How:* portal ▸ Certificates, IDs & Profiles ▸ **Keys** ▸ "+" ▸ enable + **Apple Push Notifications service (APNs)** ▸ download the `.p8` + **once** (non-recoverable). Hand the **Key ID + Team ID + .p8** to + the backend owner; enable **Push Notifications** on the App ID (§2.3). +- [ ] **APNs environment.** Confirm whether the backend sends via sandbox + (TestFlight/dev) vs production and that it keys off the right + `aps-environment`. + +**Upload tooling (optional, for CLI/CI)** +- [ ] **App Store Connect API key (.p8).** For scripted upload (§7.2). + *How:* App Store Connect ▸ Users and Access ▸ Integrations ▸ Keys ▸ + generate; note Issuer ID + Key ID; store the `.p8` securely. + +**Assets** +- [ ] **Screenshots** at 6.9" and 6.5" (§6). + *How:* boot the iPhone 16 Pro Max + a 6.5" simulator, then + `xcrun simctl io booted screenshot shot.png` per screen. + --- ## 1. Prerequisites (one-time) @@ -55,11 +134,16 @@ archive, but doing it explicitly avoids surprises: 1. 🌐 Developer portal ▸ Certificates, IDs & Profiles ▸ **Identifiers** ▸ "+". 2. Select **App IDs ▸ App**. Description: "InterlinedList". Bundle ID: **Explicit** = `com.interlinedlist.app`. -3. **Capabilities**: leave everything off for v1 *unless* you ship Phase 9 - (push) — then enable **Push Notifications** here (and in Xcode, §6 of - the roadmap). Nothing else is needed for the current feature set. +3. **Capabilities**: enable **Push Notifications** here — Phase 9 (push) is + in v1, so the App ID needs the push capability (and the matching Xcode + entitlement, §3.6). Leave everything else off; nothing else is needed + for the v1 feature set. 4. Save. + > **§2.3 APNs key.** Push also needs an **APNs Auth Key (.p8)** for the + > backend to send notifications — create it under Keys and hand the + > Key ID + Team ID + `.p8` to the backend owner (§0.1). + > You do **not** need an Associated Domains entry for the current OAuth / > deep-link flow — it uses the custom `interlinedlist://` URL scheme, which > requires no portal config. Only add Associated Domains if you later move @@ -131,11 +215,14 @@ archive, but doing it explicitly avoids surprises: rejected. ### 3.6 Capabilities currently needed 🖥️ -- **None** for the shipped feature set. Do **not** add entitlements you - don't use — unused entitlements (push, associated domains, etc.) can - trigger provisioning failures or review questions. -- Phase 9 (APNs) is the only thing that would add a capability later - (Push Notifications + the `aps-environment` entitlement). +- **Push Notifications** — required for v1 because Phase 9 (APNs) ships in + the first release. Target ▸ Signing & Capabilities ▸ "+ Capability" ▸ + **Push Notifications**; Xcode adds the `aps-environment` entitlement and + Push to the provisioning profile. Pair with enabling Push on the App ID + (§2.3) and creating the APNs key (§0.1). +- Do **not** add any *other* entitlement you don't use — unused + entitlements (associated domains, iCloud, etc.) can trigger provisioning + failures or review questions. --- @@ -184,8 +271,10 @@ archive, but doing it explicitly avoids surprises: - Complete the questionnaire honestly. A social app with user-generated content typically lands 12+/17+. **UGC apps must also** provide: a way to report objectionable content, block users, and a posted EULA/agreement — - Apple checks for these on social apps (Guideline 1.2). Confirm the app - exposes content reporting/blocking, or add it before review. + Apple checks for these on social apps (Guideline 1.2). **The app does not + have these yet** — they are tracked as **Phase 14 (UGC safety & + moderation)** in `GAP-NEXT-STEPS.md` and **must ship before this app can + pass review.** Treat Phase 14 as a hard gate on submission. ### 5.5 Export compliance ✅ handled by §3.5.1 - With `ITSAppUsesNonExemptEncryption=false`, no annual self-classification @@ -299,7 +388,8 @@ xcodebuild -exportArchive \ - **Anti-steering**: any hint of an external subscription/purchase. Keep the build silent on billing (§5.1). - **UGC safety (1.2)**: missing content reporting / user blocking / EULA on - a social app. Verify these exist before review. + a social app. **Currently missing — see Phase 14 in `GAP-NEXT-STEPS.md`; + it must ship before submission.** - **Broken demo login**: give reviewers working email/password creds. - **Privacy label mismatch**: the declared data must match what the app actually sends (email, posts, identifiers). @@ -309,16 +399,21 @@ xcodebuild -exportArchive \ --- ## 12. Quick pre-flight checklist +- [ ] §0.1 open questions resolved (team ID, app-name availability, demo + account, category, age-rating, guidelines/EULA URL, privacy inputs) - [ ] Apple Developer membership active; agreements accepted (§1) -- [ ] App ID `com.interlinedlist.app` registered (§2) +- [ ] App ID `com.interlinedlist.app` registered, **Push enabled** (§2) - [ ] Automatic signing, team set, archive signs cleanly (§3.1) - [ ] Build number incremented (§3.2) - [ ] App icon has no warnings / no alpha (§3.3) - [ ] `ITSAppUsesNonExemptEncryption=false` added (§3.5.1) - [ ] `armv7` device capability replaced with `arm64` (§3.5.2) -- [ ] No unused entitlements/capabilities (§3.6) +- [ ] **Push Notifications capability + `aps-environment` entitlement** set; + APNs `.p8` key created and handed to backend (§2.3 / §3.6 / §0.1) +- [ ] No *other* unused entitlements/capabilities (§3.6) - [ ] App Store Connect record + Free pricing + privacy/support URLs (§4) -- [ ] Content reporting / blocking present for UGC (§5.4) +- [ ] **Phase 14 UGC safety shipped** — report/block/mute + terms gate + (§5.4; `GAP-NEXT-STEPS.md` Phase 14) — **hard gate** - [ ] iPhone 6.9"/6.5" screenshots + description (§6) - [ ] Archive uploaded; build processed (§7) - [ ] TestFlight smoke test on device + demo login (§8) diff --git a/GAP-ENDPOINTS.md b/GAP-ENDPOINTS.md index f139c55..0a0a8f1 100644 --- a/GAP-ENDPOINTS.md +++ b/GAP-ENDPOINTS.md @@ -1,18 +1,21 @@ # GAP-ENDPOINTS — API contracts that are under-documented -This file no longer tracks *missing* endpoints — as of 2026-06-25 the iOS -app consumes every endpoint family it needs, and the high/medium backend -gaps (B0/B2/B3/B5) are resolved and shipped. What remains is a list of -**contracts that are live but under-documented or ambiguous**, where the -iOS client had to guess a shape, decode defensively, or work around an -inconsistency. +This file tracks two things: (1) **contracts that are live but +under-documented or ambiguous**, where the iOS client had to guess a shape, +decode defensively, or work around an inconsistency (most of the file); and +(2) the handful of **genuinely missing / blocked** endpoints in §F. + +The high/medium backend gaps that once blocked shipped phases (B0/B2/B3/B5) +are resolved. **One new high-priority gap was opened 2026-06-27:** §H — +moderation/UGC-safety endpoints (report/block/mute) are unverified, and +they gate **Phase 14**, an App Store ship-blocker. Resolve §H early. Each item says: what's unclear, what the iOS client currently assumes, and what documentation (or small contract change) would remove the guess. Sources cross-checked this pass: `/api/openapi.json` (route-generated) and the `/help/api/*` pages — which **disagree** in a few places noted below. -Last updated: 2026-06-25. +Last updated: 2026-06-27 (added §H — moderation endpoints, B10). --- @@ -164,6 +167,42 @@ These remain real gaps blocking specific iOS features: | **B6** | No tag discovery: `GET /api/tags/trending` / `GET /api/tags/autocomplete` don't exist. `?tag=` filtering works but has no discovery path. | Tag explorer + `#` autocomplete (the second half of Phase 13). | | **B9** | `GET /api/follow/{userId}/status` for the caller's **own** id returns 200 but omits `following`/`followedBy`/`pendingRequest`. | Edge case only — iOS never queries self-status in production; flagged for contract correctness. | | **B8** | No realtime (WebSocket/SSE) for feed/notifications; everything is pull-only. | Long-term; APNs (Phase 9) covers the highest-value push. | +| **B10** | **Moderation endpoints unverified** — it's unconfirmed whether report-content, report-user, block-user, blocked-list, and mute endpoints exist. See §H. | **Phase 14 (UGC safety) — a hard App Store ship-blocker.** | + +--- + +## H. Moderation / UGC-safety endpoints — unverified (Phase 14, ship-blocker) + +Apple Guideline 1.2 requires a UGC app to let users **report content**, +**block abusive users**, and accept a **community-guidelines EULA**. The +iOS app has none of this yet, and it's **not confirmed** which (if any) +backend endpoints back these flows. Phase 14 starts with a discovery pass; +record the real contracts here as they're confirmed, or escalate the +backend gap if they're missing. + +Candidate contracts the client will probe (names/shapes to confirm): + +| Need | Guessed endpoint | Must confirm | +|---|---|---| +| Report a message | `POST /api/messages/{id}/report` or `POST /api/report` | path, body (`reason`, free-text detail?), casing, response | +| Report a user | `POST /api/users/{id}/report` | same | +| Block a user | `POST /api/users/{id}/block` | path, whether it's idempotent, response shape | +| Unblock a user | `DELETE /api/users/{id}/block` | symmetric path | +| List blocked users | `GET /api/blocks` / `GET /api/user/blocks` | wrapping key, per-item fields | +| Mute a user (optional) | `POST /api/users/{id}/mute` | whether mute exists server-side at all, or is local-only | +| Server-side filtering | n/a | whether the backend already filters/queues reported content, or the client must hide it | + +**What would help:** publish whatever moderation surface exists (even if +partial), and confirm whether blocking is enforced server-side (blocked +users' content omitted from feed/replies/public responses) or whether the +client must filter locally. The web app's own report/block UI is the +reference — pointing to those routes would unblock this immediately. + +Also needed (not an endpoint, but a content dependency): a published +**Community Guidelines / zero-tolerance EULA** page URL the iOS terms-gate +can link to (Apple 1.2). Confirm whether `interlinedlist.com` already hosts +one (e.g. `/terms`, `/guidelines`, `/community`) or whether the app should +fall back to Apple's standard EULA. --- diff --git a/GAP-NEXT-STEPS.md b/GAP-NEXT-STEPS.md index ce80b6b..0141d3c 100644 --- a/GAP-NEXT-STEPS.md +++ b/GAP-NEXT-STEPS.md @@ -1,14 +1,29 @@ # GAP-NEXT-STEPS — iOS implementation roadmap What's left to build in this repo to bring the InterlinedList iOS app to -functionality parity with `interlinedlist.com`. +functionality parity with `interlinedlist.com` **and** clear the bar for a +first App Store submission. This is the **iOS-side** punchlist. For backend endpoints that still need -to ship before some of these can be done, see `GAP-ENDPOINTS.md`. - -Last updated: 2026-06-25 — after Phases 4, 5, 6, 7, 8, 12 and the -feed-search half of 13 shipped (plus B0 structured schema editing), -unblocked by the backend resolving B0/B2/B3/B5. +to ship before some of these can be done, see `GAP-ENDPOINTS.md`. For the +signing / submission mechanics, see `GAP-APPLE.md`. + +Last updated: 2026-06-27 — restructured around the remaining work after +Phases 2–8, 12 and feed-search shipped. Two things changed the shape of +this doc: + +1. **Target = full web parity** before the first store submission, with + **push (Phase 9) explicitly in v1**. +2. A new **ship-blocking** phase was added: **Phase 14 — UGC safety & + moderation**. The app is a social/UGC app with **no content reporting, + user blocking, muting, or terms-acceptance gate today** — Apple + Guideline 1.2 requires all of these before a UGC app can pass review + (see `GAP-APPLE.md` §5.4 / §11). This was previously only flagged in the + Apple doc; it is now a tracked implementation phase. + +The shipped-phase acceptance-criteria detail has been collapsed into the +table below (full history lives in git). Everything in the **Remaining +phases** section is genuinely unbuilt. ## Subscription / billing direction @@ -19,437 +34,323 @@ bundle. Subscription management is entirely on the web at `interlinedlist.com`. Full rationale and implementation details in `subscription-permissions-update.md`. +--- + ## ✅ Shipped phases | # | Phase | Shipped | Notes | |---|---|---|---| | 1 | Gap-closure + schema editor + subscriber awareness | 2026-06-23 | — | -| 2 | Auth surface parity | 2026-06-24 | Fully closed: reset, verify, OAuth ×5, identity linking, and email change (entry row + deep link + API + view) all in. | -| 3 | Profile / account management | 2026-06-24 | Avatar upload + from-URL, organizations strip, delete-account all in. | -| B0 | Structured list-schema editing | 2026-06-25 | `updateListSchemaStructured` + editor round-trips isVisible/isRequired/order; 409 → force-delete confirm. | -| 4 | Compose feature parity | 2026-06-25 | Cross-post toggles (Mastodon picker, Bluesky, LinkedIn, X) hidden for free users; repost (pushedMessageId); edit scheduledAt via PATCH; crossPostResults toast; metadata endpoint wired. | -| 5 | Follow surface parity | 2026-06-25 | Followers/following lists (paginated), mutual-count strip, remove-follower, tappable counts. | -| 6 | List collaboration / watchers | 2026-06-25 | WatchersListView (manager view) from owner's ListDetailView: roles, role picker, add/remove. Watch CTA on public lists (Phase 7). | -| 7 | Public browse end-to-end | 2026-06-25 | PublicListDetailView (read-only rows + Watch CTA), public Documents segment + reader. | -| 8 | Organizations | 2026-06-25 | Org list/detail/members CRUD, owner/admin/member roles, last-owner guard, create/edit/delete, join. | -| 12 | Settings panel | 2026-06-25 | SettingsView (theme→PATCH + applied via RootView, default visibility, advanced toggle, connected accounts, About webviews, sign-out) + NotificationPreferencesView (real catalog). | -| 13 | Feed search | 2026-06-25 | `.searchable` feed → `GET /api/messages/search`. Tag discovery still blocked on §B6. | - -Per-phase detail for shipped phases has been collapsed; the full -acceptance-criteria history lives in git. Remaining work: Phase 9 (APNs), -Phase 10 (documents sync/image upload), Phase 11 (GitHub — blocked §B4), -and the tag-discovery half of Phase 13 (blocked §B6). - -## Status snapshot — what works today - -The current app supports: - -- **Auth:** email/password login + register via `/api/auth/sync-token`, - Keychain token storage, 401 → auto-logout. Plus (Phase 2) password - reset, email-verification banner, OAuth sign-in for GitHub / Mastodon / - Bluesky / LinkedIn / X via `ASWebAuthenticationSession` (LinkedIn/X - hidden when their `/status` says unconfigured), identity linking / - disconnect, and email-change deep links. Custom URL scheme - `interlinedlist://` handles reset-password / verify-email / - verify-email-change / oauth callbacks. -- **Feed:** infinite-scroll messages, pull-to-refresh, dig/undig, - reply/delete, scheduled-at writes are wired in `postMessage`. -- **Compose:** text + image (1) + video (1) attachments; advanced toolbar - has placeholder `M`/`BS`/`in` icons (Mastodon/Bluesky/LinkedIn) that - are disabled stubs. -- **Lists:** CRUD, folder CRUD (folder UI hidden entirely for free users — see `subscription-permissions-update.md`), - schema editor with non-destructive DSL save, list connections, list - items add/edit/delete with typed fields. -- **Documents:** CRUD, folder CRUD, search. -- **Notifications:** tray fetch, read/mark-all-read, follow-request - approve/reject inline. -- **Profile:** view + edit (display name, bio, default visibility), - public profile view of other users with public lists & messages. Plus - (Phase 3) avatar upload from photo library + set-from-URL, - organizations strip on profile, and delete-account with - double-confirmation → forced logout. -- **Follow:** follow/unfollow, status, counts, requests. -- **Exports:** CSV for messages/lists/follows. -- **`customerStatus`** is now decoded on `User` with an `isSubscriber` - computed predicate. - -What's still missing — broken out into phases below. +| 2 | Auth surface parity | 2026-06-24 | reset, verify, OAuth ×5, identity linking, email change (entry + deep link + API + view). | +| 3 | Profile / account management | 2026-06-24 | Avatar upload + from-URL, organizations strip, delete-account. | +| B0 | Structured list-schema editing | 2026-06-25 | `updateListSchemaStructured` round-trips isVisible/isRequired/order; 409 → force-delete confirm. | +| 4 | Compose feature parity | 2026-06-25 | Cross-post toggles (Mastodon picker, Bluesky, LinkedIn, X) hidden for free users; repost; edit `scheduledAt`; crossPostResults toast; metadata endpoint wired. | +| 5 | Follow surface parity | 2026-06-25 | Followers/following (paginated), mutual-count strip, remove-follower, tappable counts. | +| 6 | List collaboration / watchers | 2026-06-25 | WatchersListView (roles, role picker, add/remove); Watch CTA on public lists. | +| 7 | Public browse end-to-end | 2026-06-25 | PublicListDetailView (read-only + Watch CTA), public Documents segment + reader. | +| 8 | Organizations | 2026-06-25 | Org list/detail/members CRUD, owner/admin/member roles, last-owner guard, create/edit/delete, join. *(Post-on-behalf-of-org NOT shipped → Phase 15.)* | +| 12 | Settings panel | 2026-06-25 | SettingsView (theme→PATCH, default visibility, advanced toggle, connected accounts, About webviews, sign-out) + NotificationPreferencesView. | +| 13a | Feed search | 2026-06-25 | `.searchable` feed → `GET /api/messages/search`. *(Tag discovery → Phase 13b, blocked §B6.)* | --- -## Phase 2 — Auth surface parity ✅ Shipped 2026-06-24 - -Password reset, email-verification banner + post gating, OAuth ×5 -(`ASWebAuthenticationSession`, LinkedIn/X hidden when unconfigured, -Mastodon instance prompt), identity linking/disconnect, and the -`interlinedlist://` deep-link handler (reset-password / verify-email / -verify-email-change / oauth callback) all landed. +## Status snapshot — what works today -**Shipped files:** `LoginView`, `RegisterView`, `ForgotPasswordView`, -`ResetPasswordView`, `LinkedIdentitiesView`, `EmailVerificationBanner`, -`OAuthSignInButton`, `Services/OAuthCoordinator`, `ChangeEmailView`, -`AuthState`, `InterlinedListApp` (URL scheme), `Info.plist` -(`CFBundleURLSchemes`). **APIClient:** `forgotPassword`, `resetPassword`, -`sendVerificationEmail`, `verifyEmail`, `verifyEmailChange`, -`linkedIdentities`, `unlinkIdentity`, `requestEmailChange`, -`linkedinStatus`, `twitterStatus`. +- **Auth:** email/password + register; Keychain token; 401 → re-validate + via `/api/user`. Password reset, email-verification banner, OAuth ×5 + (`ASWebAuthenticationSession`; LinkedIn/X hidden when unconfigured; + GitHub hidden — no native callback), identity linking/disconnect, + email-change deep links. Scheme `interlinedlist://`. +- **Feed:** infinite-scroll, pull-to-refresh, dig/undig, reply/delete, + scheduled writes, search, link previews. +- **Compose:** text + image + video; cross-post toggles (subscriber-gated); + repost; scheduled posts + edit. +- **Lists:** CRUD, folder CRUD (hidden for free users), structured schema + editor, connections, items with typed fields, watchers/roles. +- **Documents:** CRUD, folder CRUD, search, public reader. +- **Public browse:** other users' public lists (detail + Watch CTA) and + public documents. +- **Notifications:** tray, read/mark-all-read, follow-request approve/reject, + per-event preferences catalog. +- **Profile:** view/edit, avatar upload + from-URL, organizations strip, + delete-account (double-confirm → logout). +- **Follow:** follow/unfollow, status, counts, requests, + followers/following/mutuals, remove-follower. +- **Organizations:** full CRUD + members + roles + join. +- **Settings:** theme, default visibility, advanced toggle, connected + accounts, About webviews, sign-out. +- **Exports:** CSV for messages/lists/follows. -Email change is fully wired: `EditProfileView`'s Account section has a -tappable "Change" row that presents `ChangeEmailView` as a sheet, which -posts to `/api/user/change-email/request`; the `verify-email-change` -deep link then completes the change. No remaining items. +**Not yet present (the rest of this doc):** push notifications, inline +document image upload, offline document sync, **content reporting / user +blocking / muting / terms-acceptance gate**, posting on behalf of an org, +GitHub integration, tag discovery, realtime updates. --- -## Phase 3 — Profile / account management ✅ Shipped 2026-06-24 - -Avatar upload from photo library (`PhotosPicker` → `uploadAvatar`) and -set-from-URL (`setAvatarFromURL`), the organizations strip on -`UserProfileView` (`userOrganizations`), and delete-account with -double-confirmation → forced logout (`deleteAccount`) all landed in -`EditProfileView` / `UserProfileView`. The "Subscriber CTA on profile" -item was dropped 2026-06-24 (no subscription UI on iOS — see -`subscription-permissions-update.md`). +# Remaining phases ---- - -## Phase 4 — Compose feature parity **Medium** +Phases are grouped by tier. Within a tier they're independent and each is a +self-contained PR (or a small series). The recommended landing order is +top-to-bottom. -`ComposeView` has the scaffolding for cross-posting (the `M`/`BS`/`in` -buttons exist as `.disabled(true)` placeholders); `scheduledAt` is -already plumbed through `postMessage`. +## Tier 0 — Ship blockers (must land before the first App Store submission) -**Acceptance criteria:** +### Phase 14 — UGC safety & moderation **Large** ⛔ ship-blocker -- [ ] Replace the three placeholder cross-post buttons (`ComposeView.swift:170–193`) - with real toggles bound to state vars `crossPostToMastodon` - (per-instance), `crossPostToBluesky`, `crossPostToLinkedIn`, - `crossPostToTwitter`. -- [ ] Add an X/Twitter icon — currently only three placeholders. -- [ ] Mastodon picker driven by `GET /api/user/identities` filtered to - `provider == "mastodon"`; sends `mastodonProviderIds[]`. -- [ ] Pass `crossPostToBluesky`, `crossPostToLinkedIn`, - `crossPostToTwitter` to `APIClient.postMessage(...)`. -- [ ] **Hide** every cross-post control when - `authState.user?.isSubscriber != true`. No disable-with-paywall; - free users never see the controls. See - `subscription-permissions-update.md`. -- [ ] Surface `crossPostResults` from the response in a toast after - posting ("Posted to Bluesky ✓ · Mastodon ✗ rate-limited"). -- [ ] Confirm the scheduling UI (calendar icon + date picker) is - end-to-end — including the "future date required" validation. - Add tests around the ISO formatter. -- [ ] Edit scheduled posts: open `EditMessageView` for a scheduled - message, allow changing `scheduledAt` via - `PATCH /api/messages/:id`. -- [ ] Repost flow: "Repost" action on feed items → posts a new message - with `pushedMessageId` set, no `content`. -- [ ] **New endpoint surfaced 2026-06-23:** - `POST /api/messages/:id/metadata` — purpose not yet investigated - (link preview / OG-tag attach?). Worth a short discovery pass - during Phase 4 to decide whether to expose it. - -**Files:** `Views/ComposeView.swift`, `Views/EditMessageView.swift`, -`Views/FeedView.swift` (repost action), `Services/APIClient.swift` -(extend `postMessage` signature). - -**APIClient additions:** extend `postMessage` with cross-post params; -add `patchMessage(id:, scheduledAt:, scheduledCrossPostConfig:)`; possibly -`setMessageMetadata(...)`. - -**Dependencies:** `customerStatus` (already shipped). Phase 2 identities -endpoint for the Mastodon picker. +The app is a social/UGC app but exposes **no way to report content, block +or mute a user, or accept terms**. Apple Guideline 1.2 requires UGC apps to +provide: (1) a method to report objectionable content, (2) a mechanism to +block abusive users, (3) an EULA/community agreement the user accepts that +states zero tolerance for objectionable content/abusive behaviour, and +(4) published developer contact info (the support URL covers this). Without +1–3 the app **will be rejected**. ---- +There is currently no context menu or overflow action on feed rows +(`FeedView` has no `contextMenu`/`Menu`), so the entry points don't exist +yet anywhere. -## Phase 5 — Follow surface parity **Small** +> **Backend status: unverified.** It's not confirmed whether the backend +> exposes report/block/mute endpoints. **Start this phase with a short API +> discovery pass** (probe `/api/report`, `/api/users/:id/block`, +> `/api/blocks`, `/api/mute`, or whatever the web app calls). Record the +> real contracts in `GAP-ENDPOINTS.md` §H. If they don't exist, this phase +> is **blocked on backend** and that backend work becomes the true critical +> path to submission — escalate it immediately. **Acceptance criteria:** -- [ ] `FollowersListView` — `GET /api/follow/:userId/followers`, - paginated, push to `UserProfileView`. -- [ ] `FollowingListView` — symmetric. -- [ ] "Mutual" strip on `UserProfileView` — `GET /api/follow/:userId/mutual`. -- [ ] "Remove follower" action on FollowersListView (only for own - profile) — `DELETE /api/follow/:userId/remove`. -- [ ] Tap counts on `UserProfileView` to navigate to the lists. - -**Files:** new `Views/FollowersListView.swift`, -`Views/FollowingListView.swift`, `Views/UserProfileView.swift`. - -**APIClient additions:** `followers`, `following`, `mutualFollows`, -`removeFollower`. - -**Dependencies:** none. +- [ ] **Discovery pass:** confirm the report/block/mute endpoint shapes; + document them in `GAP-ENDPOINTS.md` §H (or file the backend gap). +- [ ] **Report content:** overflow (`Menu`) on every message row + (`FeedView`, `MessageThreadView`, public list/profile message rows) + with a "Report…" action → `ReportSheet` (reason picker + optional + detail) → `POST` report endpoint. Confirmation toast. +- [ ] **Report user:** "Report @user" on `UserProfileView`. +- [ ] **Block user:** "Block @user" on the message overflow and + `UserProfileView`. Blocking hides that user's content from the feed, + replies, and public views (optimistic local filter + server call). +- [ ] **Mute user (optional if no backend):** local-only hide if there's no + mute endpoint; server-backed if there is. +- [ ] **Blocked-users management:** new `BlockedUsersView` reachable from + `SettingsView`; list blocked users, unblock. +- [ ] **Terms / community-guidelines acceptance:** add a required "I agree + to the Terms & Community Guidelines" control to `RegisterView` that + blocks submit until checked, linking to `/terms` and a + community-guidelines/zero-tolerance page (confirm the URL exists — + see `GAP-APPLE.md` open questions). Surface the same links in + `SettingsView` ▸ About. +- [ ] Accessibility labels on all new controls; `#Preview` for new views. +- [ ] Unit tests for the new APIClient methods (MockURLSession) and a + decoding test for the report/block models. + +**Files:** new `Views/ReportSheet.swift`, `Views/BlockedUsersView.swift`, +new `Models/Moderation.swift`; edits to `Views/FeedView.swift`, +`Views/MessageThreadView.swift`, `Views/UserProfileView.swift`, +`Views/PublicListDetailView.swift`, `Views/RegisterView.swift`, +`Views/SettingsView.swift`, `Services/AppDataStore.swift` (local block +filter), `Services/APIClient.swift`. + +**APIClient additions:** `reportMessage`, `reportUser`, `blockUser`, +`unblockUser`, `blockedUsers`, (`muteUser`/`unmuteUser` if supported). + +**Dependencies:** backend report/block/mute endpoints (unverified — §H). + +### Phase 0.5 — Pre-submission Info.plist / project hygiene **Tiny** ⛔ ship-blocker + +Not a feature, but it gates the first upload and is a one-file PR. Fully +specified in `GAP-APPLE.md` §3.5 — pulled here so it isn't lost: + +- [ ] Add `ITSAppUsesNonExemptEncryption=false` to `Info.plist`. +- [ ] Replace the stale `armv7` entry in `UIRequiredDeviceCapabilities` + with `arm64`. +- [ ] Confirm `AppIcon` has no empty wells / no alpha. + +**Files:** `InterlinedList/Info.plist`, `Assets.xcassets`. --- -## Phase 6 — List collaboration / watchers **Large** - -Site supports three roles per public list: Watcher, Collaborator, -Manager. iOS has nothing. - -**Acceptance criteria:** - -- [ ] `WatchersListView` on a list — `GET /api/lists/:id/watchers/users` - with each user's role. -- [ ] "My role" badge — `GET /api/lists/:id/watchers/me`. -- [ ] "Watch" CTA on public list detail — `POST /api/lists/:id/watchers`. -- [ ] Manager-only: change role via picker - (`PUT /api/lists/:id/watchers/:userId`). -- [ ] Manager-only: remove member - (`DELETE /api/lists/:id/watchers/:userId`). -- [ ] **Permission model**: `ListDetailView` hides schema editor for - non-Managers, hides row add/edit/delete for non-Collaborators, - shows read-only view for Watchers. Plumb the current user's role - from `/watchers/me` down to all child views. -- [ ] Confirm role values match docs once they're published (the docs - list endpoints but don't enumerate role strings — see - `GAP-ENDPOINTS.md` §B5). - -**Files:** new `Views/WatchersListView.swift`, `Views/ListsView.swift` -(permission gating), new `Models/ListWatcher.swift`. - -**APIClient additions:** `listWatchers`, `listWatchersUsers`, -`myListRole`, `addWatcher`, `setWatcherRole`, `removeWatcher`. - -**Dependencies:** Phase 7 (need public-list browse to give Watchers a -target to watch). +## Tier 1 — v1 parity features ---- - -## Phase 7 — Public browse end-to-end **Small** +### Phase 9 — Push notifications (APNs) **Medium** ⭑ in v1 -`UserProfileView` already lists a user's public lists. There's no detail -flow yet. +Backend ships `POST /api/push/register` / `DELETE /api/push/unregister` +with `platform: "ios"`. Confirmed in scope for the first release, so the +Xcode capability + entitlement land now (coordinate with `GAP-APPLE.md` +§2/§3.6 and the App ID push capability). **Acceptance criteria:** -- [ ] Tap a public list on a profile → `PublicListDetailView` - (`GET /api/users/:username/lists/:id` + `/data`). -- [ ] Same view as the owner's `ListDetailView` but read-only, with a - "Watch" CTA (depends on Phase 6 endpoint). -- [ ] `PublicDocumentsView` on a profile — - `GET /api/users/:username/documents`. -- [ ] Tap a public document → read-only renderer. - -**Files:** new `Views/PublicListDetailView.swift`, -`Views/PublicDocumentsView.swift`, new `Views/PublicDocumentReader.swift`. - -**APIClient additions:** `publicListDetail`, `publicListData`, -`publicDocuments`, `publicDocument`. +- [ ] Add the **Push Notifications** capability + `aps-environment` + entitlement to the Xcode project; enable Push on the App ID in the + developer portal (`GAP-APPLE.md` §2.3). +- [ ] Request notification permission on first launch **after login** + (not at cold start). +- [ ] On `didRegisterForRemoteNotificationsWithDeviceToken`, POST the hex + token to `/api/push/register`. +- [ ] On logout / token rotation, `DELETE /api/push/unregister`. +- [ ] Handle taps: route the payload's `actionUrl` through the existing + `interlinedlist://` deep-link handler (Phase 2). +- [ ] Foreground-presentation + badge handling; clear badge on app open. +- [ ] Notification preferences already have a UI (Phase 12); confirm the + catalog covers the push event types the server actually sends. + +**Files:** new `Services/PushService.swift`, `InterlinedListApp.swift` +(lifecycle hooks via an `UIApplicationDelegateAdaptor`), Xcode project +(entitlements + capability). -**Dependencies:** none for browsing; the "Watch" CTA needs Phase 6. +**APIClient additions:** `registerPushDevice`, `unregisterPushDevice`. ---- +**Dependencies:** Phase 2 (deep-link handler); signing/entitlement work in +`GAP-APPLE.md`. -## Phase 8 — Organizations **Large** +### Phase 10 — Document inline image upload **Small** -Site has full org CRUD with `owner`/`admin`/`member` roles. iOS has -nothing. +Self-contained; the public reader already shipped (Phase 7). Offline delta +sync is split out to Phase 16. **Acceptance criteria:** -- [ ] "Organizations" entry in profile → `OrganizationsListView`. -- [ ] Create / rename / delete orgs (owner-only). -- [ ] Members list + role picker - (`/api/organizations/:id/members*`). -- [ ] Enforce "last owner cannot be demoted/removed" client-side with a - disabled control + tooltip. -- [ ] Post-on-behalf-of-org from `ComposeView` (optional v1.5 — only - ship if the message endpoint accepts an `organizationId`-style - field; otherwise document as deferred). -- [ ] LinkedIn-per-org integration is **deferred** — complex, low iOS - relevance for a v1. +- [ ] In the document editor, pick/paste an image (`PhotosPicker`) → + `POST /api/documents/:id/images/upload` → insert `![alt](url)` at the + cursor. +- [ ] Upload progress + failure handling (reuse the message image-upload + patterns from `uploadImage`). +- [ ] Accessibility label on the insert control; `#Preview` unaffected. +- [ ] APIClient unit test with MockURLSession (multipart shape). -**Files:** new `Views/OrganizationsListView.swift`, -`Views/OrganizationDetailView.swift`, `Views/OrganizationMembersView.swift`, -new `Models/Organization.swift`. +**Files:** `Views/DocumentsView.swift` (image insertion handlers), +`Services/APIClient.swift`. -**APIClient additions:** full org CRUD + members CRUD. +**APIClient additions:** `uploadDocumentImage(documentId:data:mimeType:)`. **Dependencies:** none. ---- - -## Phase 9 — Push notifications (APNs) **Medium** +### Phase 15 — Post on behalf of an organization **Small** -Backend ships `POST /api/push/register` and `DELETE /api/push/unregister` -with `platform: "ios"`. No iOS push support today. +Phase 8 shipped org CRUD but **not** posting as an org. `postMessage` has +no `organizationId` field and `ComposeView` has no author picker. The web +app lets owners/admins post as an org. **Acceptance criteria:** -- [ ] Add Push Notifications capability + APNs entitlement to the Xcode - project. -- [ ] Request user permission on first launch after login. -- [ ] On `didRegisterForRemoteNotificationsWithDeviceToken`, ship the - token to `POST /api/push/register` (hex format). -- [ ] On logout / token rotation, call `DELETE /api/push/unregister`. -- [ ] Handle incoming notification payloads: tap → deep link via the - notification's `actionUrl` (use the same URL handler from - Phase 2). -- [ ] **Notification preferences screen** — blocked on backend: there's - no endpoint that enumerates which event types exist. See - `GAP-ENDPOINTS.md` §B3. Until that ships, iOS receives whatever - the server decides to send based on user-profile settings updated - via the web. - -**Files:** new `Services/PushService.swift`, -`InterlinedListApp.swift` (lifecycle hooks), Xcode project -(entitlements + capability). +- [ ] Confirm the create-message endpoint accepts an org-author field + (name + camelCase casing — likely `organizationId` or + `postAsOrganizationId`). If it doesn't, document as deferred in + `GAP-ENDPOINTS.md` and stop. +- [ ] "Post as" picker in `ComposeView` (self vs. each org where the user + is owner/admin), driven by `userOrganizations()` filtered by role. +- [ ] Thread the chosen org id through `postMessage(...)`. +- [ ] Show the org as author on the resulting feed row. -**APIClient additions:** `registerPushDevice`, `unregisterPushDevice`. +**Files:** `Views/ComposeView.swift`, `Services/APIClient.swift` +(extend `postMessage` + `CreateMessageBody`). -**Dependencies:** Phase 2 (deep-link handler for `actionUrl`). +**Dependencies:** Phase 8 (orgs). Backend confirmation of the author field. --- -## Phase 10 — Documents enhancements **Large** +## Tier 2 — Larger / later parity + +### Phase 16 — Document offline delta sync **Large** + +Split out of the old Phase 10. This is its own mini-project and should ship +behind a feature flag. Deferred relative to image upload. **Acceptance criteria:** -- [ ] Inline image upload in document editor: paste/drag image → - `POST /api/documents/:id/images/upload` → insert `![alt](url)` - at cursor. - [ ] Delta sync via `/api/documents/sync` (GET + POST): - [ ] Background fetch every N minutes when authenticated. - - [ ] Offline edits queued and POSTed as batch on reconnect. - - [ ] Conflict resolution: last-write-wins per doc (server side) - with a banner if the user's local copy was overwritten. - - [ ] Significant rework to `AppDataStore` — treat as its own - mini-project; ship under a feature flag first. -- [ ] Public document reader for `/api/documents/:id` when the - document is `isPublic`. - -**Files:** new `Services/DocumentSyncService.swift`, -`Views/DocumentsView.swift` (image insertion handlers), -new `Views/PublicDocumentReader.swift` (also referenced by Phase 7). - -**APIClient additions:** `uploadDocumentImage`, `syncDocuments(lastSyncAt:)`, -`pushDocumentBatch(...)`. + - [ ] Offline edits queued and POSTed as a batch on reconnect. + - [ ] Conflict resolution: last-write-wins per doc (server side) with a + banner if the local copy was overwritten. +- [ ] Significant rework to `AppDataStore` (treat sync as a distinct + service); ship under a feature flag first. -**Dependencies:** none for image upload; sync is independent of other -phases but heavy. +**Files:** new `Services/DocumentSyncService.swift`, `AppDataStore.swift`, +`Views/DocumentsView.swift` (conflict banner). ---- +**APIClient additions:** `syncDocuments(lastSyncAt:)`, `pushDocumentBatch`. -## Phase 11 — GitHub integration **Medium** +**Dependencies:** Phase 10 desirable first (shared editor surface), but +independent. -Backend exposes `/api/github/repos`, `/api/github/issues`, etc. — but -they require **session cookie** auth (Bearer tokens not accepted). iOS -uses Bearer tokens. +### Phase 17 — Realtime updates (WebSocket / SSE) **Large** (long-term) -**Acceptance criteria:** +Everything is pull-only today (`GAP-ENDPOINTS.md` §B8). With APNs (Phase 9) +covering the highest-value pushes, realtime is a polish item: live feed / +notification updates without a manual refresh. **Blocked** until the +backend exposes a realtime channel — keep documented, unscheduled. -- [ ] Detect whether the current user has an active GitHub identity - (Phase 2 endpoint). -- [ ] If GitHub features are needed in iOS, two options — pick one: - - [ ] (a) Add session-cookie support to APIClient (cookie jar + - cookie-based auth flow), used only for `/api/github/*`. Or - - [ ] (b) Defer to backend: ask for Bearer-token support on the - GitHub endpoints. See `GAP-ENDPOINTS.md` §B4. -- [ ] If (a): GitHub-backed list creation, "create issue from message", - assignee/label pickers, "next issue number" helper. - -**Recommendation:** ask the backend first. Cookie support on iOS is -fragile and bypasses our Bearer-token security model. Mark this phase -**deferred** until the backend decision lands. - -**Files (if pursued):** new `Services/GitHubService.swift`, -`Views/GitHubBackedListView.swift`, `Views/CreateIssueFromMessageView.swift`. +**Dependencies:** backend realtime endpoint (§B8). --- -## Phase 12 — Settings panel + webview content **Small** +## Tier 3 — Blocked on backend (documented, not scheduled) -There is currently **no Settings view** in the app. Account-level -settings sit inside `EditProfileView`; preferences like theme and -default visibility are partial. +### Phase 11 — GitHub integration **Medium** — deferred (§B4) -**Acceptance criteria:** +`/api/github/*` requires **session-cookie** auth and rejects Bearer tokens; +iOS is Bearer-only. Recommendation unchanged: **ask the backend** for +Bearer support (or a `session-from-bearer` exchange) before building +anything. Do not add a cookie jar to APIClient just for GitHub — it +bypasses the Bearer security model. Deferred until the backend decision +lands. -- [ ] New `Views/SettingsView.swift`, presented from a gear icon on - `MainTabView` or as a section in the profile tab. Surface: - - [ ] Theme (`light` / `dark` / `system`) — bound to - `PATCH /api/user/update` `theme` field. - - [ ] Default visibility — already on `EditProfileView`; move here. - - [ ] Max message length — read-only display from `user.maxMessageLength`. - - [ ] Show advanced post settings — boolean. - - [ ] Connected accounts → Phase 2 `LinkedIdentitiesView`. - - - [ ] Notification preferences → Phase 9 (currently blocked). - - [ ] About → `SFSafariViewController` for `/blog`, `/pricing`, - `/terms`, `/privacy`, `/help/branding`. - - [ ] Sign out (move from `UserProfileView`). -- [ ] `SettingsView` is the natural home for many things that have been - bolted onto `EditProfileView`. Refactor accordingly. - -**Files:** new `Views/SettingsView.swift`, `Views/MainTabView.swift` -(entry point), `Views/EditProfileView.swift` (slim down), -`Views/UserProfileView.swift` (remove Sign-Out — now in Settings). - -**Dependencies:** none for the static portions; Phase 2/3/9 each light -up additional rows as they ship. +**Files (if pursued):** `Services/GitHubService.swift`, +`Views/GitHubBackedListView.swift`, `Views/CreateIssueFromMessageView.swift`. ---- +### Phase 13b — Tag discovery **Small** — blocked (§B6) -## Phase 13 — Feed search + tag discovery **Small** (blocked) +Feed search shipped (13a). Tag discovery needs endpoints that don't exist: +`GET /api/tags/trending` and `GET /api/tags/autocomplete`. `?tag=` +filtering already works, but there's no discovery/autocomplete path. -The website filters the feed by hashtag (`?tag=X`) and presumably -surfaces tag suggestions. iOS has no search box on the feed and no tag -explorer. +When the endpoints ship: -**Blocked on backend gaps** — see `GAP-ENDPOINTS.md` §B2 (message -search) and §B6 (tag discovery). Without those endpoints, iOS can only -support tag filtering via direct entry, which has no discovery path. +- [ ] Tag explorer (trending) → tap a tag → filtered feed. +- [ ] `#…` autocomplete inside `ComposeView`. -When the endpoints ship: +### Phase 18 — LinkedIn org cross-post targets **Small** — blocked (§D2) -- [ ] Search bar on `FeedView` — `GET /api/messages/search?q=...`. -- [ ] Tag explorer — `GET /api/tags/trending` or similar; tap a tag → - filtered feed. -- [ ] Tag autocomplete inside `ComposeView` `#…` entry. +Cross-posting ships the simple `crossPostToLinkedIn` boolean. Targeting a +specific LinkedIn **organization** page needs the `linkedInTargets[].kind` +vocabulary (`personal`/`organization`?) and whether an org target carries +an `organizationId` — both undocumented (§D2). Low iOS relevance; ship only +after the contract is published. --- ## Effort summary -| # | Phase | Effort | Status | -|---|---|---|---| -| 2 | Auth (reset / verify / OAuth ×5 / linking / email change) | Medium | ✅ shipped 2026-06-24 | -| 3 | Profile / avatar / orgs / delete account | Small | ✅ shipped 2026-06-24 | -| 4 | Compose: schedule + cross-post + gating + edit / repost | Medium | ✅ shipped 2026-06-25 | -| 5 | Followers / following / mutuals / remove-follower | Small | ✅ shipped 2026-06-25 | -| 6 | List watchers / roles / permission model | Large | ✅ shipped 2026-06-25 | -| 7 | Public browse end-to-end | Small | ✅ shipped 2026-06-25 | -| 8 | Organizations | Large | ✅ shipped 2026-06-25 | -| 9 | Push notifications (APNs) | Medium | not started (needs Xcode capability + entitlement) | -| 10 | Documents: image upload + sync + public reader | Large | public reader shipped (Phase 7); image upload + sync not started | -| 11 | GitHub integration | Medium | **deferred** (auth model conflict, §B4) | -| 12 | Settings panel + webview content | Small | ✅ shipped 2026-06-25 | -| 13 | Feed search + tag discovery | Small | search ✅ shipped 2026-06-25; tag discovery **blocked on §B6** | - -With Phases 1–8 and 12 shipped (plus B0 and feed search), the remaining -work is **Phase 9** (APNs push — requires the Push Notifications -capability + APNs entitlement in the Xcode project, so it needs a signing -config decision), **Phase 10** (document image upload + delta sync — the -public reader already shipped under Phase 7), **Phase 11** (GitHub — -deferred on §B4), and the **tag-discovery** half of Phase 13 (blocked on -§B6). Everything unblocked and code-only is now done; the full test suite -is green (365 tests). +| # | Phase | Tier | Effort | Status | +|---|---|---|---|---| +| 14 | UGC safety & moderation (report/block/mute + terms gate) | 0 | Large | ⛔ ship-blocker — **start here**; backend unverified (§H) | +| 0.5 | Info.plist / project hygiene | 0 | Tiny | ⛔ ship-blocker (one-file PR; `GAP-APPLE.md` §3.5) | +| 9 | Push notifications (APNs) | 1 | Medium | ⭑ in v1 — needs Xcode capability + entitlement + App ID push | +| 10 | Document inline image upload | 1 | Small | not started | +| 15 | Post on behalf of an organization | 1 | Small | not started; backend author-field unconfirmed | +| 16 | Document offline delta sync | 2 | Large | not started (feature-flagged) | +| 17 | Realtime updates (WebSocket/SSE) | 2 | Large | blocked (§B8) | +| 11 | GitHub integration | 3 | Medium | deferred (§B4) | +| 13b | Tag discovery | 3 | Small | blocked (§B6) | +| 18 | LinkedIn org cross-post targets | 3 | Small | blocked (§D2) | + +**Critical path to the App Store:** Phase 14 + Phase 0.5 (Tier 0) must land +first, then the v1 parity features (9, 10, 15). Tier 2/3 can follow the +first submission. The single biggest risk is Phase 14's backend dependency — +resolve the discovery pass early. --- ## How to use this doc -- Pick a phase; check the acceptance criteria; ship them in order. +- Pick a phase; check the acceptance criteria; ship them in order + (Tier 0 → 1 → 2 → 3). - Mark items `[x]` as they land. -- Each phase is independently shippable behind a feature flag if needed. -- When a phase completes, move its summary line to a "✅ Done" section - at the top of this file (delete the per-phase detail to keep the doc - scannable). -- For any new endpoint discovered mid-phase that doesn't yet exist on - the backend, add it to `GAP-ENDPOINTS.md` instead of inlining it - here. +- Each phase is an independent PR (or a small series) — feature-flag where + noted. +- When a phase completes, move its summary line into the **Shipped phases** + table and delete its per-phase detail to keep the doc scannable. +- For any new endpoint discovered mid-phase that doesn't yet exist on the + backend, add it to `GAP-ENDPOINTS.md` instead of inlining it here. diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index d930e83..e13be09 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -535,6 +535,19 @@ final class APIClient { return folder } + /// Soft-deletes a document folder. The server cascades the delete to any + /// subfolders and documents inside it (`DELETE /api/documents/folders/{id}`). + func deleteDocumentFolder(id: String) async throws { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + guard let url = URL(string: baseURL + "/api/documents/folders/\(encoded)") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + func searchDocuments(q: String, limit: Int = 20, offset: Int = 0) async throws -> ([Document], Pagination?) { let qEncoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q struct Response: Decodable { let documents: [Document]; let pagination: Pagination? } diff --git a/InterlinedList/Services/AppDataStore.swift b/InterlinedList/Services/AppDataStore.swift index 25da726..0b9141a 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -127,6 +127,7 @@ final class AppDataStore: ObservableObject { } func removeDocument(id: String) { documents.removeAll { $0.id == id }; saveToCache() } func insertDocumentFolder(_ folder: DocumentFolder) { documentFolders.append(folder); saveToCache() } + func removeDocumentFolder(id: String) { documentFolders.removeAll { $0.id == id }; saveToCache() } func reset() { feedMessages = [] diff --git a/InterlinedList/Views/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index d2ce0c3..57e8aa9 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 folderToDelete: DocumentFolder? + @State private var showDeleteFolderConfirm = false @State private var searchText = "" @State private var searchResults: [Document] = [] @State private var isSearching = false @@ -120,6 +122,30 @@ struct DocumentsView: View { store.insertDocumentFolder(newFolder) } } + .confirmationDialog( + folderToDelete.map { "Delete “\($0.name)”?" } ?? "Delete folder?", + isPresented: $showDeleteFolderConfirm, + titleVisibility: .visible, + presenting: folderToDelete + ) { folder in + Button("Delete Folder & Contents", role: .destructive) { + Task { await deleteFolder(folder) } + } + } message: { _ in + Text("This also deletes any documents and subfolders inside it.") + } + } + } + + private func deleteFolder(_ folder: DocumentFolder) async { + do { + try await APIClient.shared.deleteDocumentFolder(id: folder.id) + store.removeDocumentFolder(id: folder.id) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + // Re-sync so a failed delete doesn't leave the folder missing from the UI. + await store.refreshDocuments() } } @@ -222,6 +248,22 @@ struct DocumentsView: View { NavigationLink(destination: DocumentFolderView(folder: folder)) { Label(folder.name, systemImage: "folder") } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + folderToDelete = folder + showDeleteFolderConfirm = true + } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button(role: .destructive) { + folderToDelete = folder + showDeleteFolderConfirm = true + } label: { + Label("Delete Folder", systemImage: "trash") + } + } } } } @@ -262,6 +304,8 @@ private struct DocumentFolderView: View { @State private var isLoading = false @State private var showCreate = false @State private var showCreateFolder = false + @State private var folderToDelete: DocumentFolder? + @State private var showDeleteFolderConfirm = false var body: some View { Group { @@ -306,6 +350,18 @@ private struct DocumentFolderView: View { subfolders.append(newFolder) } } + .confirmationDialog( + folderToDelete.map { "Delete “\($0.name)”?" } ?? "Delete folder?", + isPresented: $showDeleteFolderConfirm, + titleVisibility: .visible, + presenting: folderToDelete + ) { sub in + Button("Delete Folder & Contents", role: .destructive) { + Task { await deleteFolder(sub) } + } + } message: { _ in + Text("This also deletes any documents and subfolders inside it.") + } } private var folderList: some View { @@ -316,6 +372,22 @@ private struct DocumentFolderView: View { NavigationLink(destination: DocumentFolderView(folder: sub)) { Label(sub.name, systemImage: "folder") } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + folderToDelete = sub + showDeleteFolderConfirm = true + } label: { + Label("Delete", systemImage: "trash") + } + } + .contextMenu { + Button(role: .destructive) { + folderToDelete = sub + showDeleteFolderConfirm = true + } label: { + Label("Delete Folder", systemImage: "trash") + } + } } } } @@ -364,6 +436,18 @@ private struct DocumentFolderView: View { try? await APIClient.shared.deleteDocument(id: doc.id) } } + + private func deleteFolder(_ sub: DocumentFolder) async { + do { + try await APIClient.shared.deleteDocumentFolder(id: sub.id) + subfolders.removeAll { $0.id == sub.id } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + // Re-sync this folder's contents so a failed delete is reflected accurately. + await load() + } + } } private struct DocumentRow: View { diff --git a/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift b/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift index efa64d3..5d01d66 100644 --- a/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift @@ -142,4 +142,23 @@ final class APIClientDocumentsTests: XCTestCase { XCTAssertTrue(body.contains("\"parentId\""), "expected camelCase parentId, got: \(body)") XCTAssertFalse(body.contains("parent_id")) } + + // MARK: deleteDocumentFolder() + + func test_deleteDocumentFolder_sendsDeleteToCorrectPath() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.deleteDocumentFolder(id: "f1") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/folders/f1") + } + + func test_deleteDocumentFolder_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.deleteDocumentFolder(id: "f1") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } }