diff --git a/.claude/commands/comment-and-commit.md b/.claude/commands/comment-and-commit.md new file mode 100644 index 0000000..65df7b0 --- /dev/null +++ b/.claude/commands/comment-and-commit.md @@ -0,0 +1,57 @@ +# /comment-and-commit + +Stage the work that's waiting in the working tree, compose a high-quality commit message that accurately describes it, and commit. **Does not push.** + +Optional argument (`$ARGUMENTS`): extra context to fold into the message — a ticket id, a scope hint, or an instruction like "split into two commits". + +## Conventions (must follow) + +- **Conventional Commits**: `type(scope): summary` — imperative mood, summary ≤ 72 chars. Types used in this repo: `feat`, `fix`, `docs`, `test`, `refactor`, `chore` (and combined forms like `test+docs`). Scope matches the area touched (`auth`, `documents`, `settings`, `orgs`, `lists`, …). +- The summary says **what changed and why**, never "update files" / "changes". +- Every commit message **ends with this trailer, exactly**: + ``` + Co-Authored-By: Claude Opus 4.8 (1M context) + ``` +- **Never push** from this command. +- **Never commit on the default branch** (`main`): if HEAD is `main`, create and switch to a descriptive feature branch first. + +## Steps + +1. **Survey what's waiting** — never commit blind: + ```bash + git status + git diff --stat HEAD + git diff HEAD # full diff: staged + unstaged + ``` + +2. **Safety-check the branch**: + ```bash + git rev-parse --abbrev-ref HEAD + ``` + If it prints `main`, branch first: `git switch -c /`. + +3. **Review, then stage.** Read the diff and decide what belongs in this commit. By default stage everything that's part of the work: + ```bash + git add -A + ``` + Unstage anything machine-local or unrelated (`git restore --staged `). Treat `.claude/settings.local.json` with suspicion — it accumulates session permission grants; include it only if those changes are intentional. + +4. **Compose the message from the actual diff.** If the changes span clearly independent concerns, either (a) pick the strongest `type(scope)` for the summary and enumerate the other areas as body bullets, or (b) if the user asked for it, split into multiple commits via per-path / `git add -p` staging and repeat steps 3–5 per commit. + +5. **Commit** with a HEREDOC so the body and trailer survive intact: + ```bash + git commit -F - <<'EOF' + type(scope): concise imperative summary + + - concrete change and the reason it was needed + - another concrete change + + Co-Authored-By: Claude Opus 4.8 (1M context) + EOF + ``` + +6. **Confirm and report:** + ```bash + git show --stat HEAD + ``` + Report the commit hash + subject. Do not push; mention that `/commit-and-pr` will push and open a PR if that's the next step. diff --git a/.claude/commands/commit-and-pr.md b/.claude/commands/commit-and-pr.md new file mode 100644 index 0000000..5a52604 --- /dev/null +++ b/.claude/commands/commit-and-pr.md @@ -0,0 +1,57 @@ +# /commit-and-pr + +Commit all waiting work using the `/comment-and-commit` flow, push the branch, and open a pull request whose description summarizes everything the PR includes. + +Optional argument (`$ARGUMENTS`): a PR title hint, a base-branch override, or extra context for the PR body. + +## Conventions (must follow) + +- **Runs `/comment-and-commit` first**, honoring all of its rules (Conventional Commits, the `Co-Authored-By` trailer, never committing on `main`). +- **Base branch** defaults to `main` unless `$ARGUMENTS` overrides it. +- The **PR body ends with this line, exactly**: + ``` + 🤖 Generated with [Claude Code](https://claude.com/claude-code) + ``` +- Opening a PR is outward-facing: verify the branch, base, and commit list look right before creating it. + +## Steps + +1. **Commit waiting work.** Perform every step of `/comment-and-commit` so the tree is clean and all work is committed. If there's nothing to commit but the branch is already ahead of the base, continue. + +2. **Determine branch + base, and preview the PR contents:** + ```bash + git rev-parse --abbrev-ref HEAD # feature branch — must not be the base + git log --oneline main..HEAD # every commit this PR will include + ``` + If HEAD equals the base branch, stop — step 1 should have branched. + +3. **Push and set upstream:** + ```bash + git push -u origin HEAD + ``` + +4. **Compose the PR** to describe the whole branch, not just the last commit: + - **Title** — one Conventional-Commits-style line summarizing the branch. + - **Body** — a `## Summary` paragraph; a `## What's included` list grouped from `git log main..HEAD`; a `## Testing` note (what was built/run and the result); and any caveats or follow-ups. + +5. **Create the PR** (HEREDOC body keeps formatting and the trailer): + ```bash + gh pr create --base main --head "$(git rev-parse --abbrev-ref HEAD)" \ + --title "type(scope): summary of the whole branch" \ + --body "$(cat <<'EOF' + ## Summary + One or two sentences on the goal of this PR. + + ## What's included + - area: concrete change (commits abc123, def456) + - area: concrete change + + ## Testing + - `xcodebuild ... test` — N passed + + 🤖 Generated with [Claude Code](https://claude.com/claude-code) + EOF + )" + ``` + +6. **Report the PR URL** that `gh` prints. If `gh` isn't authenticated, surface the error and tell the user to run `! gh auth login` in the prompt (so the interactive login lands in this session), then re-run. diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 7ad9a8c..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"6d6624c2-5cae-4f89-8ac0-20f638d96b77","pid":80608,"procStart":"Tue Jun 23 01:31:15 2026","acquiredAt":1782268028329} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f46e94e..dc84215 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -67,7 +67,9 @@ "Bash(git checkout *)", "Bash(git branch *)", "Bash(plutil *)", - "Bash(git stash *)" + "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\")" ], "additionalDirectories": [ "/Users/adron/Codez/interlinedlist-ios/.claude" diff --git a/.codex/agents/swift-dev.toml b/.codex/agents/swift-dev.toml deleted file mode 100644 index c6fbc84..0000000 --- a/.codex/agents/swift-dev.toml +++ /dev/null @@ -1,64 +0,0 @@ -name = "swift-dev" -description = """ -iOS/Swift development agent for the InterlinedList app. Use for implementing features, -refactoring Swift code, adding SwiftUI views, updating models/services, and diagnosing -Xcode build errors. Enforces SOLID, KISS, and project-specific conventions automatically. - -Examples: -- "Add a profile view that shows the logged-in user's details" -- "Refactor FeedView to extract MessageRow into its own file" -- "Fix the build error in APIClient around the decoder" -- "Add pagination support to ListsView"""" -developer_instructions = ''' -You are an expert iOS/Swift engineer working on **InterlinedList**, a SwiftUI app that connects to the `interlinedlist.com` API. - -## Mandatory principles - -### SOLID -- **Single Responsibility:** One `View` struct renders one distinct UI unit. One `Service` class owns one domain (networking, keychain, auth state). Never let a view reach into `APIClient` directly if `AuthState` or a dedicated service should mediate it. -- **Open/Closed:** Add behavior through new types or protocol conformances. Do not edit existing types to handle new special cases — extract instead. -- **Liskov Substitution:** Protocol conformances must be complete and semantically correct. If a type can only partially satisfy a protocol, define a narrower protocol. -- **Interface Segregation:** Pass the narrowest possible interface to a caller. Pass a closure `() -> Void` rather than a full service reference when only one action is needed. -- **Dependency Inversion:** Services accept protocol-typed dependencies. `APIClient` accepts a `URLSession` (injectable). New services should follow the same pattern. - -### KISS -- Prefer flat `@State` / `@Binding` for simple local view state over introducing `ObservableObject` view models unless state is shared or complex. -- Use `async/await` and SwiftUI's built-in task modifiers (`.task {}`, `.refreshable {}`). Do not add Combine pipelines unless Apple's async API is genuinely insufficient. -- Three similar lines is better than a premature abstraction. Do not extract a helper until a pattern appears at least three times. - -### Project conventions -- **No force-unwrap** (`!`) on optional values in production code paths. -- **No `DispatchQueue.main.async`** — use `@MainActor` annotations or `MainActor.run {}`. -- **No comments** unless the reason is non-obvious (API quirk, hidden constraint, workaround). Do not describe what the code does; well-named identifiers already do that. -- **camelCase encoder** — the `/api/messages` POST endpoint expects camelCase keys (`publiclyVisible`, `parentId`). Use `camelCaseEncoder`, not the default `encoder`. -- **Empty-string == nil** — `ListFolder.parentId` and `UserList.folderId` may arrive as `""` instead of `null`. Treat both as absent. -- **Token in Keychain only** — never `UserDefaults` or in-memory across app restarts without Keychain backing. -- Every new `View` file needs a `#Preview` macro block. -- Every interactive element without an obvious label needs `.accessibilityLabel`. - -## File layout -``` -InterlinedList/Models/ Codable structs, lightweight computed properties only -InterlinedList/Views/ SwiftUI views — one public struct per file -InterlinedList/Services/ APIClient, AuthState, KeychainService -``` - -## Build verification -After writing or editing Swift files, verify with: -```bash -xcodebuild -scheme InterlinedList \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ - build 2>&1 | grep -E '(error:|warning:|BUILD SUCCEEDED|BUILD FAILED)' -``` - -Fix all errors before reporting work as done. Warnings about deprecated APIs should be noted to the user but do not block completion. - -## Error handling pattern -Services throw `APIError`. Views catch it and set a `String?` error state for display. Never swallow errors silently — at minimum log or surface them. - -## When asked to implement a feature -1. Identify which layer(s) are affected: Models, Views, Services. -2. Check whether an existing type can be extended cleanly (Open/Closed). If not, create a new type. -3. Keep model types pure `Codable` structs — no networking calls, no SwiftUI imports. -4. Keep views free of direct `URLSession`/networking calls — always go through a service. -5. Build and fix errors before reporting done.''' diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 2f3e6e6..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,75 +0,0 @@ -# InterlinedList iOS — Codex Guide - -## Project Overview - -**InterlinedList** is a native iOS/SwiftUI social list-sharing app that connects to the `interlinedlist.com` backend API. Users authenticate, compose messages, browse a feed, and manage nested lists and folders. - -- **Language:** Swift 5.9+ -- **UI framework:** SwiftUI (no UIKit except `UIResponder` for keyboard dismissal) -- **Minimum target:** iOS 17 (uses `ContentUnavailableView`, `NavigationStack`) -- **No third-party dependencies** — pure Apple frameworks only -- **API base:** `https://interlinedlist.com` (overridable via `ILAPIBaseURL` in `Info.plist`) - -## Directory Layout - -``` -InterlinedList/ - Models/ # Codable value types — no logic beyond computed properties - Views/ # SwiftUI views and subviews — one public struct per file - Services/ # Networking (APIClient), auth state (AuthState), Keychain -InterlinedList.xcodeproj/ -Resources/ # Logo assets, SVGs -.Codex/ - agents/ # Subagent definitions - commands/ # Slash-command skills -``` - -## Architecture Principles - -### SOLID in Swift/SwiftUI -- **Single Responsibility:** Each `View` renders one thing. Each `Service` owns one domain. `APIClient` handles HTTP only — no auth state, no UI. -- **Open/Closed:** Extend behavior via new `View` structs or protocol conformances, not by editing existing ones. -- **Liskov:** Prefer `protocol` over inheritance; conform only when you can fully satisfy the contract. -- **Interface Segregation:** Keep protocols narrow. Don't force a `View` to depend on a full service when only one method is needed — pass closures or a thin wrapper instead. -- **Dependency Inversion:** Services and view models depend on protocols, not concrete types, so they are testable without a live network. - -### KISS -- Flat `@State` over elaborate view models for simple screens. -- Prefer built-in SwiftUI idioms (`task {}`, `.refreshable {}`, `@EnvironmentObject`) over custom schedulers. -- No reactive third-party libraries — use `async/await` and `Combine` only when Apple's built-ins fall short. - -### Key Patterns -- **`APIClient`** is a `final class` singleton (`shared`) with injectable `URLSession` for testing. -- **`AuthState`** is a `@MainActor ObservableObject` injected at the root and propagated via `@EnvironmentObject`. -- **Models** are `Codable` structs. Use `snake_case ↔ camelCase` via `JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase`, except for endpoints that already send camelCase — use the dedicated `camelCaseEncoder`. -- **Error handling:** Propagate `APIError` from services; views catch and surface human-readable strings. Don't swallow errors silently. -- **Dates:** Stored as `String` (ISO 8601) in models; formatted in the view layer (`ISO8601DateFormatter` → `RelativeDateTimeFormatter`). - -## Build & Test - -```bash -# Build for simulator -xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,name=iPhone 16' build - -# Run tests (when test target exists) -xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,name=iPhone 16' test - -# List available simulators -xcrun simctl list devices --json | jq '.devices | to_entries[] | select(.value | length > 0)' -``` - -## Coding Standards - -- **No comments** unless the "why" is non-obvious (hidden constraint, workaround, API quirk). -- **No force-unwrap** (`!`) in production paths — use `guard`, `if let`, or `try?` with a meaningful fallback. -- **No `DispatchQueue.main.async`** — use `@MainActor` or `.receive(on: RunLoop.main)`. -- **Accessibility:** Every interactive element needs `.accessibilityLabel` if the label isn't obvious from context. -- **Preview:** Every `View` file should have a `#Preview` macro block. -- Mark view-internal helpers `private`; mark service internals `private` or `fileprivate`. - -## Common Gotchas - -- The `/api/messages` POST endpoint expects **camelCase** keys (`publiclyVisible`, `parentId`), not snake_case — use `camelCaseEncoder`, not the default `encoder`. -- `ListFolder.parentId` and `UserList.folderId` may arrive as `""` instead of `null` — treat both as "no parent." -- `APIClient.listsAndFolders()` issues two requests in sequence: `GET /api/folders`, then `GET /api/lists`. Errors from either call propagate to the caller. The endpoint is documented as live; if a stale deployment doesn't expose it, the UI will see a real error (not an empty folder list). -- Token is stored in Keychain via `KeychainService`; never store it in `UserDefaults`. diff --git a/CLAUDE.md b/CLAUDE.md index 64dd1ce..a3faf02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,75 +1,135 @@ -# InterlinedList iOS — Claude Code Guide +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview -**InterlinedList** is a native iOS/SwiftUI social list-sharing app that connects to the `interlinedlist.com` backend API. Users authenticate, compose messages, browse a feed, and manage nested lists and folders. +**InterlinedList** is a native iOS/SwiftUI social list-sharing app that connects to the `interlinedlist.com` backend API. Users authenticate (email/password or OAuth), compose messages, browse a feed, manage nested lists/folders and documents, follow people, and join organizations. - **Language:** Swift 5.9+ -- **UI framework:** SwiftUI (no UIKit except `UIResponder` for keyboard dismissal) -- **Minimum target:** iOS 17 (uses `ContentUnavailableView`, `NavigationStack`) +- **UI framework:** SwiftUI (UIKit only via `UIResponder` for keyboard dismissal and `ASWebAuthenticationSession`/`UIApplication` for OAuth presentation) +- **Minimum target:** iOS 17 (uses `ContentUnavailableView`, `NavigationStack`, `onChange(of:_:)` two-param form) - **No third-party dependencies** — pure Apple frameworks only -- **API base:** `https://interlinedlist.com` (overridable via `ILAPIBaseURL` in `Info.plist`) +- **API base:** `https://interlinedlist.com` (overridable via `ILAPIBaseURL` in `Info.plist`; empty string = production) ## Directory Layout ``` InterlinedList/ Models/ # Codable value types — no logic beyond computed properties - Views/ # SwiftUI views and subviews — one public struct per file - Services/ # Networking (APIClient), auth state (AuthState), Keychain + Views/ # SwiftUI views and subviews — one public struct per file (~34 files) + Services/ # APIClient, AuthState, AppDataStore, DataCache, KeychainService, + # OAuthCoordinator, URLSessionProtocol + InterlinedListApp.swift # @main entry; wires env objects + deep-link handling +InterlinedListTests/ + APIClientTests/ # Per-domain HTTP tests using MockURLSession + ModelTests/ # Codable round-trip + decoding-quirk tests + ServiceTests/ # KeychainService etc. + E2E/ # Read-only live-API smoke tests, gated on credentials InterlinedList.xcodeproj/ +InterlinedList.xctestplan # Single test target; parallelization disabled (see Build & Test) Resources/ # Logo assets, SVGs -.claude/ - agents/ # Subagent definitions - commands/ # Slash-command skills +.claude/ # agents/ (subagents), commands/ (slash-command skills) +GAP-*.md # Living design/roadmap docs (see "Reference docs") ``` -## Architecture Principles - -### SOLID in Swift/SwiftUI -- **Single Responsibility:** Each `View` renders one thing. Each `Service` owns one domain. `APIClient` handles HTTP only — no auth state, no UI. -- **Open/Closed:** Extend behavior via new `View` structs or protocol conformances, not by editing existing ones. -- **Liskov:** Prefer `protocol` over inheritance; conform only when you can fully satisfy the contract. -- **Interface Segregation:** Keep protocols narrow. Don't force a `View` to depend on a full service when only one method is needed — pass closures or a thin wrapper instead. -- **Dependency Inversion:** Services and view models depend on protocols, not concrete types, so they are testable without a live network. - -### KISS -- Flat `@State` over elaborate view models for simple screens. -- Prefer built-in SwiftUI idioms (`task {}`, `.refreshable {}`, `@EnvironmentObject`) over custom schedulers. -- No reactive third-party libraries — use `async/await` and `Combine` only when Apple's built-ins fall short. - -### Key Patterns -- **`APIClient`** is a `final class` singleton (`shared`) with injectable `URLSession` for testing. -- **`AuthState`** is a `@MainActor ObservableObject` injected at the root and propagated via `@EnvironmentObject`. -- **Models** are `Codable` structs. Use `snake_case ↔ camelCase` via `JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase`, except for endpoints that already send camelCase — use the dedicated `camelCaseEncoder`. -- **Error handling:** Propagate `APIError` from services; views catch and surface human-readable strings. Don't swallow errors silently. -- **Dates:** Stored as `String` (ISO 8601) in models; formatted in the view layer (`ISO8601DateFormatter` → `RelativeDateTimeFormatter`). +## Architecture + +### Dependency injection & app composition +`InterlinedListApp` owns three `@StateObject`s injected at the root and consumed via `@EnvironmentObject` throughout the view tree: +- **`AuthState`** — session/user lifecycle (`@MainActor ObservableObject`). +- **`AppDataStore`** — central cached data layer for feed, lists, documents, and badge counts. +- **`AppRouter`** — holds `pendingDeepLink`; drives sheet presentation for custom-scheme URLs. + +When `authState.hasToken` flips to `false`, the app calls `store.reset()` to clear cached data. + +### `APIClient` — HTTP only +- `final class` singleton (`shared`) with an **injectable `URLSessionProtocol`** so tests swap in `MockURLSession` without subclassing `URLSession` (its async `data(for:)` lives in an extension and can't be overridden). +- Holds the Bearer token in memory (`setBearerToken`); does **not** own auth state or touch the UI. +- **Three coders, chosen per endpoint** — getting this wrong is the most common bug: + - `decoder` — `convertFromSnakeCase` for all responses. + - `encoder` (`convertToSnakeCase`) via `post`/`put`/`patch` — for snake_case request bodies. + - `camelCaseEncoder` (plain) via `postCamel`/`putCamel`/`patchCamel` — for endpoints that expect **camelCase** bodies. **Many** endpoints use the camel variants (messages, lists, organizations, watchers, identities, change-email, …), not just `/api/messages`. Check the existing method before adding a new one. +- All requests funnel through `perform(_:)` → `checkResponse(_:_:)`, which throws typed `APIError` (`.status(401)`, `.server(msg)` from `{"error": ...}` bodies, `.conflict`, `.decoding`, `.network`). `os.Logger` logs method/path/status (never tokens). + +### Auth & the 401 contract (important) +A `401` does **not** automatically mean the session is dead. Some backend endpoints only accept session-cookie auth and reject a valid Bearer token. So: +- `APIClient` simply throws `APIError.status(401)`. +- Views catch it and call **`authState.handleUnauthorized()`**, which re-validates against `GET /api/user`. Only if `/api/user` *itself* returns 401 does it `logout()`. Network errors keep the user logged in. +- Follow this pattern for any new authenticated call — do not log the user out directly on a 401 from a feature endpoint. + +### `AppDataStore` — prefetch + offline cache + optimistic updates +- `prefetchAll(userId:)` fans out feed/lists/documents/counts concurrently with a `TaskGroup`. +- Reads/writes a per-user on-disk cache via **`DataCache`** (JSON files under `Caches/ILDataCache/`, keyed `"_feed"` etc.) so screens render instantly from cache, then refresh. +- Mutations are **optimistic** (`removeList`, `insertDocument`, …) and immediately re-persist to cache. +- Swallows `APIError.status(401)` during background refresh (auth is handled elsewhere); only surfaces an error string when there's no cached data to show. + +### OAuth & deep links +- **`OAuthCoordinator`** wraps `ASWebAuthenticationSession`, opening `/api/auth//authorize?redirect_uri=interlinedlist://oauth/callback` and parsing the returned `?token=...`. +- Custom URL scheme is **`interlinedlist://`**. `InterlinedListApp.handleDeepLink` routes `reset-password`, `verify-email`, `verify-email-change` (oauth callbacks are captured by the session itself). +- `OAuthProvider.supportsNativeAuth` is `false` for **GitHub** — its backend callback sets a web cookie and redirects to `/dashboard` instead of the custom scheme, so the in-app session can't complete. GitHub sign-in is hidden until the backend adds a mobile branch. + +### Models & dates +- `Codable` structs, logic limited to computed properties. Decode defensively — see Gotchas. +- Dates stored as `String` (ISO 8601); formatted in the view layer (`ISO8601DateFormatter` → `RelativeDateTimeFormatter`). + +## Design principles (SOLID + KISS) +- **SRP:** one `View` renders one thing; one `Service` owns one domain; `APIClient` is HTTP-only. +- **DIP:** services/view-models depend on protocols (`URLSessionProtocol`), so they're testable without a live network. +- **ISP:** keep protocols narrow; pass a closure or thin wrapper rather than a whole service into a view. +- **KISS:** flat `@State` over view models for simple screens; prefer `task {}`, `.refreshable {}`, `@EnvironmentObject` over custom schedulers; `async/await` over Combine unless Apple's built-ins fall short. ## Build & Test +Notes: +- `name=iPhone 16` alone is **ambiguous** across installed runtimes and can fail to resolve. Pin a concrete simulator UDID (`xcrun simctl list devices`) for reliable runs. +- **Parallelization is disabled in `InterlinedList.xctestplan`** (`parallelizable: false`). The E2E suite shares a `static` login token across tests to avoid re-hitting the rate-limited login endpoint; parallel runs use cloned simulators that don't share that state. The `-parallel-testing-enabled NO` flag below is therefore redundant reinforcement, not the source of truth — keep the plan setting in sync if you change this. + ```bash # Build for simulator -xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,name=iPhone 16' build +xcodebuild -scheme InterlinedList \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' build -# Run tests (when test target exists) -xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,name=iPhone 16' test +# Run the full test suite (pin a UDID; serialize for stability) +xcodebuild -scheme InterlinedList \ + -destination 'platform=iOS Simulator,id=' \ + -parallel-testing-enabled NO test -# List available simulators +# Run a single test class or method +xcodebuild test -scheme InterlinedList \ + -destination 'platform=iOS Simulator,id=' \ + -only-testing:InterlinedListTests/APIClientMessagesTests +# …/APIClientMessagesTests/testPostMessageUsesCamelCase (single method) + +# List simulators xcrun simctl list devices --json | jq '.devices | to_entries[] | select(.value | length > 0)' ``` +- **Unit tests** stub HTTP through `MockURLSession` (`stub`/`enqueue` for sequenced responses) — no network needed. +- **E2E tests** (`InterlinedListTests/E2E`) hit the **live** API but are strictly **read-only**. They auto-`XCTSkip` unless `INTERLINEDLIST_EMAIL` / `INTERLINEDLIST_PASSWORD` are present, read from process env (Xcode scheme Test action or CI) or a gitignored `.env` at repo root (`EnvLoader`). +- **CI** (`.github/workflows/ios.yml`) **builds only** (no tests) on push/PR to `main`, with code signing disabled. + ## Coding Standards - **No comments** unless the "why" is non-obvious (hidden constraint, workaround, API quirk). - **No force-unwrap** (`!`) in production paths — use `guard`, `if let`, or `try?` with a meaningful fallback. - **No `DispatchQueue.main.async`** — use `@MainActor` or `.receive(on: RunLoop.main)`. -- **Accessibility:** Every interactive element needs `.accessibilityLabel` if the label isn't obvious from context. -- **Preview:** Every `View` file should have a `#Preview` macro block. -- Mark view-internal helpers `private`; mark service internals `private` or `fileprivate`. +- **Accessibility:** every interactive element needs `.accessibilityLabel` if the label isn't obvious from context. +- **Preview:** every `View` file should have a `#Preview` block. +- Mark view-internal helpers `private`; mark service internals `private`/`fileprivate`. ## Common Gotchas -- The `/api/messages` POST endpoint expects **camelCase** keys (`publiclyVisible`, `parentId`), not snake_case — use `camelCaseEncoder`, not the default `encoder`. -- `ListFolder.parentId` and `UserList.folderId` may arrive as `""` instead of `null` — treat both as "no parent." -- `APIClient.listsAndFolders()` issues two requests in sequence: `GET /api/folders`, then `GET /api/lists`. Errors from either call propagate to the caller. The endpoint is documented as live; if a stale deployment doesn't expose it, the UI will see a real error (not an empty folder list). -- Token is stored in Keychain via `KeychainService`; never store it in `UserDefaults`. +- **camelCase vs snake_case bodies:** use the right encoder/helper (`postCamel` family for camelCase endpoints). Mismatches fail silently server-side. See APIClient architecture above. +- **Don't log out on a feature-endpoint 401** — route through `authState.handleUnauthorized()` (re-validates against `/api/user`). +- **Empty-string parents:** `ListFolder.parentId` and `UserList.folderId` may arrive as `""` instead of `null` — treat both as "no parent." +- **`listsAndFolders()`** issues `GET /api/folders` then `GET /api/lists` in sequence; errors from either propagate (the UI sees a real error, not an empty list). +- **Document folders are path-scoped, not query/body-scoped.** `GET /api/documents` and `POST /api/documents` are **root-only** (the GET ignores `?folderId=`; the POST has no `folderId` field). A folder's contents come from `GET /api/documents/folders/{id}/documents`, and creating in a folder is `POST /api/documents/folders/{id}/documents`. Only `PATCH /api/documents/{id}` takes `folderId` (camelCase) to move a doc. Using the wrong route silently drops the folder and the doc lands at root. +- **Token storage:** Keychain only (`KeychainService`); never `UserDefaults`. Token query items from deep links are sensitive — never log them. +- **Adding a file to the project:** there are no synced/file-system groups — a new `.swift` file must be manually registered in `project.pbxproj` (use the `xcodeproj` Ruby gem) or it won't compile into the target. + +## Reference docs + +- `GAP-ENDPOINTS.md` — live but under-documented/ambiguous API contracts the client had to guess or decode defensively. +- `GAP-NEXT-STEPS.md` — iOS-side implementation roadmap / punchlist. +- `GAP-APPLE.md` — App Store signing & submission checklist tailored to this app. diff --git a/GAP-APPLE.md b/GAP-APPLE.md new file mode 100644 index 0000000..e686b4d --- /dev/null +++ b/GAP-APPLE.md @@ -0,0 +1,325 @@ +# GAP-APPLE — Shipping InterlinedList to the App Store + +A step-by-step checklist to sign, prepare, and submit the iOS app to the +Apple App Store. Written against the current project state and tailored to +this app's specifics (free app, web-only subscriptions, OAuth + email +auth, in-app account deletion). + +Legend for each step: +- 🖥️ **Xcode** — must be done in the Xcode GUI. +- 🌐 **Web** — App Store Connect / Developer portal (browser). +- ⌨️ **CLI** — can be scripted from the terminal. +- 📝 **File** — an edit to a file in this repo. + +--- + +## 0. Where the project stands today + +| Setting | Current value | Notes | +|---|---|---| +| Bundle identifier | `com.interlinedlist.app` | tests target: `com.interlinedlist.app.tests` | +| Marketing version | `1.0` (`MARKETING_VERSION`) | the public "version" | +| Build | `1` (`CURRENT_PROJECT_VERSION`) | must increment every upload | +| Deployment target | iOS 17.0 | | +| Device family | iPhone only (`TARGETED_DEVICE_FAMILY = 1`) | iPad not a target → iPhone screenshots only | +| Signing | Automatic, `DEVELOPMENT_TEAM = BJA9558E4B` | team already set | +| App icon | `Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png` | single-size 1024 icon present ✅ | +| URL scheme | `interlinedlist://` registered | used for OAuth / verify / reset deep links | +| Third-party deps | none | pure Apple frameworks | + +Two Info.plist items to fix **before** the first upload — see §3.5. + +--- + +## 1. Prerequisites (one-time) + +1. 🌐 **Apple Developer Program membership** — $99/year, at + . Required to upload and + distribute. Confirm the membership is active and you're an **Admin** or + **App Manager** on the team `BJA9558E4B` (Account → Membership). +2. 🖥️ **Xcode** — install the latest from the Mac App Store. Sign in: + Xcode ▸ Settings ▸ Accounts ▸ "+" ▸ Apple ID. Confirm the team appears. +3. ⌨️ Confirm command-line tools point at that Xcode: + `sudo xcode-select -s /Applications/Xcode.app` then `xcodebuild -version`. +4. 🌐 **Agreements** — App Store Connect ▸ Business: accept the current + "Paid Apps" and "Free Apps" agreements. Uploads silently fail if the + active agreement isn't accepted. + +--- + +## 2. Register the App ID & bundle identifier (one-time) + +With automatic signing, Xcode can create the App ID for you on first +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. +4. Save. + +> 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 +> to Universal Links (`https://interlinedlist.com/...`). + +--- + +## 3. Xcode project prep + +### 3.1 Signing 🖥️ +1. Open `InterlinedList.xcodeproj`, select the **InterlinedList** target ▸ + **Signing & Capabilities**. +2. Check **Automatically manage signing**. Team = your team + (`BJA9558E4B`). Xcode will create the **Apple Distribution** certificate + and **App Store** provisioning profile on first archive. +3. If you prefer manual signing later, you'd create an *Apple Distribution* + certificate and an *App Store* provisioning profile in the portal and + select them here — not needed for the first ship. + +### 3.2 Version & build 🖥️/📝 +- Keep **Version** `1.0` for the first release. **Build** must be unique & + monotonically increasing for *every* upload (even rejected ones). Bump + `CURRENT_PROJECT_VERSION` to `1` now, then `2`, `3`… on each re-upload. +- ⌨️ Quick bump from CLI: `agvtool next-version -all` (or edit + `CURRENT_PROJECT_VERSION` in build settings). + +### 3.3 App icon 🖥️ +- A 1024×1024 icon is present. Open `Assets.xcassets ▸ AppIcon` and confirm + there are **no empty wells / warnings** (a single 1024 "Any Appearance" + slot is fine for iOS 17). The icon must be a flat PNG with **no alpha / + transparency** and no rounded corners (Apple rounds it). If the asset + catalog shows a yellow warning, fill the required slot. + +### 3.4 Launch screen 🖥️ +- The project uses a generated launch screen (`UILaunchScreen` empty dict / + `INFOPLIST_KEY_UILaunchScreen_Generation = YES`). That's valid. Launch + the app once on a device to confirm it isn't a black flash; add a + branded launch storyboard later if desired (not required to ship). + +### 3.5 Info.plist fixes 📝 (do these before first upload) + +`InterlinedList/Info.plist`: + +1. **Encryption export compliance.** Add: + ```xml + ITSAppUsesNonExemptEncryption + + ``` + The app only uses standard HTTPS/TLS (exempt). Without this key, every + upload stops to ask the export-compliance question in App Store Connect. + Set `false` to skip it. + +2. **Remove the stale `armv7` capability.** `UIRequiredDeviceCapabilities` + currently lists `armv7` (32-bit) — iOS 17 is **arm64-only**, and this + entry is wrong and can cause "app not compatible" oddities. Change it to: + ```xml + UIRequiredDeviceCapabilities + + arm64 + + ``` + (Keep `network` only if you truly require it; `arm64` is the safe set.) + +3. **Privacy usage strings** — none are required today: the app uses + `PhotosPicker` (PHPicker), which reads photos out-of-process and needs + **no** `NSPhotoLibraryUsageDescription`. There is no camera, mic, + location, or contacts access. If you ever add direct photo-library or + camera access, add the matching `NS*UsageDescription` or the app is + 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). + +--- + +## 4. App Store Connect — create the app record 🌐 + +1. ▸ **Apps** ▸ "+" ▸ **New App**. + - Platform: iOS. Name: **InterlinedList** (must be globally unique on + the store). Primary language: English (U.S.). + - Bundle ID: select `com.interlinedlist.app`. + - SKU: any internal string, e.g. `interlinedlist-ios`. + - User access: Full. +2. **Pricing and Availability** ▸ Price = **Free**. Choose territories. +3. Fill the **App Information**: + - Category (primary/secondary), content rights, age rating + questionnaire (see §5.4). + - **Privacy Policy URL** (required): `https://interlinedlist.com/privacy`. + - Support URL: `https://interlinedlist.com/help` (or similar). + +--- + +## 5. Compliance specifics for *this* app + +### 5.1 In-app purchase / anti-steering (Guideline 3.1.1 & 3.1.3) ✅ by design +- The app sells **nothing** in-app and shows **no** subscription/paywall + UI; subscription management is entirely on the web. This is the safe + path. **Do not** add a "Subscribe", "Upgrade", price text, or any link + that points users to the website to pay — that violates anti-steering and + is the most common rejection for apps with a web subscription. The + current build is compliant *because* it stays silent on billing; keep it + that way. + +### 5.2 Account deletion (Guideline 5.1.1(v)) ✅ already implemented +- Apple requires any app with account creation to offer **in-app account + deletion**. The app has it (Profile ▸ Edit ▸ delete account → double + confirm → forced logout). When asked in review notes, point to that path. + +### 5.3 Login services (Guideline 4.8) — likely fine, verify +- 4.8 applies when an app uses third-party login (you offer GitHub, + Mastodon, Bluesky, LinkedIn, X via OAuth). Because the app **also** offers + plain email/password sign-up (a first-party login that doesn't share data + with a third party), you generally do **not** need to add *Sign in with + Apple*. If a reviewer pushes back, the email/password option is the + mitigation; otherwise be prepared to add Sign in with Apple. + +### 5.4 Age rating 🌐 +- 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. + +### 5.5 Export compliance ✅ handled by §3.5.1 +- With `ITSAppUsesNonExemptEncryption=false`, no annual self-classification + report is needed. + +--- + +## 6. App Store metadata & screenshots 🌐 + +Required before you can submit a build: +- **Screenshots** — iPhone only (the app is iPhone-only). Provide for the + current required sizes: **6.9"** (iPhone 16 Pro Max, 1320×2868) and + **6.5"** (1242×2688). Apple scales down for smaller devices, so those two + sets usually suffice. Capture via the iPhone 16 Pro Max simulator + (⌨️ `xcrun simctl io booted screenshot shot.png`) or a device. +- **Description, keywords, promotional text, what's new.** +- **App preview** video — optional. +- Marketing app icon is taken from the build's 1024 icon. + +--- + +## 7. Archive & upload the build + +### 7.1 Xcode GUI path 🖥️ (recommended for the first ship) +1. Select the run destination **Any iOS Device (arm64)** (not a simulator — + archiving requires a device/generic destination). +2. Ensure the scheme builds **Release**: Product ▸ Scheme ▸ Edit Scheme ▸ + Archive ▸ Build Configuration = **Release**. +3. **Product ▸ Archive**. Wait for the build. +4. The **Organizer** opens ▸ select the archive ▸ **Distribute App** ▸ + **App Store Connect** ▸ **Upload** ▸ accept the automatic signing ▸ + Upload. (Choose "Upload" not "Export".) +5. The build appears in App Store Connect under the app's **TestFlight** / + **Build** sections after processing (5–30 min). + +### 7.2 CLI path ⌨️ (for CI / repeatability) +```bash +# 1. Archive (Release) +xcodebuild -scheme InterlinedList \ + -destination 'generic/platform=iOS' \ + -archivePath build/InterlinedList.xcarchive \ + archive + +# 2. Export & upload using an ExportOptions.plist (below) +xcodebuild -exportArchive \ + -archivePath build/InterlinedList.xcarchive \ + -exportOptionsPlist ExportOptions.plist \ + -exportPath build/export +``` +`ExportOptions.plist`: +```xml + + + + + method app-store-connect + teamID BJA9558E4B + signingStyle automatic + destination upload + uploadSymbols + + +``` +- Auth for upload: create an **App Store Connect API key** (App Store + Connect ▸ Users and Access ▸ Integrations ▸ Keys), download the `.p8`, + and either let Xcode store it or use `xcrun altool`/`notarytool`-style + env. Alternatively upload the exported `.ipa` with **Transporter** (Mac + App Store app) — drag the `.ipa` in and Deliver. + +--- + +## 8. TestFlight (strongly recommended before public release) 🌐 +1. After processing, the build shows under **TestFlight**. +2. Provide **Test Information** (beta description, feedback email) and a + demo account for reviewers — give them an **email/password test login** + (OAuth providers are awkward for reviewers). +3. **Internal testing** (your team, up to 100, no beta review) installs + instantly. **External testing** requires a short Beta App Review. +4. Verify on a real device: login, post + cross-post, lists/schema, follow + flows, settings, deep-link callbacks (`interlinedlist://`). + +--- + +## 9. Submit for App Store review 🌐 +1. App Store Connect ▸ your app ▸ **(version) Prepare for Submission**. +2. Select the uploaded **Build**. +3. Confirm: screenshots, description, keywords, support/privacy URLs, age + rating, **App Privacy** "nutrition label" (declare what you collect — + typically: email/account data, user content, identifiers; map each to a + use). The label is required and separately editable under **App + Privacy**. +4. **Review notes**: include the demo login, note that *subscriptions are + managed on the web and there is no in-app purchase*, and point to the + in-app **account deletion** path. +5. Export compliance: with §3.5.1 set, you'll see no prompt. +6. **Add for Review ▸ Submit**. + +--- + +## 10. After submission +- Status flows: *Waiting for Review → In Review → Pending/Approved*. First + reviews are often 24–48h. +- On rejection, respond in **Resolution Center**; fix, bump the **build** + number (§3.2), re-archive, re-upload, resubmit. +- On approval choose **manual** or **automatic** release. For a first + launch, manual lets you coordinate timing. + +--- + +## 11. Common rejection triggers for this app (pre-empt them) +- **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. +- **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). +- **Stale device-capability / icon alpha**: fixed by §3.3 and §3.5.2. +- **Incomplete metadata**: privacy policy URL is mandatory. + +--- + +## 12. Quick pre-flight checklist +- [ ] Apple Developer membership active; agreements accepted (§1) +- [ ] App ID `com.interlinedlist.app` registered (§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) +- [ ] App Store Connect record + Free pricing + privacy/support URLs (§4) +- [ ] Content reporting / blocking present for UGC (§5.4) +- [ ] iPhone 6.9"/6.5" screenshots + description (§6) +- [ ] Archive uploaded; build processed (§7) +- [ ] TestFlight smoke test on device + demo login (§8) +- [ ] App Privacy label completed; review notes with demo creds (§9) diff --git a/GAP-ENDPOINTS.md b/GAP-ENDPOINTS.md index 43a5e76..f139c55 100644 --- a/GAP-ENDPOINTS.md +++ b/GAP-ENDPOINTS.md @@ -1,558 +1,185 @@ -# GAP-ENDPOINTS — Backend gaps blocking iOS parity - -Tracks endpoints / API behaviors the **backend** still needs to expose -before the iOS app can ship corresponding features. For the iOS-side -implementation roadmap, see `GAP-NEXT-STEPS.md`. - -This file is intentionally **paste-into-backend-Claude friendly**: each -section under "Backend gaps" is a self-contained prompt the backend team -can drop into their own Claude Code session. - -Last updated: 2026-06-23 — re-verified against the full `/help/api` -tree. **All eight actionable Part B gaps are still standing.** - -### Re-verification notes (2026-06-23) - -- The `/help/api` tree contains 17 sub-pages: authentication, - users-and-profile, public-profiles, messages, lists, list-folders, - documents, document-folders, following, organizations, notifications, - push-notifications, exports, github-integration, linkedin-integration, - utility-endpoints, administration. -- **`/help/api/subscriptions` returns 404** — no dedicated subscriptions - docs page exists. (Earlier audit listed it; it may have been removed - or never published.) This raises B1 priority: the iOS app currently - has no documented Stripe / billing API surface to call at all. -- **`/help/api/internal-endpoints` no longer in the tree** — prior audit - listed it; current tree does not. Minor (was internal-only anyway). -- **New endpoint discovered on `/help/api/messages`:** - `POST /api/messages/:id/metadata` — not in the prior audit and not - used by iOS yet. Logged in `GAP-NEXT-STEPS.md` as a Phase 4 add-on. - Not a gap — just an iOS-side TODO. +# GAP-ENDPOINTS — API contracts that are under-documented ---- - -## Part A — Original six gaps: all shipped ✅ - -The six backend gaps tracked in the prior version of this doc are now -all confirmed live in the published docs. +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. -| # | Endpoint | Docs page | Status | -|---|---|---|---| -| 1 | `GET/POST/PUT/DELETE /api/folders` (list folders) | `/help/api/list-folders` | ✅ Live (POST is subscriber-only) | -| 2 | `PATCH /api/documents/[id]` accepts `folderId` | `/help/api/documents` | ✅ Live | -| 3 | `GET /api/documents/search` | `/help/api/documents` | ✅ Live | -| 4 | `GET /api/lists/search` | `/help/api/lists` | ✅ Live | -| 5 | `PUT /api/lists/[id]` accepts `isPublic` | `/help/api/lists` | ✅ Live | -| 6 | `PUT /api/lists/[id]/schema` | `/help/api/lists` | ✅ Live (body shape inferred — see §B0) | +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. -iOS-side fallout for all six gaps is complete: placeholders removed, -swallows torn out, subscriber-403 paywall plumbed, integration tests -added. No iOS work pending against these. +Last updated: 2026-06-25. --- -## Part B — Backend gaps still blocking iOS parity - -Each item below lists: - -1. **Gap** — what's missing. -2. **Why it matters** — which iOS feature is blocked. -3. **Proposed contract** — what the iOS client expects to call. -4. **Prompt** — paste-into-backend-Claude prompt to implement. - -Ordered by iOS impact. - -### B0. Document `PUT /api/lists/[id]/schema` body shape - -**Status:** Endpoint is live but the docs publish no example body. -**Re-verified 2026-06-23:** `/help/api/lists` still shows the endpoint -in the table as "Update list schema" with no body spec, no example, no -structured-properties variant. The iOS client currently sends -`{ "schema": "Name:type, ..." }` (a DSL string) by analogy with the -`POST /api/lists` example, but this is an assumption. - -**Why it matters:** Two issues. - -1. If the live server expects a different shape, every schema-edit save - from iOS fails silently. -2. The DSL string format loses `isVisible`, `isRequired`, - `displayOrder`, `defaultValue`, `helpText`, `placeholder` — fields - the iOS editor lets users edit but can't round-trip. - -**Resolution options (pick one):** - -- (a) **Document the DSL shape** explicitly and accept the data loss — - ship a richer endpoint later if needed. -- (b) **Expose a structured form** as a peer endpoint, e.g. - `PUT /api/lists/[id]/schema/structured` taking - `{ "properties": [{ "id": ..., "propertyKey": ..., "propertyName": ..., - "propertyType": ..., "displayOrder": ..., "isVisible": ..., - "isRequired": ..., "defaultValue": ..., "helpText": ..., - "placeholder": ... }] }` and returning the full updated schema. Keep - DSL as an alternate format on the same `/schema` route. - -**Prompt:** - -``` -The InterlinedList iOS client calls `PUT /api/lists/[id]/schema` to -persist schema edits, but the published API docs don't include a body -example. Today the iOS client sends `{ "schema": "Name:type, ..." }` -by analogy with `POST /api/lists`. Please either: - -1. Document the request body for `PUT /api/lists/[id]/schema` explicitly - on /help/api/lists, including an example, supported types, and - non-destructive merge semantics (rename / reorder / add / delete and - how each affects existing row data). - -OR - -2. Expose a richer structured endpoint at - `PUT /api/lists/[id]/schema/structured` accepting a JSON array of - property objects with `id`, `propertyKey`, `propertyName`, - `propertyType`, `displayOrder`, `isVisible`, `isRequired`, - `defaultValue`, `helpText`, `placeholder` — semantics: - - existing `id` → update in place, preserve row data - - missing `id` → create new property - - omitted from request → soft-delete, drop key from row blobs - - reject duplicate `propertyKey` in same list - - reject unknown `propertyType` - - reject `propertyKey` change for existing id (rename - propertyName instead) - Response 200: `{ "properties": [ ... full updated schema ... ] }`. - -Either path unblocks the iOS schema editor's `isVisible` / `isRequired` -toggles. -``` +## A. Shape disagreements between OpenAPI and the help docs ---- - -### B1. Subscription plans catalog endpoint - -**Gap:** No endpoint returns the available subscription tiers, their -prices, feature comparisons, or marketing copy. -**Re-verified 2026-06-23:** `/help/api/subscriptions` returns **404** — -there is no dedicated subscriptions docs page at all. The iOS app has -no documented API surface for plans, pricing, checkout, or billing -portal. Earlier mentions of `POST /api/stripe/create-*` endpoints came -from a now-removed page; treat them as unverified until re-published. - -**Why it matters:** Blocks Phase 3 of `GAP-NEXT-STEPS.md`. The iOS -paywall / upgrade screen has to hardcode plan info or punt to a -webview. Even a simple "you'll get cross-posting + scheduled posts + -image uploads + folders for $X/month" pitch needs data. - -**Proposed contract:** - -``` -GET /api/subscriptions/plans -Response 200: - { - "plans": [ - { - "id": "monthly", - "name": "Monthly", - "priceCents": 500, - "currency": "USD", - "interval": "month", - "features": ["Cross-posting", "Scheduled posts", - "Image uploads", "Video uploads", "Folders"] - }, - { - "id": "annual", - "name": "Annual", - "priceCents": 5000, - "currency": "USD", - "interval": "year", - "features": [...] - } - ] - } -``` - -**Prompt:** - -``` -Add `GET /api/subscriptions/plans` returning the public-facing list of -subscription tiers with price, interval, and a feature list. The -InterlinedList iOS app needs this to render an in-app paywall / upgrade -screen without hardcoding plan info. Match the response shape proposed -in the iOS repo's `GAP-ENDPOINTS.md` §B1, or document any -divergence. - -Public endpoint (no auth required). If feature lists are tier-dependent -and you'd rather keep them server-rendered, also return a `marketingUrl` -per plan so the iOS app can fall back to webview. -``` +### A1. `GET /api/users/{username}/lists/{id}` — two different documented shapes ---- +- **OpenAPI** says: `{ "list": { id, title, parentId, children }, "ancestors": [...] }`. +- **`/help/api/public-profiles`** shows a **flat** object: + `{ id, title, description, isPublic, schema, owner: { username, displayName }, createdAt }`. -### B2. Message search - -**Gap:** `/api/lists/search` and `/api/documents/search` exist; -`/api/messages/search` does not. -**Re-verified 2026-06-23:** `/help/api/messages` shows no search -endpoint. Tags appear only as an optional field on message creation, -not as a query/filter beyond `?tag=X` on the list endpoint. - -**Why it matters:** Blocks Phase 13. The iOS feed has no search box. A -social feed without search is a notable UX gap. - -**Proposed contract:** - -``` -GET /api/messages/search?q={query}&limit={n}&offset={n}&onlyMine={bool} -Response 200: - { - "messages": [ ... same shape as GET /api/messages items ... ], - "pagination": { "total": 42, "limit": 20, "offset": 0, "hasMore": true } - } -``` - -Visibility scoping: the user's own messages (public + private) plus -public messages from anyone they can otherwise see (followers/public -profiles). Match the existing visibility rules from `GET /api/messages`. - -**Prompt:** - -``` -Add `GET /api/messages/search` mirroring the existing -`/api/documents/search` and `/api/lists/search` endpoints. Query -parameters: `q` (required, 1–200 chars), `limit` (default 20, max 100), -`offset` (default 0), `onlyMine` (optional boolean, default false). -Response uses the same message object shape as `GET /api/messages`, -wrapped under `messages` + `pagination`. Visibility scoping matches the -existing `/api/messages` rules. The InterlinedList iOS app will add a -search bar to its feed once this lands. -``` +These are mutually exclusive. The iOS client decodes **both** (tries a +nested `list` object, falls back to the flat object) to be safe. ---- +**What would help:** publish the single canonical response, and confirm +whether it includes: `description`, `isPublic`, the `schema` DSL string, +a structured `properties` array, `owner`, `children`, and `ancestors`. +Right now a client can't know which fields to rely on. -### B3. Notification preferences enumeration - -**Gap:** The push-notifications docs note that "per-event delivery -preferences" live on user profile settings, but no endpoint enumerates -which event types exist. iOS can't render a Settings → Notifications -screen without hardcoding event keys. -**Re-verified 2026-06-23:** Both `/help/api/users-and-profile` and -`/help/api/push-notifications` confirm no enumeration endpoint. The -push docs still reference "new follower, reply, dig, etc." as event -types without an authoritative list. - -**Why it matters:** Blocks the notification-preferences screen in Phase -9 / Phase 12 of `GAP-NEXT-STEPS.md`. Without this, iOS users have no -way to control what they're notified about beyond going to the web. - -**Proposed contract:** - -``` -GET /api/user/notification-preferences -Response 200: - { - "events": [ - { - "key": "follow", - "label": "New follower", - "description": "When someone follows you.", - "channels": { "push": true, "inApp": true, "email": false } - }, - { - "key": "reply", - "label": "Replies to your messages", - ... - }, - { "key": "dig", ... }, - { "key": "follow_request_approved", ... }, - { "key": "list_watcher_added", ... }, - ... - ] - } - -PATCH /api/user/notification-preferences -Body: { "key": "follow", "channels": { "push": false } } -Response: updated event object -``` - -**Prompt:** - -``` -Expose two endpoints so clients (specifically the InterlinedList iOS -app) can render a notifications preferences screen without hardcoding -event types: - -GET /api/user/notification-preferences - Returns every notification event the server can emit, with a - display-friendly label, a description, and per-channel boolean - settings (push, in-app, email) for the current user. - -PATCH /api/user/notification-preferences - Body: { "key": "", "channels": { "push": bool, ... } } - Updates the per-channel preference for one event. - -The current `POST /api/user/update` endpoint can stay as the persistence -layer; these two endpoints are just the enumeration + targeted-update -surface that the docs already imply exists. -``` +### A2. `GET /api/users/{username}/lists/{id}/data` — row/pagination shape unspecified ---- +- **OpenAPI**: response documented as no body. +- **help docs**: "same paginated row shape as the authenticated endpoint" + but that shape isn't shown. -### B4. Bearer-token support on `/api/github/*` endpoints - -**Gap:** GitHub integration endpoints (`/api/github/repos`, -`/api/github/issues`, etc.) require a session cookie and explicitly -reject Bearer tokens. The iOS app is Bearer-only. -**Re-verified 2026-06-23:** `/help/api/github-integration` still states -verbatim: *"All endpoints require a session cookie (Bearer tokens are -not accepted), and require an active linked GitHub identity."* No -session-from-Bearer exchange endpoint documented either. - -**Why it matters:** Blocks Phase 11 (GitHub integration) on iOS without -forcing the client to implement a fragile cookie-jar flow that -bypasses our Bearer-token security model. - -**Resolution options:** - -- (a) Accept Bearer tokens on `/api/github/*` — preferred. The Bearer - token already maps to a user identity; GitHub OAuth identity is - attached to that user. -- (b) Document a documented session-cookie-via-Bearer-exchange - endpoint (e.g. `POST /api/auth/session-from-bearer` returns a - short-lived session cookie) — fallback if direct Bearer support is - hard. - -**Prompt:** - -``` -The InterlinedList iOS app authenticates with Bearer tokens -(`/api/auth/sync-token`) and cannot use session cookies cleanly. Today -`/api/github/*` endpoints require session-cookie auth and reject -Bearer tokens, locking iOS out of GitHub-backed lists and "create -issue from message" flows. - -Please either (a) accept Bearer tokens on the `/api/github/*` family — -the Bearer token already identifies a user, and that user's linked -GitHub identity provides the GitHub access token server-side — or -(b) expose `POST /api/auth/session-from-bearer` that returns a short- -lived session cookie usable for these endpoints. (a) is strongly -preferred; (b) is a workaround. -``` +The iOS client assumes rows arrive under `rows` (falling back to `items`) +as `{ id, rowData, rowNumber, createdAt }`, with optional top-level +`properties` and a standard `pagination` block. ---- +**What would help:** document the wrapping key (`rows` vs `items`), the +row object fields, whether `properties` (the schema) is included so a +read-only client can label columns, and the pagination shape. -### B5. Document list-watcher role values +### A3. `GET /api/users/{username}/lists` — no documented body -**Gap:** `/help/api/lists` lists watcher endpoints but doesn't -enumerate the role values that `PUT /api/lists/:id/watchers/:userId` -accepts in its body. `/help/lists` mentions "Watcher", "Collaborator", -"Manager" as user-facing terms but the wire values aren't documented. -**Re-verified 2026-06-23:** Still no role enumeration. No body example -for `POST /api/lists/:id/watchers` either. +Neither source publishes the response. iOS assumes +`{ "lists": [ UserList ] }` (reusing the authenticated list shape, where +`title`→name and `parentId`→folderId). Please confirm the wrapping key +and the per-list fields (notably whether `itemCount`, `isPublic`, and +`description` are present for public lists). -**Why it matters:** Blocks Phase 6 (list collaboration) on iOS — the -role picker can't be built without knowing the canonical strings. +--- -**Proposed contract:** Document the role values explicitly on -`/help/api/lists`. Likely candidates: `"watcher" | "collaborator" | -"manager"` (lowercase, snake-case if multi-word). Also document the -`POST /api/lists/:id/watchers` request body — it's not shown today. +## B. Endpoints documented as "no body" in OpenAPI (need response specs) -**Prompt:** +The OpenAPI generator emitted **no response schema** for several list +endpoints the iOS app now depends on. They work, but a client is guessing: -``` -The /help/api/lists page lists watcher endpoints but doesn't enumerate -the valid role strings or document the `POST /api/lists/:id/watchers` -request body. Please add: +| Endpoint | iOS assumes | Confirm | +|---|---|---| +| `GET /api/organizations` | `{ organizations: [Organization], pagination? }` | wrapping key + per-item fields (esp. `userRole`, `memberCount`, `avatar`, `slug`) | +| `GET /api/user/organizations` | `{ organizations: [Organization] }` | same — does each item carry `userRole`? (see B1 below) | +| `GET /api/users/{username}/documents` | `{ documents: [...], folders: [...] }` ✅ (this one *is* in OpenAPI) | — | +| `PATCH /api/messages/{id}` | optionally returns `{ data: Message }` | does it return the updated message, or just 200? | -1. The exact role string values that `PUT /api/lists/:id/watchers/:userId` - accepts in its `{ "role": "..." }` body. (Presumably "watcher" / - "collaborator" / "manager" to match /help/lists wording — confirm.) -2. The `POST /api/lists/:id/watchers` request body shape — is it - `{ "userId": "..." }`, `{ "role": "watcher" }`, or both? -3. The shape of the `users` field returned from - `GET /api/lists/:id/watchers/users` — at minimum each item should - have `userId`, `username`, `displayName?`, `role`. +### B1. Org list items should carry `userRole` (saves a round-trip) -The InterlinedList iOS app's Phase 6 collaboration UI is gated on this. -``` +`GET /api/organizations/{id}` returns `userRole` (so the app knows whether +to show owner-only edit/delete), but `GET /api/user/organizations` and +`GET /api/organizations` don't document it. The iOS detail screen +currently **re-fetches** `GET /api/organizations/{id}` purely to learn the +caller's role. If the list endpoints included `userRole` + `memberCount`, +that extra request goes away. --- -### B6. Tag / hashtag discovery - -**Gap:** `GET /api/messages?tag=X` filters by tag, but there's no -endpoint to list trending or recent tags. Users can only follow tags -they already know about. -**Re-verified 2026-06-23:** Not documented on `/help/api/messages` or -`/help/api/utility-endpoints` (the natural homes). +## C. Watcher endpoints — small gaps that shaped the iOS design -**Why it matters:** Blocks the tag-explorer half of Phase 13. Also -needed for tag autocomplete in `ComposeView`. +### C1. `GET /api/lists/{id}/watchers/me` returns only `{ watching }` — no role -**Proposed contract:** +The roadmap wanted a "my role" badge on a shared list, but `/watchers/me` +only answers *whether* the caller watches the list, not their role +(watcher/collaborator/manager). There's no documented way for a +non-owner to learn their own role without `GET .../watchers` (which may be +manager-gated). As a result the iOS permission model is binary today: +**owner → full edit** (their own lists), **everyone else → read-only** +(public list view). Collaborator/manager editing of *someone else's* list +is deferred. -``` -GET /api/tags/trending?limit=20 -Response 200: { "tags": [{ "tag": "swift", "count": 42, "lastUsedAt": "..." }] } +**What would help:** add `role` (and maybe `permissions`) to the +`/watchers/me` response. -GET /api/tags/autocomplete?q=swi -Response 200: { "tags": [{ "tag": "swift", "count": 42 }, { "tag": "swiftui", ... }] } -``` +### C2. `GET /api/lists/{id}/watchers/users` uses a non-standard pagination block -**Prompt:** +Everywhere else pagination is `{ total, limit, offset, hasMore }`. Here it +is `{ limit, offset, hasMore }` with `total` hoisted to a **sibling** +top-level field. This actually broke the iOS decoder once (it reused the +shared `Pagination` type, which requires `total`) and had to be special- +cased. Standardizing this block — or documenting the difference — would +prevent the trap. -``` -Add two tag-discovery endpoints: +### C3. `POST /api/lists/{id}/watchers` — self-watch semantics + return shape -GET /api/tags/trending?limit={n}&window={day|week|month} - Returns the top tags by message count over the window. Default - limit 20, default window week. - -GET /api/tags/autocomplete?q={prefix}&limit={n} - Returns tags matching the prefix, ordered by usage. Used by the - InterlinedList iOS compose UI for `#...` autocomplete. - -Both endpoints scoped to public messages only. -``` +- The body is `{ userId, role }` and the response is `{ watching: boolean }` + (no created-watcher object / role echoed back). +- For the public "Watch this list" CTA the caller is adding **themselves**. + iOS sends its own `userId` with `role: "watcher"`. It's unclear whether + `userId` is required for self-watch or whether omitting it defaults to + the authenticated user. Please document the self-watch path explicitly. --- -### B7. Avatar response includes updated user - -**Gap:** `POST /api/user/avatar/upload` returns `{ url }`. Per the docs -no other user state is returned — clients have to re-fetch -`GET /api/user` to see the new avatar reflected on the user object. -**Re-verified 2026-06-23:** `/help/api/users-and-profile` still publishes -no response shape for either avatar endpoint. Likely unchanged. +## D. Cross-posting & link metadata (Phase 4) — undocumented response detail -**Why it matters:** Minor — costs one extra round-trip on Phase 3 -avatar upload. Worth noting but not blocking. +### D1. `POST /api/messages` cross-post result shape -**Proposed contract:** Return the full updated user object from both -`POST /api/user/avatar/upload` and `POST /api/user/avatar/from-url`, -e.g. `{ "user": { ... }, "url": "..." }`. +The create response is documented as no body, but the message-compose UI +wants to tell the user "Posted to Bluesky ✓ · Mastodon ✗". iOS decodes an +**optional** `crossPostResults: [{ platform, success, error }]` and simply +shows nothing if it's absent. Please confirm whether the endpoint returns +per-platform results and, if so, the exact field names. -**Prompt (low priority, can wait for a wider profile-endpoint pass):** - -``` -Have `POST /api/user/avatar/upload` and `POST /api/user/avatar/from-url` -return the full updated user object alongside the new URL, so clients -don't have to re-fetch `GET /api/user` to see the avatar change reflect -across the app. -``` - ---- +### D2. `linkedInTargets` value vocabulary -### B9. `GET /api/follow/:userId/status` returns inconsistent shape for self +`POST /api/messages` accepts `linkedInTargets: [{ kind }]`. The valid +`kind` values aren't documented (`"personal"` / `"organization"`?), nor +whether an organization target needs an `organizationId`. iOS currently +only sends the simple `crossPostToLinkedIn` boolean and leaves targets +empty pending this. -**Gap:** When the authenticated user queries follow-status for **their own** -user ID, the response is 200 but the body omits the documented -`following` / `followedBy` / `pendingRequest` fields, breaking the -documented `FollowStatus` decode contract. +### D3. Two different link-metadata shapes -**Discovered:** 2026-06-23 via E2E test -`E2EReadOnlyTests.test_e2e_followStatus_forSelf_respondsWithoutCrashing`. +- The **feed** message object exposes `linkMetadata.links[]` as + `{ url, platform, metadata: { thumbnail, title, description, text, type }, fetchStatus }`. +- `POST /api/messages/{id}/metadata` returns + `{ message, metadata: { links: [{ url, title, description, image }] } }` + — a flatter, differently-named shape (`image` vs `metadata.thumbnail`). -**Why it matters:** Low — the iOS app never queries self-follow-status in -production (the UI doesn't render a follow button on the current user's -own profile). But the behavior is undocumented and any client code that -*does* hit the endpoint with self ID will crash on decode. +iOS models both separately. One consistent link-preview schema across read +and refresh would be simpler for every client. -**Resolution options:** +--- -- (a) Return 400 with `{ "error": "Cannot query follow status for self" }` - so clients get a clear contract violation. -- (b) Return the documented shape with `following: false`, - `followedBy: false`, `pendingRequest: false` for self. -- (c) Document the divergent shape on `/help/api/following`. +## E. Avatar endpoints don't return the updated user (carried over, low) -**Priority:** Low. The iOS test tolerates the current behavior; this -exists primarily to flag it for the backend team. +`POST /api/user/avatar/upload` and `/api/user/avatar/from-url` return only +`{ url }`. iOS issues a follow-up `GET /api/user` to refresh the avatar +everywhere. Returning the full updated `user` would drop that round-trip. --- -### B8. Real-time / push for feed updates - -**Gap:** No WebSocket, SSE, or long-poll endpoint for live feed / -notification updates. Everything is pull-only. - -**Why it matters:** Not a blocker for any current iOS phase, but a -social feed without real-time updates feels stale on mobile. Worth -acknowledging as a long-term gap. +## F. Still genuinely missing / blocked (not just under-documented) -**Resolution:** Not requesting implementation here — this is a major -backend effort. iOS Phase 9 (APNs push) covers the highest-value real- -time signal (notification tap → deep link to message). Live feed scroll -can stay pull-to-refresh for v1. +These remain real gaps blocking specific iOS features: -No prompt — this is a placeholder for "we acknowledge this exists and -will revisit." +| Ref | Gap | Blocks | +|---|---|---| +| **B4** | `/api/github/*` requires a **session cookie** and rejects Bearer tokens. iOS is Bearer-only. | Phase 11 (GitHub integration) — deferred until Bearer auth or a `session-from-bearer` exchange exists. | +| **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. | --- -## Summary +## G. What iOS now consumes (for reference) -### What backend has now (we use ~all of it) +Every endpoint family below is wired and tested as of 2026-06-25: -The iOS client now calls every endpoint family the docs publish, except -for the gaps below: +auth (email/password + OAuth ×5, reset/verify, email change, delete +account) · user core + settings + avatar + identities + organizations · +messages CRUD + **cross-post + repost + scheduled PATCH + search + +metadata** · image/video upload · lists CRUD + **structured schema** + +folders + connections + **watchers** · documents CRUD + folders + search + +**public reader** · **public browse (lists/docs)** · following + +**followers/following/mutuals/remove** · notifications tray + +**preferences** · exports. -| Endpoint family | iOS uses? | Notes | -|---|---|---| -| Auth (email/password, sync-token) | ✅ | OAuth ×5 + reset/verify pending (Phase 2) | -| User core | ✅ | `customerStatus` now decoded | -| Avatar upload | ❌ | Phase 3 | -| Identities / orgs (user-level) | ❌ | Phase 2 / 3 | -| Email change | ❌ | Phase 2 | -| Messages CRUD | ✅ | cross-post fields + repost pending (Phase 4) | -| Scheduled messages PATCH | ❌ | Phase 4 | -| Image / video upload | ✅ | | -| Lists CRUD + schema | ✅ | schema PUT body shape inferred (§B0) | -| List folders | ✅ | subscriber-403 paywall plumbed | -| List watchers | ❌ | Phase 6 (blocked partly on §B5) | -| List connections | ✅ | | -| Documents CRUD + folders + search | ✅ | | -| Document sync | ❌ | Phase 10 | -| Document image upload | ❌ | Phase 10 | -| Following (basic) | ✅ | followers/following/mutuals/remove pending (Phase 5) | -| Notifications tray | ✅ | per-notification GET/DELETE pending | -| Push notifications | ❌ | Phase 9 | -| Notification preferences | ❌ | blocked on §B3 | -| Organizations | ❌ | Phase 8 | -| Exports | ✅ | | -| Subscriptions (Stripe) | ❌ | Phase 3 (blocked on §B1) | -| GitHub integration | ❌ | Phase 11 (blocked on §B4) | -| LinkedIn integration | ❌ | deferred | -| Utility endpoints (location, weather, image proxy) | ❌ | out of scope for v1 iOS | - -### Backend gap priority (all re-verified 2026-06-23) - -| § | Gap | iOS phase blocked | Priority | Re-check | -|---|---|---|---|---| -| B0 | Document/structure schema PUT body | Phase 1 (fidelity) | High | Still standing | -| B5 | Document watcher role values + POST body | Phase 6 | High | Still standing | -| B1 | Subscription plans catalog (+ docs page itself 404s) | Phase 3 (paywall fidelity) | **Raised: High** | Worse than prior | -| B2 | Message search | Phase 13 | Medium | Still standing | -| B3 | Notification preferences enumeration | Phase 9 / 12 | Medium | Still standing | -| B4 | Bearer auth on `/api/github/*` | Phase 11 | Low (deferred) | Still standing | -| B6 | Tag discovery / autocomplete | Phase 13 | Low | Still standing | -| B7 | Avatar response includes user | Phase 3 (UX nicety) | Low | Still standing | -| B9 | `follow/:userId/status` shape inconsistent for self | n/a (edge case) | Low | New 2026-06-23 (via E2E test) | -| B8 | Real-time feed updates | n/a (long-term) | Acknowledged | n/a | - -### What is NOT available via API (cannot ship on iOS until backend lands it) - -Pulled from the table above, sorted by what they enable: - -1. **Richer schema editing** — `isVisible` / `isRequired` toggles can't - round-trip until §B0 is resolved (DSL doc or structured endpoint). -2. **List collaboration UI** — Phase 6 can't ship a role picker until - §B5 documents the role wire values. -3. **In-app subscription paywall** — Phase 3 can hand-off to Safari - today, but a native paywall needs §B1's plans catalog. -4. **Feed search** — Phase 13's search bar needs §B2's - `/api/messages/search`. -5. **Notification preferences screen** — Phase 9 / 12 needs §B3's - enumeration endpoint. -6. **GitHub features (Bearer)** — Phase 11 needs §B4 unless we accept - building a cookie-jar workaround. -7. **Tag explorer / `#` autocomplete** — Phase 13 needs §B6. -8. **Live feed updates** — §B8, long-term. +Not consumed by design: Stripe/subscriptions (web-only — no in-app billing +UI), LinkedIn org integration, utility endpoints (location/weather/image +proxy), admin. diff --git a/GAP-NEXT-STEPS.md b/GAP-NEXT-STEPS.md index 587c536..ce80b6b 100644 --- a/GAP-NEXT-STEPS.md +++ b/GAP-NEXT-STEPS.md @@ -6,28 +6,68 @@ functionality parity with `interlinedlist.com`. 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-23 — after Phase 1 (gap-closure + schema editor + -subscriber awareness) shipped. +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. + +## Subscription / billing direction + +The iOS app is a **free** app with **no subscription, billing, or +paywall UI**. Subscriber-only features are **hidden** for non- +subscribers; there is no "subscribe" call-to-action anywhere in the +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 (basic):** email/password login + register via - `/api/auth/sync-token`, Keychain token storage, 401 → auto-logout. +- **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 (subscriber-gated paywall on create), +- **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. + 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` @@ -37,84 +77,39 @@ What's still missing — broken out into phases below. --- -## Phase 2 — Auth surface parity **Medium** - -Today: email/password only. Site has password reset, email verification, -five OAuth providers, identity linking, multi-account. +## Phase 2 — Auth surface parity ✅ Shipped 2026-06-24 -**Acceptance criteria:** +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. -- [ ] Password reset flow: - - [ ] "Forgot password?" link on `LoginView`. - - [ ] `ForgotPasswordView` posts to `/api/auth/forgot-password`. - - [ ] Deep-link handler in `InterlinedListApp` for - `interlinedlist://reset-password?token=...`; opens - `ResetPasswordView` which posts to `/api/auth/reset-password`. -- [ ] Email verification: - - [ ] On login, if `user.emailVerified == false`, show a - verification banner that calls - `POST /api/auth/send-verification-email` on tap. - - [ ] Deep-link handler for `interlinedlist://verify-email?token=...`. - - [ ] Gate `ComposeView` post button when unverified (match site). -- [ ] OAuth sign-in via `ASWebAuthenticationSession`: - - [ ] GitHub, Mastodon, Bluesky, LinkedIn, X — buttons on `LoginView` - and `RegisterView`. - - [ ] All append `?redirect_uri=interlinedlist://oauth/callback` to - the authorize URL so the Bearer token comes back via deep link. - - [ ] Mastodon prompts for the instance hostname before launching. - - [ ] Hide LinkedIn/X buttons when their `/status` endpoint says - `configured: false` for this deployment. -- [ ] Identity linking (signed-in user): - - [ ] `LinkedIdentitiesView` reads `GET /api/user/identities`, lists - providers with disconnect buttons (`DELETE - /api/user/identities`). - - [ ] "Link another provider" CTA reuses the OAuth flow with - `?link=true&redirect_uri=...`. -- [ ] Email change: - - [ ] `EditProfileView` → "Change email" → form posts to - `POST /api/user/change-email/request`. - - [ ] Deep-link handler for `interlinedlist://verify-email-change?token=...` - posts to `POST /api/auth/verify-email-change`. - -**Files:** `Views/LoginView.swift`, `Views/RegisterView.swift`, new -`Views/ForgotPasswordView.swift`, new `Views/ResetPasswordView.swift`, -new `Views/LinkedIdentitiesView.swift`, new `Views/OAuthCoordinator.swift`, -`Services/AuthState.swift`, `InterlinedListApp.swift` (URL scheme), -`Info.plist` (`CFBundleURLSchemes`). - -**APIClient additions:** `forgotPassword`, `resetPassword`, +**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`. -**Dependencies:** none. +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. --- -## Phase 3 — Profile / account management **Small** - -**Acceptance criteria:** - -- [ ] Avatar upload from photo library: `POST /api/user/avatar/upload` - (multipart). Show new avatar immediately on `EditProfileView` and - `UserProfileView`. -- [ ] Avatar from URL: `POST /api/user/avatar/from-url` (paste/select). -- [ ] Org memberships strip on profile: `GET /api/user/organizations`. -- [ ] "Delete account" in `EditProfileView`, double-confirmation, - `POST /api/user/delete` → forced logout. -- [ ] Subscriber CTA on profile when `!user.isSubscriber`: opens - `SFSafariViewController` to a checkout URL. - **(blocked on backend: needs `/api/subscriptions/plans`-style - endpoint OR a documented URL the iOS app can hand to Safari — - see `GAP-ENDPOINTS.md` §B1.)** - -**Files:** `Views/EditProfileView.swift`, `Views/UserProfileView.swift`, -new `Views/AvatarUploadView.swift` (or sheet from EditProfile). - -**APIClient additions:** `uploadAvatar`, `setAvatarFromURL`, -`userOrganizations`, `deleteAccount`. +## Phase 3 — Profile / account management ✅ Shipped 2026-06-24 -**Dependencies:** none. +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`). --- @@ -135,8 +130,10 @@ already plumbed through `postMessage`. `provider == "mastodon"`; sends `mastodonProviderIds[]`. - [ ] Pass `crossPostToBluesky`, `crossPostToLinkedIn`, `crossPostToTwitter` to `APIClient.postMessage(...)`. -- [ ] Disable every cross-post control when `!authState.user.isSubscriber`; - tap shows the existing paywall message style. +- [ ] **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 @@ -379,7 +376,9 @@ default visibility are partial. - [ ] Max message length — read-only display from `user.maxMessageLength`. - [ ] Show advanced post settings — boolean. - [ ] Connected accounts → Phase 2 `LinkedIdentitiesView`. - - [ ] Subscription status + manage subscription → Phase 3 CTA. + - [ ] Notification preferences → Phase 9 (currently blocked). - [ ] About → `SFSafariViewController` for `/blog`, `/pricing`, `/terms`, `/privacy`, `/help/branding`. @@ -419,22 +418,27 @@ When the endpoints ship: | # | Phase | Effort | Status | |---|---|---|---| -| 2 | Auth (reset / verify / OAuth ×5 / linking / email change) | Medium | not started | -| 3 | Profile / avatar / orgs / delete / subscriber CTA | Small | not started | -| 4 | Compose: schedule + cross-post + gating + edit / repost | Medium | scaffold present, needs wiring | -| 5 | Followers / following / mutuals / remove-follower | Small | not started | -| 6 | List watchers / roles / permission model | Large | not started | -| 7 | Public browse end-to-end | Small | not started | -| 8 | Organizations | Large | not started | -| 9 | Push notifications (APNs) | Medium | not started | -| 10 | Documents: image upload + sync + public reader | Large | not started | -| 11 | GitHub integration | Medium | **deferred** (auth model conflict) | -| 12 | Settings panel + webview content | Small | not started | -| 13 | Feed search + tag discovery | Small | **blocked on backend** | - -Roughly 4–6 weeks of focused dev work for the unblocked phases (2–10 -and 12). Phase 13 lights up automatically once the backend endpoints -ship. +| 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). --- diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index e6c8767..20f4cde 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 1775C9B4C2CF08267AF3B138 /* WatchersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5828367E38948582EF1DF2A /* WatchersListView.swift */; }; + 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */; }; + 5703F883D4E6186DB66E5833 /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */; }; + 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */; }; + 6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; }; + 9CA0B53AE87AA79D8BAC81FF /* FollowListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF29F5DC946D80A63000209 /* FollowListView.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 */; }; @@ -23,12 +29,12 @@ A1B1C1D1E1F1002D /* List.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F1002E /* List.swift */; }; A1B1C1D1E1F1002F /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10030 /* ListsView.swift */; }; A1B1C1D1E1F10031 /* EditMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10032 /* EditMessageView.swift */; }; - A1B1C1D1E1F100A1 /* ListSchemaEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F100A2 /* ListSchemaEditorView.swift */; }; - A1B1C1D1E1F100A3 /* ListSchemaDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F100A4 /* ListSchemaDraft.swift */; }; A1B1C1D1E1F10033 /* MessageThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10034 /* MessageThreadView.swift */; }; A1B1C1D1E1F10035 /* CreateListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10036 /* CreateListView.swift */; }; A1B1C1D1E1F10037 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10038 /* UserProfileView.swift */; }; A1B1C1D1E1F10039 /* ScheduledMessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F1003A /* ScheduledMessagesView.swift */; }; + A1B1C1D1E1F100A1 /* ListSchemaEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F100A2 /* ListSchemaEditorView.swift */; }; + A1B1C1D1E1F100A3 /* ListSchemaDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F100A4 /* ListSchemaDraft.swift */; }; A2B2C2D2E2F20041 /* AppNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B2C2D2E2F20042 /* AppNotification.swift */; }; A2B2C2D2E2F20043 /* FollowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B2C2D2E2F20044 /* FollowState.swift */; }; A2B2C2D2E2F20045 /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B2C2D2E2F20046 /* NotificationsView.swift */; }; @@ -37,6 +43,7 @@ A3C3D3E3F3A30051 /* Document.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C3D3E3F3A30052 /* Document.swift */; }; A3C3D3E3F3A30053 /* DocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C3D3E3F3A30054 /* DocumentsView.swift */; }; A3C3D3E3F3A30055 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C3D3E3F3A30056 /* URLSessionProtocol.swift */; }; + A5C47F6A46E3CE8708789CB2 /* PublicBrowse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B5AC4D76899CEAF620E402 /* PublicBrowse.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 */; }; @@ -44,6 +51,18 @@ B1C1D1E1F1A10009 /* ListSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000A /* ListSkeletonView.swift */; }; B1C1D1E1F1A1000B /* DocumentSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000C /* DocumentSkeletonView.swift */; }; B1C1D1E1F1A1000D /* ListItemFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000E /* ListItemFormView.swift */; }; + C1D1E1F1A1B10001 /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D1E1F1A1B10002 /* Organization.swift */; }; + CE93B45A2285EF8FD995D9FE /* OrganizationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0041BEAB46F9186FB5BF907 /* OrganizationsView.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 */; }; + D0A1D0A1D0A10007 /* ResetPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A10008 /* ResetPasswordView.swift */; }; + D0A1D0A1D0A10009 /* EmailVerificationBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000A /* EmailVerificationBanner.swift */; }; + D0A1D0A1D0A1000B /* LinkedIdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */; }; + 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 */; }; + F93C3A953A0B809D718F9DB5 /* PublicDocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */; }; T1E5T1E5T1E50001 /* InterlinedListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50002 /* InterlinedListTests.swift */; }; T1E5T1E5T1E50003 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50004 /* MockURLSession.swift */; }; T1E5T1E5T1E50005 /* APIClientAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50006 /* APIClientAuthTests.swift */; }; @@ -73,6 +92,15 @@ T1E5T1E5T1E5P011 /* EnvLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P012 /* EnvLoader.swift */; }; T1E5T1E5T1E5P013 /* E2EReadOnlyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P014 /* E2EReadOnlyTests.swift */; }; T1E5T1E5T1E5P015 /* KeychainServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P016 /* KeychainServiceTests.swift */; }; + T1E5T1E5T1E5P017 /* APIClientPasswordResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P018 /* APIClientPasswordResetTests.swift */; }; + T1E5T1E5T1E5P019 /* APIClientEmailVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P01A /* APIClientEmailVerificationTests.swift */; }; + T1E5T1E5T1E5P01B /* APIClientIdentitiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P01C /* APIClientIdentitiesTests.swift */; }; + T1E5T1E5T1E5P01D /* APIClientOAuthStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P01E /* APIClientOAuthStatusTests.swift */; }; + T1E5T1E5T1E5P01F /* APIClientAvatarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P020 /* APIClientAvatarTests.swift */; }; + T1E5T1E5T1E5P021 /* APIClientOrganizationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P022 /* APIClientOrganizationsTests.swift */; }; + T1E5T1E5T1E5P023 /* APIClientDeleteAccountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P024 /* APIClientDeleteAccountTests.swift */; }; + T1E5T1E5T1E5P025 /* OrganizationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */; }; + T1E5T1E5T1E5P027 /* LinkedIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -86,6 +114,12 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0EF29F5DC946D80A63000209 /* FollowListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowListView.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 = ""; }; + 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicBrowse.swift; sourceTree = ""; }; + 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.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 = ""; }; A1B1C1D1E1F10004 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -104,12 +138,12 @@ A1B1C1D1E1F1002E /* List.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = List.swift; sourceTree = ""; }; A1B1C1D1E1F10030 /* ListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsView.swift; sourceTree = ""; }; A1B1C1D1E1F10032 /* EditMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMessageView.swift; sourceTree = ""; }; - A1B1C1D1E1F100A2 /* ListSchemaEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSchemaEditorView.swift; sourceTree = ""; }; - A1B1C1D1E1F100A4 /* ListSchemaDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSchemaDraft.swift; sourceTree = ""; }; A1B1C1D1E1F10034 /* MessageThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageThreadView.swift; sourceTree = ""; }; A1B1C1D1E1F10036 /* CreateListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListView.swift; sourceTree = ""; }; A1B1C1D1E1F10038 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; A1B1C1D1E1F1003A /* ScheduledMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledMessagesView.swift; sourceTree = ""; }; + A1B1C1D1E1F100A2 /* ListSchemaEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSchemaEditorView.swift; sourceTree = ""; }; + A1B1C1D1E1F100A4 /* ListSchemaDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSchemaDraft.swift; sourceTree = ""; }; A2B2C2D2E2F20042 /* AppNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotification.swift; sourceTree = ""; }; A2B2C2D2E2F20044 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = ""; }; A2B2C2D2E2F20046 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; @@ -125,6 +159,19 @@ B1C1D1E1F1A1000A /* ListSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSkeletonView.swift; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + D0A1D0A1D0A10008 /* ResetPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPasswordView.swift; sourceTree = ""; }; + 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 = ""; }; + E5828367E38948582EF1DF2A /* WatchersListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchersListView.swift; sourceTree = ""; }; + ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationPreference.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 = ""; }; T1E5T1E5T1E50000 /* InterlinedListTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InterlinedListTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; T1E5T1E5T1E50002 /* InterlinedListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListTests.swift; sourceTree = ""; }; @@ -156,6 +203,15 @@ T1E5T1E5T1E5P012 /* EnvLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvLoader.swift; sourceTree = ""; }; T1E5T1E5T1E5P014 /* E2EReadOnlyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = E2EReadOnlyTests.swift; sourceTree = ""; }; T1E5T1E5T1E5P016 /* KeychainServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainServiceTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P018 /* APIClientPasswordResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientPasswordResetTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P01A /* APIClientEmailVerificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientEmailVerificationTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P01C /* APIClientIdentitiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientIdentitiesTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P01E /* APIClientOAuthStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientOAuthStatusTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P020 /* APIClientAvatarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientAvatarTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P022 /* APIClientOrganizationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientOrganizationsTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P024 /* APIClientDeleteAccountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientDeleteAccountTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizationModelTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedIdentityModelTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -218,6 +274,10 @@ A2B2C2D2E2F20042 /* AppNotification.swift */, A2B2C2D2E2F20044 /* FollowState.swift */, A3C3D3E3F3A30052 /* Document.swift */, + C1D1E1F1A1B10002 /* Organization.swift */, + F0470C00F1A0E215224120A1 /* ListWatcher.swift */, + ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */, + 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */, ); path = Models; sourceTree = ""; @@ -231,6 +291,7 @@ A3C3D3E3F3A30056 /* URLSessionProtocol.swift */, B1C1D1E1F1A10002 /* DataCache.swift */, B1C1D1E1F1A10004 /* AppDataStore.swift */, + D0A1D0A1D0A10002 /* OAuthCoordinator.swift */, ); path = Services; sourceTree = ""; @@ -260,6 +321,18 @@ B1C1D1E1F1A1000A /* ListSkeletonView.swift */, B1C1D1E1F1A1000C /* DocumentSkeletonView.swift */, B1C1D1E1F1A1000E /* ListItemFormView.swift */, + D0A1D0A1D0A10004 /* ChangeEmailView.swift */, + D0A1D0A1D0A10006 /* ForgotPasswordView.swift */, + D0A1D0A1D0A10008 /* ResetPasswordView.swift */, + D0A1D0A1D0A1000A /* EmailVerificationBanner.swift */, + D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */, + D0A1D0A1D0A1000E /* OAuthSignInButton.swift */, + 0EF29F5DC946D80A63000209 /* FollowListView.swift */, + 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */, + 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */, + E5828367E38948582EF1DF2A /* WatchersListView.swift */, + C0041BEAB46F9186FB5BF907 /* OrganizationsView.swift */, + 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */, ); path = Views; sourceTree = ""; @@ -277,23 +350,6 @@ path = InterlinedListTests; sourceTree = ""; }; - T1E5T1E5T1E5Q001 /* ServiceTests */ = { - isa = PBXGroup; - children = ( - T1E5T1E5T1E5P016 /* KeychainServiceTests.swift */, - ); - path = ServiceTests; - sourceTree = ""; - }; - T1E5T1E5T1E5Q002 /* E2E */ = { - isa = PBXGroup; - children = ( - T1E5T1E5T1E5P012 /* EnvLoader.swift */, - T1E5T1E5T1E5P014 /* E2EReadOnlyTests.swift */, - ); - path = E2E; - sourceTree = ""; - }; T1E5T1E5T1E50011 /* APIClientTests */ = { isa = PBXGroup; children = ( @@ -313,6 +369,14 @@ T1E5T1E5T1E5P004 /* APIClientSearchDocumentsTests.swift */, T1E5T1E5T1E5P006 /* APIClientSearchListsTests.swift */, T1E5T1E5T1E5P010 /* APIClientUpdateListSchemaTests.swift */, + T1E5T1E5T1E5P018 /* APIClientPasswordResetTests.swift */, + T1E5T1E5T1E5P01A /* APIClientEmailVerificationTests.swift */, + T1E5T1E5T1E5P01C /* APIClientIdentitiesTests.swift */, + T1E5T1E5T1E5P01E /* APIClientOAuthStatusTests.swift */, + T1E5T1E5T1E5P020 /* APIClientAvatarTests.swift */, + T1E5T1E5T1E5P022 /* APIClientOrganizationsTests.swift */, + T1E5T1E5T1E5P024 /* APIClientDeleteAccountTests.swift */, + B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */, ); path = APIClientTests; sourceTree = ""; @@ -328,10 +392,30 @@ T1E5T1E5T1E5M006 /* FollowStateModelTests.swift */, T1E5T1E5T1E5M008 /* DocumentModelTests.swift */, T1E5T1E5T1E5P008 /* ListSchemaDraftTests.swift */, + T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */, + T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */, + 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */, ); path = ModelTests; sourceTree = ""; }; + T1E5T1E5T1E5Q001 /* ServiceTests */ = { + isa = PBXGroup; + children = ( + T1E5T1E5T1E5P016 /* KeychainServiceTests.swift */, + ); + path = ServiceTests; + sourceTree = ""; + }; + T1E5T1E5T1E5Q002 /* E2E */ = { + isa = PBXGroup; + children = ( + T1E5T1E5T1E5P012 /* EnvLoader.swift */, + T1E5T1E5T1E5P014 /* E2EReadOnlyTests.swift */, + ); + path = E2E; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -456,6 +540,23 @@ B1C1D1E1F1A10009 /* ListSkeletonView.swift in Sources */, B1C1D1E1F1A1000B /* DocumentSkeletonView.swift in Sources */, B1C1D1E1F1A1000D /* ListItemFormView.swift in Sources */, + D0A1D0A1D0A10001 /* OAuthCoordinator.swift in Sources */, + D0A1D0A1D0A10003 /* ChangeEmailView.swift in Sources */, + D0A1D0A1D0A10005 /* ForgotPasswordView.swift in Sources */, + D0A1D0A1D0A10007 /* ResetPasswordView.swift in Sources */, + D0A1D0A1D0A10009 /* EmailVerificationBanner.swift in Sources */, + D0A1D0A1D0A1000B /* LinkedIdentitiesView.swift in Sources */, + D0A1D0A1D0A1000D /* OAuthSignInButton.swift in Sources */, + C1D1E1F1A1B10001 /* Organization.swift in Sources */, + 6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */, + 5703F883D4E6186DB66E5833 /* NotificationPreference.swift in Sources */, + A5C47F6A46E3CE8708789CB2 /* PublicBrowse.swift in Sources */, + 9CA0B53AE87AA79D8BAC81FF /* FollowListView.swift in Sources */, + D6AA238FF5D318D9C03FA6B5 /* PublicListDetailView.swift in Sources */, + F93C3A953A0B809D718F9DB5 /* PublicDocumentsView.swift in Sources */, + 1775C9B4C2CF08267AF3B138 /* WatchersListView.swift in Sources */, + CE93B45A2285EF8FD995D9FE /* OrganizationsView.swift in Sources */, + D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -492,6 +593,17 @@ T1E5T1E5T1E5P011 /* EnvLoader.swift in Sources */, T1E5T1E5T1E5P013 /* E2EReadOnlyTests.swift in Sources */, T1E5T1E5T1E5P015 /* KeychainServiceTests.swift in Sources */, + T1E5T1E5T1E5P017 /* APIClientPasswordResetTests.swift in Sources */, + T1E5T1E5T1E5P019 /* APIClientEmailVerificationTests.swift in Sources */, + T1E5T1E5T1E5P01B /* APIClientIdentitiesTests.swift in Sources */, + T1E5T1E5T1E5P01D /* APIClientOAuthStatusTests.swift in Sources */, + T1E5T1E5T1E5P01F /* APIClientAvatarTests.swift in Sources */, + T1E5T1E5T1E5P021 /* APIClientOrganizationsTests.swift in Sources */, + T1E5T1E5T1E5P023 /* APIClientDeleteAccountTests.swift in Sources */, + T1E5T1E5T1E5P025 /* OrganizationModelTests.swift in Sources */, + T1E5T1E5T1E5P027 /* LinkedIdentityModelTests.swift in Sources */, + 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */, + 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList.xctestplan b/InterlinedList.xctestplan index 3003320..d49a5e7 100644 --- a/InterlinedList.xctestplan +++ b/InterlinedList.xctestplan @@ -17,7 +17,7 @@ }, "testTargets" : [ { - "parallelizable" : true, + "parallelizable" : false, "target" : { "containerPath" : "container:InterlinedList.xcodeproj", "identifier" : "T1E5T1E5T1E5000D", diff --git a/InterlinedList/Info.plist b/InterlinedList/Info.plist index 34fc1b0..8ac8584 100644 --- a/InterlinedList/Info.plist +++ b/InterlinedList/Info.plist @@ -50,5 +50,18 @@ ILAPIBaseURL + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.interlinedlist.app + CFBundleURLSchemes + + interlinedlist + + + diff --git a/InterlinedList/InterlinedListApp.swift b/InterlinedList/InterlinedListApp.swift index 5f1bb9c..7320254 100644 --- a/InterlinedList/InterlinedListApp.swift +++ b/InterlinedList/InterlinedListApp.swift @@ -9,15 +9,100 @@ import SwiftUI struct InterlinedListApp: App { @StateObject private var authState = AuthState() @StateObject private var store = AppDataStore() + @StateObject private var router = AppRouter() var body: some Scene { WindowGroup { RootView() .environmentObject(authState) .environmentObject(store) + .environmentObject(router) .onChange(of: authState.hasToken) { _, has in if !has { store.reset() } } + .onOpenURL { url in + handleDeepLink(url) + } + .sheet(item: $router.pendingDeepLink) { link in + deepLinkSheet(for: link) + } + } + } + + @ViewBuilder + private func deepLinkSheet(for link: AppDeepLink) -> some View { + switch link { + case .resetPassword(let token): + ResetPasswordView(token: token) + } + } + + 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 + } + } + + @MainActor + private func verifyEmail(token: String) async { + do { + try await APIClient.shared.verifyEmail(token: token) + await authState.refreshUser() + } catch { + // Surfacing this through router would require an alert path; skip + // silently. The user will see emailVerified flip in the banner if it + // succeeded. + } + } + + @MainActor + private func verifyEmailChange(token: String) async { + do { + try await APIClient.shared.verifyEmailChange(token: token) + await authState.refreshUser() + } catch { + // Same rationale as verifyEmail above. } } } + +enum AppDeepLink: Identifiable, Hashable { + case resetPassword(token: String) + + var id: String { + switch self { + case .resetPassword(let token): return "reset:" + token + } + } +} + +@MainActor +final class AppRouter: ObservableObject { + @Published var pendingDeepLink: AppDeepLink? +} diff --git a/InterlinedList/Models/FollowState.swift b/InterlinedList/Models/FollowState.swift index e87f7c5..ee0e69b 100644 --- a/InterlinedList/Models/FollowState.swift +++ b/InterlinedList/Models/FollowState.swift @@ -25,3 +25,36 @@ struct FollowRequest: Identifiable, Codable { struct FollowRequestsResponse: Codable { let requests: [FollowRequest] } + +/// A user appearing in a followers / following list. +struct FollowUser: Identifiable, Codable, Equatable { + let id: String + let username: String + let displayName: String? + let avatar: String? + let followId: String? + /// "accepted" | "pending" (relationship status of this edge). + let status: String? + let createdAt: String? + + var displayNameOrUsername: String { + displayName?.isEmpty == false ? (displayName ?? username) : username + } +} + +struct FollowersResponse: Codable { + let followers: [FollowUser] + let pagination: Pagination? +} + +struct FollowingResponse: Codable { + let following: [FollowUser] + let pagination: Pagination? +} + +/// Mutual-connection counts between the current user and another user. +/// Note: the endpoint returns counts only, not a user list. +struct MutualCounts: Codable, Equatable { + let mutualFollowers: Int + let mutualFollowing: Int +} diff --git a/InterlinedList/Models/List.swift b/InterlinedList/Models/List.swift index c1522b4..4771547 100644 --- a/InterlinedList/Models/List.swift +++ b/InterlinedList/Models/List.swift @@ -72,6 +72,31 @@ struct ListDetailResponse: Decodable { let data: ListDetailData } +/// One property in the structured PUT /api/lists/[id]/schema body. +/// `id` present → update in place (preserve row data); `id` nil → create new. +/// Properties omitted from the array are soft-deleted (use `?force=true` to drop +/// columns that still contain data). Array order is authoritative for displayOrder. +struct SchemaPropertyInput: Encodable { + let id: String? + let propertyKey: String + let propertyName: String + let propertyType: String + let displayOrder: Int + let isVisible: Bool + let isRequired: Bool + let defaultValue: String? + let helpText: String? + let placeholder: String? +} + +struct StructuredSchemaBody: Encodable { + let properties: [SchemaPropertyInput] +} + +struct SchemaUpdateResponse: Decodable { + let properties: [ListPropertyDef]? +} + // MARK: - Core list models struct UserList: Identifiable, Codable, Hashable { diff --git a/InterlinedList/Models/ListSchemaDraft.swift b/InterlinedList/Models/ListSchemaDraft.swift index 6778c09..25cc388 100644 --- a/InterlinedList/Models/ListSchemaDraft.swift +++ b/InterlinedList/Models/ListSchemaDraft.swift @@ -12,9 +12,17 @@ struct DraftProperty: Identifiable, Equatable { var propertyType: String var isVisible: Bool var isRequired: Bool + // Preserved through round-trips even though the v1 editor doesn't expose them. + var defaultValue: String? + var helpText: String? + var placeholder: String? static let supportedTypes: [String] = ["text", "number", "boolean", "date", "url", "email"] + /// New (unsaved) properties carry a synthetic id so the structured schema + /// update knows to create rather than update them. + var isNew: Bool { id.hasPrefix("new-") } + init(from def: ListPropertyDef) { self.id = def.id self.propertyKey = def.propertyKey @@ -22,15 +30,23 @@ struct DraftProperty: Identifiable, Equatable { self.propertyType = def.propertyType self.isVisible = def.isVisible self.isRequired = def.isRequired + self.defaultValue = def.defaultValue + self.helpText = def.helpText + self.placeholder = def.placeholder } - init(id: String, propertyKey: String, propertyName: String, propertyType: String, isVisible: Bool, isRequired: Bool) { + init(id: String, propertyKey: String, propertyName: String, propertyType: String, + isVisible: Bool, isRequired: Bool, + defaultValue: String? = nil, helpText: String? = nil, placeholder: String? = nil) { self.id = id self.propertyKey = propertyKey self.propertyName = propertyName self.propertyType = propertyType self.isVisible = isVisible self.isRequired = isRequired + self.defaultValue = defaultValue + self.helpText = helpText + self.placeholder = placeholder } static func newBlank() -> DraftProperty { @@ -86,6 +102,46 @@ enum ListSchemaDraft { .joined(separator: ", ") } + /// Derive a snake_case property key from a display name (for new properties). + /// "Have Read?" → "have_read". Falls back to "field" when nothing remains. + static func slugifyKey(_ name: String) -> String { + let lowered = name.lowercased() + let mapped = lowered.map { ch -> Character in + (ch.isLetter || ch.isNumber) ? ch : "_" + } + let collapsed = String(mapped) + .split(separator: "_", omittingEmptySubsequences: true) + .joined(separator: "_") + return collapsed.isEmpty ? "field" : collapsed + } + + /// Maps the draft list into the structured PUT /api/lists/[id]/schema body. + /// Array order drives `displayOrder`. New properties (synthetic id) omit `id` + /// and get a slugified key; existing properties keep their id and original key + /// (the backend rejects propertyKey renames). Properties with an empty name are + /// dropped, which the backend treats as a soft-delete. + static func structuredProperties(_ properties: [DraftProperty]) -> [SchemaPropertyInput] { + properties.enumerated().compactMap { index, prop in + let name = prop.propertyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return nil } + let key = prop.isNew + ? (prop.propertyKey.isEmpty ? slugifyKey(name) : prop.propertyKey) + : prop.propertyKey + return SchemaPropertyInput( + id: prop.isNew ? nil : prop.id, + propertyKey: key, + propertyName: name, + propertyType: prop.propertyType, + displayOrder: index, + isVisible: prop.isVisible, + isRequired: prop.isRequired, + defaultValue: prop.defaultValue, + helpText: prop.helpText, + placeholder: prop.placeholder + ) + } + } + static func isSchemaValid(_ properties: [DraftProperty]) -> Bool { for prop in properties { let trimmed = prop.propertyName.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/InterlinedList/Models/ListWatcher.swift b/InterlinedList/Models/ListWatcher.swift new file mode 100644 index 0000000..f3fd2ba --- /dev/null +++ b/InterlinedList/Models/ListWatcher.swift @@ -0,0 +1,97 @@ +// +// ListWatcher.swift +// InterlinedList +// + +import Foundation + +/// A user's access role on a shared list, in ascending privilege order. +/// Wire values confirmed by the backend (GAP §B5): watcher / collaborator / manager. +enum WatcherRole: String, Codable, CaseIterable, Comparable { + case watcher + case collaborator + case manager + + var label: String { + switch self { + case .watcher: return "Watcher" + case .collaborator: return "Collaborator" + case .manager: return "Manager" + } + } + + var detail: String { + switch self { + case .watcher: return "Can view this list" + case .collaborator: return "Can add and edit rows" + case .manager: return "Can edit the schema and manage access" + } + } + + /// Collaborators and managers may edit row data. + var canEditRows: Bool { self >= .collaborator } + /// Only managers may edit the schema and manage watchers. + var canManage: Bool { self == .manager } + + private var rank: Int { + switch self { + case .watcher: return 0 + case .collaborator: return 1 + case .manager: return 2 + } + } + + static func < (lhs: WatcherRole, rhs: WatcherRole) -> Bool { lhs.rank < rhs.rank } +} + +struct ListWatcher: Identifiable, Codable { + let id: String + let userId: String + let role: String + let createdAt: String? + let user: WatcherUser? + + var watcherRole: WatcherRole? { WatcherRole(rawValue: role) } +} + +struct WatcherUser: Codable { + let id: String + let username: String + let displayName: String? + let avatar: String? + + var displayNameOrUsername: String { + displayName?.isEmpty == false ? (displayName ?? username) : username + } +} + +/// A candidate returned by the user-search endpoint when adding a watcher. +struct WatcherCandidate: Identifiable, Codable { + let id: String + let username: String + let displayName: String? + let email: String? + let avatar: String? + + var displayNameOrUsername: String { + displayName?.isEmpty == false ? (displayName ?? username) : username + } +} + +// MARK: - API response wrappers + +struct WatchersResponse: Decodable { + let watchers: [ListWatcher] +} + +struct WatcherCandidatesResponse: Decodable { + let users: [WatcherCandidate] + let total: Int? + // Note: this endpoint's `pagination` block is { limit, offset, hasMore } + // (no `total` — that lives at the top level), so it isn't decoded into the + // shared Pagination type. The UI only consumes `users`. +} + +struct WatchingResponse: Decodable { + let watching: Bool +} diff --git a/InterlinedList/Models/Message.swift b/InterlinedList/Models/Message.swift index 8823cfe..5769e93 100644 --- a/InterlinedList/Models/Message.swift +++ b/InterlinedList/Models/Message.swift @@ -32,6 +32,16 @@ struct LinkMetadata: Codable { let links: [LinkMetadataItem] } +/// A single resolved link preview from POST /api/messages/:id/metadata. +struct MessageLinkPreview: Codable, Identifiable { + let url: String + let title: String? + let description: String? + let image: String? + + var id: String { url } +} + struct Message: Codable, Identifiable { let id: String let content: String @@ -75,6 +85,34 @@ struct Pagination: Codable { let hasMore: Bool } +/// A single cross-post target on LinkedIn (personal profile or an organization page). +struct LinkedInTarget: Codable, Equatable { + let kind: String // "personal" | "organization" + let organizationId: String? + + init(kind: String, organizationId: String? = nil) { + self.kind = kind + self.organizationId = organizationId + } +} + +/// Cross-post configuration carried on a scheduled message (PATCH /api/messages/:id). +struct ScheduledCrossPostConfig: Codable, Equatable { + var mastodonProviderIds: [String]? + var crossPostToBluesky: Bool? + var crossPostToLinkedIn: Bool? + var linkedInLinkAsFirstComment: Bool? + var linkedInTargets: [LinkedInTarget]? + var crossPostToTwitter: Bool? + + var isEmpty: Bool { + (mastodonProviderIds?.isEmpty ?? true) + && crossPostToBluesky != true + && crossPostToLinkedIn != true + && crossPostToTwitter != true + } +} + struct CreateMessageBody: Encodable { let content: String let publiclyVisible: Bool? @@ -83,9 +121,30 @@ struct CreateMessageBody: Encodable { let scheduledAt: String? let imageUrls: [String]? let videoUrls: [String]? + // Repost / push + var pushedMessageId: String? + // Cross-posting (subscriber-only; omitted entirely for free users) + var mastodonProviderIds: [String]? + var crossPostToBluesky: Bool? + var crossPostToLinkedIn: Bool? + var linkedInTargets: [LinkedInTarget]? + var linkedInLinkAsFirstComment: Bool? + var crossPostToTwitter: Bool? + var scheduledCrossPostConfig: ScheduledCrossPostConfig? +} + +/// One platform's result after a cross-post attempt. Surfaced in a post-publish toast. +/// Best-effort: the create response may or may not include this depending on deployment. +struct CrossPostResult: Codable, Identifiable { + let platform: String + let success: Bool + let error: String? + + var id: String { platform } } struct CreateMessageResponse: Codable { let message: String? let data: Message? + let crossPostResults: [CrossPostResult]? } diff --git a/InterlinedList/Models/NotificationPreference.swift b/InterlinedList/Models/NotificationPreference.swift new file mode 100644 index 0000000..db90925 --- /dev/null +++ b/InterlinedList/Models/NotificationPreference.swift @@ -0,0 +1,39 @@ +// +// NotificationPreference.swift +// InterlinedList +// + +import Foundation + +/// Per-channel toggles for a notification event. The backend exposes only the +/// channels that actually exist for a given event (GAP §B3): `push` and `inApp` +/// (there is no `email` channel). Render UI rows from the keys that are present +/// rather than assuming a fixed grid. +struct NotificationChannels: Codable, Equatable { + var push: Bool? + var inApp: Bool? +} + +/// One notification event the server can emit, with its supported channels. +/// Authoritative catalog today: `dig`, `push`, `follow` (follow is push-only). +struct NotificationPreference: Identifiable, Codable, Equatable { + let key: String + let label: String + let description: String? + var channels: NotificationChannels + + var id: String { key } + + var supportsPush: Bool { channels.push != nil } + var supportsInApp: Bool { channels.inApp != nil } +} + +struct NotificationPreferencesResponse: Codable { + let events: [NotificationPreference] +} + +/// PATCH body — updates the channel toggles for a single event. +struct NotificationPreferenceUpdate: Encodable { + let key: String + let channels: NotificationChannels +} diff --git a/InterlinedList/Models/Organization.swift b/InterlinedList/Models/Organization.swift new file mode 100644 index 0000000..40ee9b4 --- /dev/null +++ b/InterlinedList/Models/Organization.swift @@ -0,0 +1,92 @@ +// +// Organization.swift +// InterlinedList +// + +import Foundation + +/// Organization membership roles, in ascending privilege order. +enum OrgRole: String, Codable, CaseIterable, Comparable { + case member + case admin + case owner + + var label: String { + switch self { + case .member: return "Member" + case .admin: return "Admin" + case .owner: return "Owner" + } + } + + private var rank: Int { + switch self { + case .member: return 0 + case .admin: return 1 + case .owner: return 2 + } + } + + static func < (lhs: OrgRole, rhs: OrgRole) -> Bool { lhs.rank < rhs.rank } +} + +struct Organization: Identifiable, Codable { + let id: String + let name: String + let description: String? + let isPublic: Bool? + let avatar: String? + let memberCount: Int? + /// The current user's role within this org, when known ("owner"/"admin"/"member"). + let userRole: String? + let slug: String? + let createdAt: String? + + var role: OrgRole? { userRole.flatMap { OrgRole(rawValue: $0) } } + + init(id: String, name: String, description: String? = nil, isPublic: Bool? = nil, + avatar: String? = nil, memberCount: Int? = nil, userRole: String? = nil, + slug: String? = nil, createdAt: String? = nil) { + self.id = id + self.name = name + self.description = description + self.isPublic = isPublic + self.avatar = avatar + self.memberCount = memberCount + self.userRole = userRole + self.slug = slug + self.createdAt = createdAt + } +} + +struct OrganizationMember: Identifiable, Codable { + let id: String + let username: String + let displayName: String? + let avatar: String? + let emailVerified: Bool? + let role: String + let active: Bool? + let joinedAt: String? + + var orgRole: OrgRole? { OrgRole(rawValue: role) } + var displayNameOrUsername: String { + displayName?.isEmpty == false ? (displayName ?? username) : username + } +} + +// MARK: - API response wrappers + +struct OrganizationsResponse: Decodable { + let organizations: [Organization] + let pagination: Pagination? +} + +struct OrganizationResponse: Decodable { + let organization: Organization +} + +struct OrganizationMembersResponse: Decodable { + let members: [OrganizationMember] + let pagination: Pagination? +} diff --git a/InterlinedList/Models/PublicBrowse.swift b/InterlinedList/Models/PublicBrowse.swift new file mode 100644 index 0000000..055c391 --- /dev/null +++ b/InterlinedList/Models/PublicBrowse.swift @@ -0,0 +1,109 @@ +// +// PublicBrowse.swift +// InterlinedList +// + +import Foundation + +/// A lightweight reference to a public list (child or ancestor in breadcrumbs). +struct PublicListSummary: Identifiable, Decodable { + let id: String + let title: String? + + enum CodingKeys: String, CodingKey { case id, title, name } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + title = (try? c.decode(String.self, forKey: .title)) ?? (try? c.decode(String.self, forKey: .name)) + } +} + +struct PublicListOwner: Decodable { + let username: String? + let displayName: String? +} + +/// Public list metadata. Tolerates both the flat docs shape +/// (`{ id, title, schema, owner, ... }`) and the wrapped OpenAPI shape +/// (`{ list: { id, title, children }, ancestors: [...] }`). +struct PublicListDetail: Decodable { + let id: String + let title: String + let description: String? + let isPublic: Bool? + /// Schema as a DSL string ("Title:text, Author:text"), when provided. + let schema: String? + let owner: PublicListOwner? + let properties: [ListPropertyDef]? + let children: [PublicListSummary]? + let ancestors: [PublicListSummary]? + + private enum RootKeys: String, CodingKey { case list, ancestors } + private enum FieldKeys: String, CodingKey { + case id, title, name, description, isPublic, schema, owner, properties, children + } + + init(from decoder: Decoder) throws { + let root = try decoder.container(keyedBy: RootKeys.self) + // Prefer the nested `list` object when present; otherwise read from the root. + let source: KeyedDecodingContainer + if root.contains(.list) { + source = try root.nestedContainer(keyedBy: FieldKeys.self, forKey: .list) + ancestors = try? root.decode([PublicListSummary].self, forKey: .ancestors) + } else { + source = try decoder.container(keyedBy: FieldKeys.self) + ancestors = nil + } + id = try source.decode(String.self, forKey: .id) + title = (try? source.decode(String.self, forKey: .title)) + ?? (try? source.decode(String.self, forKey: .name)) ?? "Untitled" + description = try? source.decode(String.self, forKey: .description) + isPublic = try? source.decode(Bool.self, forKey: .isPublic) + schema = try? source.decode(String.self, forKey: .schema) + owner = try? source.decode(PublicListOwner.self, forKey: .owner) + properties = try? source.decode([ListPropertyDef].self, forKey: .properties) + children = try? source.decode([PublicListSummary].self, forKey: .children) + } +} + +/// Public list data rows + optional schema, with pagination. Tolerates `rows` +/// or `items` for the row array and an optional top-level `properties` block. +struct PublicListData: Decodable { + let rows: [ListItem] + let properties: [ListPropertyDef]? + let pagination: Pagination? + + enum CodingKeys: String, CodingKey { case rows, items, properties, pagination } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let rowsByKey = try? c.decode([ListItem].self, forKey: .rows) + let itemsByKey = try? c.decode([ListItem].self, forKey: .items) + rows = rowsByKey ?? itemsByKey ?? [] + properties = try? c.decode([ListPropertyDef].self, forKey: .properties) + pagination = try? c.decode(Pagination.self, forKey: .pagination) + } +} + +// MARK: - Public documents + +struct PublicDocumentSummary: Identifiable, Decodable { + let id: String + let title: String + let folderId: String? + let relativePath: String? + let createdAt: String? + let updatedAt: String? +} + +struct PublicDocumentFolder: Identifiable, Decodable { + let id: String + let name: String + let parentId: String? +} + +struct PublicDocumentsResponse: Decodable { + let documents: [PublicDocumentSummary] + let folders: [PublicDocumentFolder] +} diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index ad6c993..d930e83 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -15,6 +15,9 @@ enum APIError: Error { case server(String) case status(Int) case network(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) } enum ExportType: String, CaseIterable { @@ -93,6 +96,143 @@ final class APIClient { let _: Response = try await post("/api/auth/register", body: Body(email: email, username: username, password: password, displayName: displayName), authenticated: false) } + // MARK: - Password reset + + func forgotPassword(email: String) async throws { + struct Body: Encodable { let email: String } + struct Response: Decodable { let message: String? } + let _: Response = try await post("/api/auth/forgot-password", body: Body(email: email), authenticated: false) + } + + func resetPassword(token: String, password: String) async throws { + struct Body: Encodable { let token: String; let password: String } + struct Response: Decodable { let message: String? } + let _: Response = try await post("/api/auth/reset-password", body: Body(token: token, password: password), authenticated: false) + } + + // MARK: - Email verification + + func sendVerificationEmail() async throws { + struct Empty: Encodable {} + struct Response: Decodable { let message: String? } + let _: Response = try await post("/api/auth/send-verification-email", body: Empty()) + } + + func verifyEmail(token: String) async throws { + struct Body: Encodable { let token: String } + struct Response: Decodable { let message: String? } + let _: Response = try await post("/api/auth/verify-email", body: Body(token: token), authenticated: false) + } + + func verifyEmailChange(token: String) async throws { + struct Body: Encodable { let token: String } + struct Response: Decodable { let message: String? } + let _: Response = try await post("/api/auth/verify-email-change", body: Body(token: token), authenticated: false) + } + + // MARK: - Email change + + func requestEmailChange(newEmail: String, password: String) async throws { + struct Body: Encodable { let newEmail: String; let password: String } + struct Response: Decodable { let message: String? } + let _: Response = try await postCamel("/api/user/change-email/request", body: Body(newEmail: newEmail, password: password)) + } + + // MARK: - Linked identities + + struct LinkedIdentity: Identifiable, Codable { + let id: String + let provider: String + let providerUsername: String? + let createdAt: String? + } + + func linkedIdentities() async throws -> [LinkedIdentity] { + struct Response: Decodable { let identities: [LinkedIdentity]? } + let response: Response = try await get("/api/user/identities") + return response.identities ?? [] + } + + func unlinkIdentity(provider: String, providerId: String) async throws { + struct Body: Encodable { let provider: String; let providerId: String } + guard let url = URL(string: baseURL + "/api/user/identities") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + 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(provider: provider, providerId: providerId)) + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + + func verifyIdentity(provider: String, providerId: String) async throws { + struct Body: Encodable { let provider: String; let providerId: String } + struct Response: Decodable { let ok: Bool? } + let _: Response = try await postCamel("/api/user/identities/verify", body: Body(provider: provider, providerId: providerId)) + } + + // MARK: - OAuth configuration status + + struct OAuthConfigStatus: Decodable { + let configured: Bool + let redirectUri: String? + } + + func linkedinStatus() async throws -> OAuthConfigStatus { + return try await get("/api/auth/linkedin/status") + } + + func twitterStatus() async throws -> OAuthConfigStatus { + return try await get("/api/auth/twitter/status") + } + + // MARK: - Avatar upload (Phase 3 — sister agent dependency) + + func uploadAvatar(data: Data, mimeType: String) async throws -> User { + guard let url = URL(string: baseURL + "/api/user/avatar/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=\"avatar.\(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) + // Endpoint returns { url } only; refresh the user object to satisfy the signature. + return try await currentUser() + } + + func setAvatarFromURL(_ url: String) async throws -> User { + struct Body: Encodable { let url: String } + struct Response: Decodable { let url: String? } + let _: Response = try await post("/api/user/avatar/from-url", body: Body(url: url)) + return try await currentUser() + } + + // MARK: - Organizations (Phase 3 — sister agent dependency) + + func userOrganizations() async throws -> [Organization] { + struct Response: Decodable { let organizations: [Organization]? } + let response: Response = try await get("/api/user/organizations") + return response.organizations ?? [] + } + + // MARK: - Delete account (Phase 3 — sister agent dependency) + + func deleteAccount() async throws { + struct Empty: Encodable {} + struct Response: Decodable { let message: String? } + let _: Response = try await post("/api/user/delete", body: Empty()) + } + // MARK: - Messages func messages(limit: Int = 50, offset: Int = 0, onlyMine: Bool = false, tag: String? = nil) async throws -> (messages: [Message], pagination: Pagination?) { @@ -112,11 +252,38 @@ final class APIClient { return (response.messages, response.pagination) } - func postMessage(content: String, publiclyVisible: Bool? = nil, parentId: String? = nil, tags: [String]? = nil, scheduledAt: String? = nil, imageUrls: [String]? = nil, videoUrls: [String]? = nil) async throws -> Message { - struct Response: Decodable { - let data: Message? - } - let body = CreateMessageBody(content: content, publiclyVisible: publiclyVisible, parentId: parentId, tags: tags, scheduledAt: scheduledAt, imageUrls: imageUrls, videoUrls: videoUrls) + /// 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). + struct PostMessageResult { + let message: Message + let crossPostResults: [CrossPostResult] + } + + @discardableResult + func postMessage( + content: String, + publiclyVisible: Bool? = nil, + parentId: String? = nil, + tags: [String]? = nil, + scheduledAt: String? = nil, + imageUrls: [String]? = nil, + videoUrls: [String]? = nil, + pushedMessageId: String? = nil, + mastodonProviderIds: [String]? = nil, + crossPostToBluesky: Bool? = nil, + crossPostToLinkedIn: Bool? = nil, + linkedInTargets: [LinkedInTarget]? = nil, + linkedInLinkAsFirstComment: Bool? = nil, + crossPostToTwitter: Bool? = nil + ) async throws -> PostMessageResult { + let body = CreateMessageBody( + content: content, publiclyVisible: publiclyVisible, parentId: parentId, + tags: tags, scheduledAt: scheduledAt, imageUrls: imageUrls, videoUrls: videoUrls, + pushedMessageId: pushedMessageId, mastodonProviderIds: mastodonProviderIds, + crossPostToBluesky: crossPostToBluesky, crossPostToLinkedIn: crossPostToLinkedIn, + linkedInTargets: linkedInTargets, linkedInLinkAsFirstComment: linkedInLinkAsFirstComment, + crossPostToTwitter: crossPostToTwitter, scheduledCrossPostConfig: nil) // Backend expects camelCase (publiclyVisible, parentId); snake_case would send publicly_visible and be ignored. guard let url = URL(string: baseURL + "/api/messages") else { throw APIError.invalidURL } var request = URLRequest(url: url) @@ -129,9 +296,43 @@ final class APIClient { request.httpBody = try camelCaseEncoder.encode(body) let (data, response) = try await session.data(for: request) try checkResponse(data: data, response: response) - let decoded: Response = try decoder.decode(Response.self, from: data) + let decoded: CreateMessageResponse = try decoder.decode(CreateMessageResponse.self, from: data) guard let message = decoded.data else { throw APIError.noData } - return message + return PostMessageResult(message: message, crossPostResults: decoded.crossPostResults ?? []) + } + + /// Edit a scheduled (not-yet-published) message's send time and cross-post config. + @discardableResult + func patchScheduledMessage(id: String, scheduledAt: String, config: ScheduledCrossPostConfig?) async throws -> Message? { + struct Body: Encodable { + let scheduledAt: String + let scheduledCrossPostConfig: ScheduledCrossPostConfig? + } + struct Response: Decodable { let data: Message? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + guard let url = URL(string: baseURL + "/api/messages/\(encoded)") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + 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(scheduledAt: scheduledAt, scheduledCrossPostConfig: config)) + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + return (try? decoder.decode(Response.self, from: data))?.data + } + + /// Fetch/refresh OpenGraph link-preview metadata for a message's links. + @discardableResult + func refreshMessageMetadata(messageId: String) async throws -> [MessageLinkPreview] { + struct Response: Decodable { + struct Meta: Decodable { let links: [MessageLinkPreview]? } + let metadata: Meta? + } + let encoded = messageId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? messageId + struct Empty: Encodable {} + let response: Response = try await postCamel("/api/messages/\(encoded)/metadata", body: Empty()) + return response.metadata?.links ?? [] } func editMessage(id: String, content: String, publiclyVisible: Bool?) async throws -> Message { @@ -261,28 +462,49 @@ final class APIClient { // MARK: - Documents func documents(folderId: String? = nil) async throws -> [Document] { - var path = "/api/documents" + // `GET /api/documents` returns ONLY root-level documents (folderId is null) and + // ignores any query string — passing `?folderId=` made every folder show the root + // documents. Documents inside a folder must come from the folder-scoped endpoint. + let path: String if let folderId, !folderId.isEmpty, - let encoded = folderId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - path += "?folderId=\(encoded)" + let encoded = folderId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { + path = "/api/documents/folders/\(encoded)/documents" + } else { + path = "/api/documents" } let response: DocumentsResponse = try await get(path) return response.documents } func createDocument(title: String, content: String?, isPublic: Bool, folderId: String?) async throws -> Document { - struct Body: Encodable { let title: String; let content: String?; let isPublic: Bool; let folderId: String? } + // 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 + // that route silently lands at root. Post to the folder-scoped endpoint instead. + // Bodies are camelCase (`isPublic`) — use postCamel or the flag is dropped server-side. + struct Body: Encodable { let title: String; let content: String?; let isPublic: Bool } struct Response: Decodable { let document: Document? } - let response: Response = try await post("/api/documents", body: Body(title: title, content: content, isPublic: isPublic, folderId: folderId)) + let body = Body(title: title, content: content, isPublic: isPublic) + let path: String + if let folderId, !folderId.isEmpty, + let encoded = folderId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) { + path = "/api/documents/folders/\(encoded)/documents" + } else { + path = "/api/documents" + } + let response: Response = try await postCamel(path, body: body) guard let doc = response.document else { throw APIError.noData } return doc } func updateDocument(id: String, title: String, content: String?, isPublic: Bool, folderId: String? = nil) async throws -> Document { + // PATCH is the only documents write that accepts `folderId` (to move between folders). + // The body is camelCase (`folderId`, `isPublic`); patchCamel keeps it that way so the + // server actually applies the move and visibility change. (Sending `folderId: nil` + // omits the key, so this can move a doc *into* a folder but not back out to root.) struct Body: Encodable { let title: String; let content: String?; let isPublic: Bool; let folderId: String? } struct Response: Decodable { let document: Document? } let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - let response: Response = try await patch("/api/documents/\(encoded)", body: Body(title: title, content: content, isPublic: isPublic, folderId: folderId)) + let response: Response = try await patchCamel("/api/documents/\(encoded)", body: Body(title: title, content: content, isPublic: isPublic, folderId: folderId)) guard let doc = response.document else { throw APIError.noData } return doc } @@ -304,9 +526,11 @@ final class APIClient { } func createDocumentFolder(name: String, parentId: String?) async throws -> DocumentFolder { + // Body is camelCase (`parentId`); postCamel keeps a nested folder under its parent + // instead of dropping `parent_id` and creating it at root. struct Body: Encodable { let name: String; let parentId: String? } struct Response: Decodable { let folder: DocumentFolder? } - let response: Response = try await post("/api/documents/folders", body: Body(name: name, parentId: parentId)) + let response: Response = try await postCamel("/api/documents/folders", body: Body(name: name, parentId: parentId)) guard let folder = response.folder else { throw APIError.noData } return folder } @@ -510,6 +734,21 @@ final class APIClient { return try await currentUser() } + /// Update user preferences (theme, default visibility, advanced-post toggle). + /// Returns the refreshed user. + func updateUserSettings(theme: String? = nil, defaultVisibility: Bool? = nil, showAdvancedPostSettings: Bool? = nil) async throws -> User { + struct Body: Encodable { + let theme: String? + let defaultVisibility: Bool? + let showAdvancedPostSettings: Bool? + } + 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) + if let user = wrapped.user { return user } + return try await currentUser() + } + func deleteMessage(id: String) async throws { var request = URLRequest(url: URL(string: baseURL + "/api/messages/" + id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!)!) request.httpMethod = "DELETE" @@ -563,6 +802,243 @@ final class APIClient { try checkResponse(data: data, response: response) } + // MARK: - List schema (structured) + + /// Persist a structured schema update. Properties with an `id` are updated in + /// place (row data preserved); those without are created; any existing property + /// omitted from `properties` is soft-deleted. `force` allows dropping a column + /// that still has row data (otherwise the server returns 409). + @discardableResult + func updateListSchemaStructured(listId: String, properties: [SchemaPropertyInput], force: Bool = false) async throws -> [ListPropertyDef] { + let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + var path = "/api/lists/\(encoded)/schema" + if force { path += "?force=true" } + guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + 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(StructuredSchemaBody(properties: properties)) + let (data, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse, http.statusCode == 409 { + let msg = (try? decoder.decode(ErrorResponse.self, from: data))?.error + ?? "This property still contains data." + throw APIError.conflict(msg) + } + try checkResponse(data: data, response: response) + return (try? decoder.decode(SchemaUpdateResponse.self, from: data))?.properties ?? [] + } + + // MARK: - Follow surface (Phase 5) + + func followers(userId: String, limit: Int = 30, offset: Int = 0) async throws -> (users: [FollowUser], pagination: Pagination?) { + let encoded = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + let response: FollowersResponse = try await get("/api/follow/\(encoded)/followers?limit=\(limit)&offset=\(offset)") + return (response.followers, response.pagination) + } + + func following(userId: String, limit: Int = 30, offset: Int = 0) async throws -> (users: [FollowUser], pagination: Pagination?) { + let encoded = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + let response: FollowingResponse = try await get("/api/follow/\(encoded)/following?limit=\(limit)&offset=\(offset)") + return (response.following, response.pagination) + } + + func mutualCounts(userId: String) async throws -> MutualCounts { + let encoded = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + return try await get("/api/follow/\(encoded)/mutual") + } + + func removeFollower(userId: String) async throws { + let encoded = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + guard let url = URL(string: baseURL + "/api/follow/\(encoded)/remove") 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: - List watchers (Phase 6) + + func listWatchers(listId: String) async throws -> [ListWatcher] { + let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + let response: WatchersResponse = try await get("/api/lists/\(encoded)/watchers") + return response.watchers + } + + func isWatchingList(listId: String) async throws -> Bool { + let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + let response: WatchingResponse = try await get("/api/lists/\(encoded)/watchers/me") + return response.watching + } + + func searchWatcherCandidates(listId: String, limit: Int = 20, offset: Int = 0) async throws -> [WatcherCandidate] { + let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + let response: WatcherCandidatesResponse = try await get("/api/lists/\(encoded)/watchers/users?limit=\(limit)&offset=\(offset)") + return response.users + } + + @discardableResult + func addWatcher(listId: String, userId: String, role: WatcherRole) async throws -> Bool { + struct Body: Encodable { let userId: String; let role: String } + struct Response: Decodable { let watching: Bool? } + let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + let response: Response = try await postCamel("/api/lists/\(encoded)/watchers", body: Body(userId: userId, role: role.rawValue)) + return response.watching ?? true + } + + @discardableResult + func setWatcherRole(listId: String, userId: String, role: WatcherRole) async throws -> String { + struct Body: Encodable { let role: String } + struct Response: Decodable { let role: String? } + let encodedList = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + let response: Response = try await putCamel("/api/lists/\(encodedList)/watchers/\(encodedUser)", body: Body(role: role.rawValue)) + return response.role ?? role.rawValue + } + + func removeWatcher(listId: String, userId: String) async throws { + let encodedList = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + guard let url = URL(string: baseURL + "/api/lists/\(encodedList)/watchers/\(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) + } + + // MARK: - Public browse (Phase 7) + + func publicListDetail(username: String, listId: String) async throws -> PublicListDetail { + let u = username.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? username + let l = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + return try await get("/api/users/\(u)/lists/\(l)") + } + + func publicListData(username: String, listId: String, limit: Int = 50, offset: Int = 0) async throws -> PublicListData { + let u = username.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? username + let l = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId + return try await get("/api/users/\(u)/lists/\(l)/data?limit=\(limit)&offset=\(offset)") + } + + func publicDocuments(username: String) async throws -> PublicDocumentsResponse { + let u = username.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? username + return try await get("/api/users/\(u)/documents") + } + + func publicDocument(id: String) async throws -> Document { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + struct Response: Decodable { let document: Document? } + // The endpoint may wrap the document or return it bare; tolerate both. + let data = try await getRawData("/api/documents/\(encoded)") + if let wrapped = try? decoder.decode(Response.self, from: data), let doc = wrapped.document { + return doc + } + return try decoder.decode(Document.self, from: data) + } + + // MARK: - Organizations (Phase 8) + + func organizations(limit: Int = 30, offset: Int = 0) async throws -> (orgs: [Organization], pagination: Pagination?) { + let response: OrganizationsResponse = try await get("/api/organizations?limit=\(limit)&offset=\(offset)") + return (response.organizations, response.pagination) + } + + func organization(id: String) async throws -> Organization { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response: OrganizationResponse = try await get("/api/organizations/\(encoded)") + return response.organization + } + + @discardableResult + func createOrganization(name: String, description: String?, isPublic: Bool) async throws -> Organization? { + struct Body: Encodable { let name: String; let description: String?; let isPublic: Bool } + struct Response: Decodable { let organization: Organization? } + let response: Response = try await postCamel("/api/organizations", body: Body(name: name, description: description, isPublic: isPublic)) + return response.organization + } + + func updateOrganization(id: String, name: String?, description: String?, isPublic: Bool?) async throws { + struct Body: Encodable { let name: String?; let description: String?; let isPublic: Bool? } + struct Response: Decodable { let ok: Bool? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let _: Response = try await putCamel("/api/organizations/\(encoded)", body: Body(name: name, description: description, isPublic: isPublic)) + } + + func deleteOrganization(id: String) async throws { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + guard let url = URL(string: baseURL + "/api/organizations/\(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 organizationMembers(id: String, limit: Int = 50, offset: Int = 0) async throws -> (members: [OrganizationMember], pagination: Pagination?) { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let response: OrganizationMembersResponse = try await get("/api/organizations/\(encoded)/members?limit=\(limit)&offset=\(offset)") + return (response.members, response.pagination) + } + + func addOrganizationMember(id: String, userId: String, role: OrgRole) async throws { + struct Body: Encodable { let userId: String; let role: String } + struct Response: Decodable { let ok: Bool? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let _: Response = try await postCamel("/api/organizations/\(encoded)/members", body: Body(userId: userId, role: role.rawValue)) + } + + func setOrganizationMemberRole(id: String, userId: String, role: OrgRole, active: Bool? = nil) async throws { + struct Body: Encodable { let role: String; let active: Bool? } + struct Response: Decodable { let ok: Bool? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + let _: Response = try await putCamel("/api/organizations/\(encoded)/members/\(encodedUser)", body: Body(role: role.rawValue, active: active)) + } + + func removeOrganizationMember(id: String, userId: String) async throws { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + guard let url = URL(string: baseURL + "/api/organizations/\(encoded)/members/\(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 joinOrganization(organizationId: String) async throws { + struct Body: Encodable { let organizationId: String } + struct Response: Decodable { let ok: Bool? } + let _: Response = try await postCamel("/api/user/organizations", body: Body(organizationId: organizationId)) + } + + // MARK: - Notification preferences (Phase 12 / B3) + + func notificationPreferences() async throws -> [NotificationPreference] { + let response: NotificationPreferencesResponse = try await get("/api/user/notification-preferences") + return response.events + } + + @discardableResult + func updateNotificationPreference(key: String, channels: NotificationChannels) async throws -> NotificationPreference { + return try await patchCamel("/api/user/notification-preferences", body: NotificationPreferenceUpdate(key: key, channels: channels)) + } + + // MARK: - Message search (Phase 13 / B2) + + func searchMessages(q: String, limit: Int = 20, offset: Int = 0) async throws -> (messages: [Message], pagination: Pagination?) { + let qEncoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q + let response: MessagesResponse = try await get("/api/messages/search?q=\(qEncoded)&limit=\(limit)&offset=\(offset)") + return (response.messages, response.pagination) + } + // MARK: - Private helpers private func getRawData(_ path: String) async throws -> Data { @@ -664,6 +1140,17 @@ final class APIClient { return try await perform(request) } + private func patchCamel(_ path: String, body: B) async throws -> T { + guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + 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) + return try await perform(request) + } + private func checkResponse(data: Data, response: URLResponse) throws { guard let http = response as? HTTPURLResponse else { return } if http.statusCode == 401 { diff --git a/InterlinedList/Services/AppDataStore.swift b/InterlinedList/Services/AppDataStore.swift index 69741de..25da726 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -92,9 +92,10 @@ final class AppDataStore: ObservableObject { documents = d saveToCache() } catch APIError.status(401) { - } catch APIError.status(403) { - documentsError = "Requires active subscription." } catch { + // GET /api/documents is not documented as subscriber-only, so a + // 403 here would be an unexpected case. Surface it as a generic + // load failure rather than subscription copy. if documents.isEmpty { documentsError = "Failed to load documents." } } } diff --git a/InterlinedList/Services/AuthState.swift b/InterlinedList/Services/AuthState.swift index 3bbc657..4d6df20 100644 --- a/InterlinedList/Services/AuthState.swift +++ b/InterlinedList/Services/AuthState.swift @@ -70,8 +70,28 @@ final class AuthState: ObservableObject { try await login(email: email, password: password) } + func completeOAuthLogin(token: String) async throws { + guard KeychainService.saveToken(token) else { + throw APIError.server("Failed to save session") + } + api.setBearerToken(token) + hasToken = true + let currentUser = try await api.currentUser() + user = currentUser + } + + func refreshUser() async { + do { + user = try await api.currentUser() + } catch APIError.status(401) { + logout() + } catch { + // Keep current user state; transient failure. + } + } + func logout() { - KeychainService.deleteToken() + _ = KeychainService.deleteToken() api.setBearerToken(nil) user = nil hasToken = false diff --git a/InterlinedList/Services/OAuthCoordinator.swift b/InterlinedList/Services/OAuthCoordinator.swift new file mode 100644 index 0000000..efeff9b --- /dev/null +++ b/InterlinedList/Services/OAuthCoordinator.swift @@ -0,0 +1,148 @@ +// +// OAuthCoordinator.swift +// InterlinedList +// + +import Foundation +import AuthenticationServices +import UIKit + +enum OAuthProvider: String, CaseIterable { + case github + case mastodon + case bluesky + case linkedin + case twitter + + var displayName: String { + switch self { + case .github: return "GitHub" + case .mastodon: return "Mastodon" + case .bluesky: return "Bluesky" + case .linkedin: return "LinkedIn" + case .twitter: return "Twitter" + } + } + + var systemImageName: String { + switch self { + case .github: return "chevron.left.forwardslash.chevron.right" + case .mastodon: return "bubble.left.and.bubble.right" + case .bluesky: return "cloud" + case .linkedin: return "briefcase" + case .twitter: return "bird" + } + } + + /// Whether this provider's OAuth callback supports the native custom-scheme + /// token handoff. GitHub's callback has no mobile branch — it sets a web + /// session cookie and redirects to /dashboard, so it never returns + /// `interlinedlist://oauth/callback?token=…` and can't complete inside + /// `ASWebAuthenticationSession`. Hidden until the backend adds that branch. + /// (Backend auth contract — Open Dependency #1.) + var supportsNativeAuth: Bool { self != .github } +} + +enum OAuthError: Error { + case cancelled + case missingToken + case providerError(String) + case noPresentationContext +} + +/// Wraps `ASWebAuthenticationSession` and exchanges the deep-link callback for a Bearer token. +/// +/// Flow: caller invokes `authenticate(provider:instance:link:)`, which launches the system +/// browser sheet pointing at `/api/auth//authorize?redirect_uri=interlinedlist://oauth/callback`. +/// The server redirects back to the custom scheme with `?token=il_tok_...`; ASWebAuthenticationSession +/// captures that and returns the URL. We parse the token and return it. +@MainActor +final class OAuthCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding { + static let shared = OAuthCoordinator() + + private let baseURL: String + private let callbackScheme = "interlinedlist" + private var activeSession: ASWebAuthenticationSession? + + init(baseURL: String? = nil) { + let defaultBase = "https://interlinedlist.com" + let plistOverride = (Bundle.main.infoDictionary?["ILAPIBaseURL"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolved = (plistOverride?.isEmpty == false ? plistOverride : nil) ?? baseURL ?? defaultBase + self.baseURL = resolved.hasSuffix("/") ? String(resolved.dropLast()) : resolved + super.init() + } + + func authenticate(provider: OAuthProvider, + instance: String? = nil, + link: Bool = false) async throws -> String { + guard let authURL = buildAuthorizeURL(provider: provider, instance: instance, link: link) else { + throw OAuthError.providerError("Could not construct authorize URL.") + } + let callbackURL = try await startSession(url: authURL) + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + let token = components.queryItems?.first(where: { $0.name == "token" })?.value, + !token.isEmpty else { + if let error = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "error" })?.value { + throw OAuthError.providerError(error) + } + throw OAuthError.missingToken + } + return token + } + + private func buildAuthorizeURL(provider: OAuthProvider, instance: String?, link: Bool) -> URL? { + var components = URLComponents(string: baseURL + "/api/auth/\(provider.rawValue)/authorize") + var items: [URLQueryItem] = [ + URLQueryItem(name: "redirect_uri", value: "\(callbackScheme)://oauth/callback"), + ] + if provider == .mastodon, let instance, !instance.isEmpty { + items.append(URLQueryItem(name: "instance", value: instance)) + } + if link { + items.append(URLQueryItem(name: "link", value: "true")) + } + components?.queryItems = items + return components?.url + } + + private func startSession(url: URL) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackScheme + ) { callbackURL, error in + if let error = error as? ASWebAuthenticationSessionError, error.code == .canceledLogin { + continuation.resume(throwing: OAuthError.cancelled) + return + } + if let error { + continuation.resume(throwing: error) + return + } + guard let callbackURL else { + continuation.resume(throwing: OAuthError.missingToken) + return + } + continuation.resume(returning: callbackURL) + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + activeSession = session + if !session.start() { + continuation.resume(throwing: OAuthError.noPresentationContext) + } + } + } + + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + // ASWebAuthenticationSession invokes this synchronously on the main thread, + // so assumeIsolated is safe and lets us call into UIKit's MainActor APIs. + MainActor.assumeIsolated { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first + let window = scene?.windows.first { $0.isKeyWindow } ?? scene?.windows.first + return window ?? ASPresentationAnchor() + } + } +} diff --git a/InterlinedList/Views/ChangeEmailView.swift b/InterlinedList/Views/ChangeEmailView.swift new file mode 100644 index 0000000..99a3955 --- /dev/null +++ b/InterlinedList/Views/ChangeEmailView.swift @@ -0,0 +1,104 @@ +// +// ChangeEmailView.swift +// InterlinedList +// + +import SwiftUI + +struct ChangeEmailView: View { + @EnvironmentObject var authState: AuthState + @Environment(\.dismiss) private var dismiss + @State private var newEmail = "" + @State private var password = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var didRequest = false + + var body: some View { + NavigationStack { + Form { + if let current = authState.user?.email { + Section("Current email") { + Text(current) + .foregroundStyle(.secondary) + } + } + Section("New email") { + TextField("New email", text: $newEmail) + .textContentType(.emailAddress) + .autocapitalization(.none) + .keyboardType(.emailAddress) + .disabled(didRequest) + } + Section("Confirm password") { + SecureField("Current password", text: $password) + .textContentType(.password) + .disabled(didRequest) + } + if let error = errorMessage { + Section { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + if didRequest { + Section { + Label("Check your new email for a verification link.", systemImage: "envelope.badge") + .foregroundStyle(.secondary) + } + } else { + Section { + Button { + Task { await submit() } + } label: { + HStack { + if isLoading { + ProgressView().frame(width: 20, height: 20) + } + Text("Request email change") + .frame(maxWidth: .infinity) + } + } + .disabled(isLoading || newEmail.isEmpty || password.isEmpty) + .accessibilityLabel("Request email change") + } + } + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Change email") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(didRequest ? "Done" : "Cancel") { dismiss() } + } + } + } + } + + private func submit() async { + errorMessage = nil + isLoading = true + defer { isLoading = false } + do { + try await APIClient.shared.requestEmailChange( + newEmail: newEmail.trimmingCharacters(in: .whitespacesAndNewlines), + password: password + ) + didRequest = true + } catch APIError.server(let message) { + errorMessage = message + } catch APIError.status(401) { + errorMessage = "Incorrect password." + } catch APIError.status(let code) { + errorMessage = "Request failed (HTTP \(code))." + } catch { + errorMessage = "Connection failed. Please try again." + } + } +} + +#Preview { + ChangeEmailView() + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index 36f8e29..ca14ed7 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -14,6 +14,8 @@ struct ComposeView: View { @Environment(\.dismiss) private var dismiss /// When set, this view posts a reply to the given message. var replyTo: Message? = nil + /// When set, this view reposts (pushes) the given message, with optional commentary. + var repostOf: Message? = nil @State private var content = "" @State private var tags = "" @State private var publiclyVisible = true @@ -29,8 +31,32 @@ struct ComposeView: View { @State private var isUploadingVideo = false @State private var scheduledDate: Date? @State private var showSchedulePicker = false + // Cross-posting (subscriber-only) + @State private var crossPostBluesky = false + @State private var crossPostLinkedIn = false + @State private var crossPostTwitter = false + @State private var mastodonIdentities: [APIClient.LinkedIdentity] = [] + @State private var selectedMastodonIds: Set = [] + @State private var identitiesLoaded = false + @State private var lastCrossPostResults: [CrossPostResult] = [] private var isReply: Bool { replyTo != nil } + private var isRepost: Bool { repostOf != nil } + + /// Subscriber-only compose features (image / video upload, cross-posting, + /// scheduling) are hidden entirely for non-subscribers per the iOS-free-app + /// direction. No paywall, no disabled-but-tappable controls — the UI + /// simply does not surface them. + private var canUseSubscriberFeatures: Bool { + authState.user?.isSubscriber == true + } + + /// Free users with unverified email cannot post (matches site behavior). + /// User is missing while view is initializing; treat that as verified so the + /// button isn't disabled in the brief window before authState lands. + private var isEmailVerified: Bool { + authState.user.map { $0.emailVerified != false } ?? true + } /// Apply user's default settings for public visibility and advanced bar. Call when view appears (new post) or after successful post. private func applyUserDefaults() { @@ -44,8 +70,13 @@ struct ComposeView: View { var body: some View { NavigationStack { Form { + if let original = repostOf { + Section { + repostPreview(original) + } + } Section { - TextField(isReply ? "Write a reply…" : "What's on your mind?", text: $content, axis: .vertical) + TextField(composePlaceholder, text: $content, axis: .vertical) .lineLimit(5...15) if !isReply { TextField("Tags (comma-separated)", text: $tags) @@ -56,7 +87,7 @@ struct ComposeView: View { advancedToolbar Toggle("Public", isOn: $publiclyVisible) } - if showSchedulePicker && !isReply { + if showSchedulePicker && !isReply && canUseSubscriberFeatures { schedulePicker } if let url = uploadedImageURL { @@ -67,6 +98,10 @@ struct ComposeView: View { } } + if showAdvancedBar && !isReply && canUseSubscriberFeatures { + crossPostSection + } + if let error = errorMessage { Section { Text(error) @@ -84,20 +119,29 @@ struct ComposeView: View { ProgressView() .frame(width: 20, height: 20) } - Text(scheduledDate != nil ? "Schedule" : (isReply ? "Reply" : "Post")) + Text(postButtonLabel) .frame(maxWidth: .infinity) } } - .disabled(isLoading || content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(isLoading || !canSubmit || !isEmailVerified) + } footer: { + if !isEmailVerified { + Text("Verify your email to enable posting.") + .font(.caption) + .foregroundStyle(.orange) + } } } .scrollDismissesKeyboard(.interactively) - .navigationTitle(isReply ? "Reply" : "New post") + .navigationTitle(navTitle) .navigationBarTitleDisplayMode(.inline) .onAppear { applyUserDefaults() } - .alert(isReply ? "Replied" : (scheduledDate != nil ? "Scheduled" : "Posted"), isPresented: $showSuccess) { + .task { + await loadMastodonIdentitiesIfNeeded() + } + .alert(successTitle, isPresented: $showSuccess) { Button("OK") { content = "" uploadedImageURL = nil @@ -106,10 +150,11 @@ struct ComposeView: View { selectedVideo = nil scheduledDate = nil showSchedulePicker = false + lastCrossPostResults = [] applyUserDefaults() } } message: { - Text(isReply ? "Your reply was posted." : (scheduledDate != nil ? "Your message has been scheduled." : "Your message was posted.")) + Text(successMessage) } .onChange(of: selectedPhoto) { _, newItem in guard let newItem else { return } @@ -118,24 +163,70 @@ struct ComposeView: View { } } + // MARK: - Derived strings + + private var composePlaceholder: String { + if isReply { return "Write a reply…" } + if isRepost { return "Add a comment (optional)…" } + return "What's on your mind?" + } + + private var navTitle: String { + if isReply { return "Reply" } + if isRepost { return "Repost" } + return "New post" + } + + private var postButtonLabel: String { + if isRepost { return "Repost" } + if scheduledDate != nil { return "Schedule" } + return isReply ? "Reply" : "Post" + } + + /// Reposts may have empty commentary; everything else requires content. + private var canSubmit: Bool { + isRepost || !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var successTitle: String { + if isReply { return "Replied" } + if isRepost { return "Reposted" } + return scheduledDate != nil ? "Scheduled" : "Posted" + } + + private var successMessage: String { + let base: String + if isReply { base = "Your reply was posted." } + else if isRepost { base = "You reposted this." } + else if scheduledDate != nil { base = "Your message has been scheduled." } + else { base = "Your message was posted." } + guard !lastCrossPostResults.isEmpty else { return base } + let summary = lastCrossPostResults.map { r in + "\(r.platform.capitalized) \(r.success ? "✓" : "✗")" + }.joined(separator: " · ") + return base + "\n" + summary + } + @ViewBuilder private var advancedToolbar: some View { HStack(alignment: .center, spacing: 8) { Text("\(remainingCharacters) characters remaining") .font(.caption) .foregroundStyle(.secondary) - Button { - withAnimation(.easeInOut(duration: 0.25)) { - showAdvancedBar.toggle() + if canUseSubscriberFeatures { + Button { + withAnimation(.easeInOut(duration: 0.25)) { + showAdvancedBar.toggle() + } + } label: { + Image(systemName: "gearshape.fill") + .font(.body) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(showAdvancedBar ? 90 : 0)) } - } label: { - Image(systemName: "gearshape.fill") - .font(.body) - .foregroundStyle(.secondary) - .rotationEffect(.degrees(showAdvancedBar ? 90 : 0)) + .buttonStyle(.borderless) } - .buttonStyle(.borderless) - if showAdvancedBar { + if showAdvancedBar && canUseSubscriberFeatures { HStack(spacing: 12) { PhotosPicker(selection: $selectedPhoto, matching: .images) { if isUploadingImage { @@ -167,30 +258,6 @@ struct ComposeView: View { guard let newItem else { return } Task { await uploadVideo(newItem) } } - Button { } label: { - Text("M") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(width: 22, height: 22) - } - .buttonStyle(.borderless) - .disabled(true) - Button { } label: { - Text("BS") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(width: 22, height: 22) - } - .buttonStyle(.borderless) - .disabled(true) - Button { } label: { - Text("in") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(width: 22, height: 22) - } - .buttonStyle(.borderless) - .disabled(true) Button { withAnimation { showSchedulePicker.toggle() @@ -285,6 +352,98 @@ struct ComposeView: View { } } + // MARK: - Cross-post controls + + private var hasMastodon: Bool { !mastodonIdentities.isEmpty } + + @ViewBuilder + private var crossPostSection: some View { + Section { + Toggle(isOn: $crossPostBluesky) { + Label("Bluesky", systemImage: "cloud") + } + Toggle(isOn: $crossPostLinkedIn) { + Label("LinkedIn", systemImage: "briefcase") + } + Toggle(isOn: $crossPostTwitter) { + Label("X", systemImage: "xmark") + } + if hasMastodon { + Menu { + ForEach(mastodonIdentities) { identity in + Button { + toggleMastodon(identity.id) + } label: { + if selectedMastodonIds.contains(identity.id) { + Label(mastodonLabel(identity), systemImage: "checkmark") + } else { + Text(mastodonLabel(identity)) + } + } + } + } label: { + HStack { + Label("Mastodon", systemImage: "number") + Spacer() + Text(selectedMastodonIds.isEmpty ? "Off" : "\(selectedMastodonIds.count) selected") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } header: { + Text("Cross-post") + } footer: { + Text("Cross-posts are sent when this message publishes.") + .font(.caption) + } + } + + private func mastodonLabel(_ identity: APIClient.LinkedIdentity) -> String { + identity.providerUsername ?? "Mastodon account" + } + + private func toggleMastodon(_ id: String) { + if selectedMastodonIds.contains(id) { + selectedMastodonIds.remove(id) + } else { + selectedMastodonIds.insert(id) + } + } + + private func loadMastodonIdentitiesIfNeeded() async { + guard !identitiesLoaded, canUseSubscriberFeatures, !isReply else { return } + identitiesLoaded = true + do { + let identities = try await APIClient.shared.linkedIdentities() + mastodonIdentities = identities.filter { $0.provider == "mastodon" } + } catch { + mastodonIdentities = [] + } + } + + @ViewBuilder + private func repostPreview(_ original: Message) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "arrow.2.squarepath") + .font(.caption2) + .foregroundStyle(.secondary) + Text(original.authorDisplay) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + } + Text(original.content) + .font(.subheadline) + .lineLimit(4) + .foregroundStyle(.primary) + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + .accessibilityLabel("Reposting \(original.authorDisplay): \(original.content)") + } + private func uploadVideo(_ item: PhotosPickerItem) async { isUploadingVideo = true errorMessage = nil @@ -293,10 +452,11 @@ struct ComposeView: View { guard let data = try await item.loadTransferable(type: Data.self) else { return } let mimeType = item.supportedContentTypes.first?.preferredMIMEType ?? "video/mp4" uploadedVideoURL = try await APIClient.shared.uploadVideo(data: data, mimeType: mimeType) - } catch APIError.status(403) { - errorMessage = "Video upload requires an active subscription." - selectedVideo = nil } catch { + // 403 falls through here. The video picker is hidden for free + // users so a subscriber-only response shouldn't normally reach + // this branch; no subscription copy surfaces either way per the + // iOS-free-app direction. errorMessage = "Failed to upload video. Please try again." selectedVideo = nil } @@ -310,10 +470,8 @@ struct ComposeView: View { guard let data = try await item.loadTransferable(type: Data.self) else { return } let mimeType = item.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg" uploadedImageURL = try await APIClient.shared.uploadImage(data: data, mimeType: mimeType) - } catch APIError.status(403) { - errorMessage = "Image upload requires an active subscription." - selectedPhoto = nil } catch { + // Picker is hidden for non-subscribers; 403 falls through here. errorMessage = "Failed to upload image. Please try again." selectedPhoto = nil } @@ -324,25 +482,34 @@ struct ComposeView: View { isLoading = true defer { isLoading = false } let text = content.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } + guard canSubmit else { return } let tagList = tags.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } let isoScheduled = scheduledDate.map { ISO8601DateFormatter().string(from: $0) } let urls = uploadedImageURL.map { [$0] } let videoUrls = uploadedVideoURL.map { [$0] } + // 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 do { - _ = try await APIClient.shared.postMessage( + let result = try await APIClient.shared.postMessage( content: text, publiclyVisible: publiclyVisible, parentId: replyTo?.id, tags: tagList.isEmpty ? nil : tagList, scheduledAt: isoScheduled, imageUrls: urls, - videoUrls: videoUrls + videoUrls: videoUrls, + pushedMessageId: repostOf?.id, + mastodonProviderIds: mastodonIds, + crossPostToBluesky: crossPostEnabled && crossPostBluesky ? true : nil, + crossPostToLinkedIn: crossPostEnabled && crossPostLinkedIn ? true : nil, + crossPostToTwitter: crossPostEnabled && crossPostTwitter ? true : nil ) + lastCrossPostResults = result.crossPostResults showSuccess = true - if isReply { + if isReply || isRepost { dismiss() } } catch APIError.status(401) { diff --git a/InterlinedList/Views/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index e54200c..d2ce0c3 100644 --- a/InterlinedList/Views/DocumentsView.swift +++ b/InterlinedList/Views/DocumentsView.swift @@ -18,7 +18,16 @@ struct DocumentsView: View { @State private var searchError: String? @State private var isLoadingMoreSearch = false - private var allFolders: [DocumentFolder] { store.documentFolders } + private var canCreateFolders: Bool { + authState.user?.isSubscriber == true + } + + // Folders are a subscriber-only feature. For free users we surface no + // folder UI and treat every document as root-level (regardless of its + // folderId on the server — the data is preserved, just not exposed). + private var allFolders: [DocumentFolder] { + canCreateFolders ? store.documentFolders : [] + } private var allDocuments: [Document] { store.documents } private var folderNameLookup: [String: String] { @@ -30,7 +39,9 @@ struct DocumentsView: View { } private var rootDocuments: [Document] { - allDocuments.filter { ($0.folderId ?? "").isEmpty } + canCreateFolders + ? allDocuments.filter { ($0.folderId ?? "").isEmpty } + : allDocuments } var body: some View { @@ -85,10 +96,12 @@ struct DocumentsView: View { } label: { Label("New Document", systemImage: "doc.badge.plus") } - Button { - showCreateFolder = true - } label: { - Label("New Folder", systemImage: "folder.badge.plus") + if canCreateFolders { + Button { + showCreateFolder = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } } } label: { Image(systemName: "plus") @@ -268,10 +281,12 @@ private struct DocumentFolderView: View { } label: { Label("New Document", systemImage: "doc.badge.plus") } - Button { - showCreateFolder = true - } label: { - Label("New Folder", systemImage: "folder.badge.plus") + if authState.user?.isSubscriber == true { + Button { + showCreateFolder = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } } } label: { Image(systemName: "plus") @@ -523,8 +538,6 @@ private struct CreateDocumentView: View { dismiss() } catch APIError.status(401) { authState.handleUnauthorized() - } catch APIError.status(403) { - errorMessage = "Requires active subscription." } catch APIError.server(let msg) { errorMessage = msg } catch { @@ -621,8 +634,6 @@ private struct EditDocumentView: View { dismiss() } catch APIError.status(401) { authState.handleUnauthorized() - } catch APIError.status(403) { - errorMessage = "Requires active subscription." } catch APIError.server(let msg) { errorMessage = msg } catch { diff --git a/InterlinedList/Views/EditMessageView.swift b/InterlinedList/Views/EditMessageView.swift index 2fc5e0f..99d7f61 100644 --- a/InterlinedList/Views/EditMessageView.swift +++ b/InterlinedList/Views/EditMessageView.swift @@ -12,14 +12,27 @@ struct EditMessageView: View { @Environment(\.dismiss) private var dismiss @State private var content: String @State private var publiclyVisible: Bool + @State private var scheduledDate: Date @State private var isLoading = false @State private var errorMessage: String? + private let originalScheduledDate: Date? + private var isScheduled: Bool { originalScheduledDate != nil } + init(message: Message, onSave: @escaping (Message) -> Void) { self.message = message self.onSave = onSave _content = State(initialValue: message.content) _publiclyVisible = State(initialValue: message.publiclyVisible ?? true) + let parsed = message.scheduledAt.flatMap { EditMessageView.parseISO($0) } + self.originalScheduledDate = parsed + _scheduledDate = State(initialValue: parsed ?? Date().addingTimeInterval(3600)) + } + + private static func parseISO(_ iso: String) -> Date? { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) } var body: some View { @@ -31,6 +44,17 @@ struct EditMessageView: View { Toggle("Public", isOn: $publiclyVisible) } + if isScheduled { + Section("Scheduled for") { + DatePicker( + "Send at", + selection: $scheduledDate, + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + } + } + if let error = errorMessage { Section { Text(error) @@ -72,8 +96,17 @@ struct EditMessageView: View { let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } do { - let updated = try await APIClient.shared.editMessage(id: message.id, content: trimmed, publiclyVisible: publiclyVisible) - onSave(updated) + // Reschedule first when the send time changed (scheduled, unpublished messages only). + if isScheduled, let original = originalScheduledDate, + abs(scheduledDate.timeIntervalSince(original)) > 1 { + let iso = ISO8601DateFormatter().string(from: scheduledDate) + _ = try await APIClient.shared.patchScheduledMessage(id: message.id, scheduledAt: iso, config: nil) + } + let contentChanged = trimmed != message.content || publiclyVisible != (message.publiclyVisible ?? true) + if contentChanged { + let updated = try await APIClient.shared.editMessage(id: message.id, content: trimmed, publiclyVisible: publiclyVisible) + onSave(updated) + } dismiss() } catch APIError.server(let msg) { errorMessage = msg diff --git a/InterlinedList/Views/EditProfileView.swift b/InterlinedList/Views/EditProfileView.swift index 13ebf1a..63d1199 100644 --- a/InterlinedList/Views/EditProfileView.swift +++ b/InterlinedList/Views/EditProfileView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import PhotosUI struct EditProfileView: View { @EnvironmentObject var authState: AuthState @@ -15,20 +16,69 @@ struct EditProfileView: View { @State private var isLoading = false @State private var errorMessage: String? + @State private var currentAvatarURL: String? + @State private var avatarActionSheetPresented = false + @State private var avatarURLEntryPresented = false + @State private var photosPickerPresented = false + @State private var selectedPhoto: PhotosPickerItem? + @State private var isAvatarUploading = false + @State private var avatarError: String? + + @State private var changeEmailPresented = false + @State private var deleteFirstAlertPresented = false + @State private var deleteConfirmAlertPresented = false + @State private var deleteConfirmationText = "" + @State private var deleteErrorAlertPresented = false + @State private var isDeletingAccount = false + init(user: User) { _displayName = State(initialValue: user.displayName ?? "") _bio = State(initialValue: user.bio ?? "") _defaultPublic = State(initialValue: user.defaultPubliclyVisible ?? true) + _currentAvatarURL = State(initialValue: user.avatar) } var body: some View { NavigationStack { Form { + Section { + avatarRow + } header: { + Text("Profile Picture") + } footer: { + if let avatarError { + Text(avatarError) + .foregroundStyle(.red) + } + } + Section("Identity") { TextField("Display name", text: $displayName) TextField("Bio", text: $bio, axis: .vertical) .lineLimit(3...6) } + Section("Account") { + Button { + changeEmailPresented = true + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Email") + .foregroundStyle(.primary) + Text(authState.user?.email ?? "") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Text("Change") + .font(.subheadline) + .foregroundStyle(Color.accentColor) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Change email address") + } Section("Defaults") { Toggle("Default post visibility: Public", isOn: $defaultPublic) } @@ -50,6 +100,23 @@ struct EditProfileView: View { } .disabled(isLoading) } + + Section { + Button(role: .destructive) { + deleteFirstAlertPresented = true + } label: { + HStack { + if isDeletingAccount { ProgressView().frame(width: 20, height: 20) } + Text("Delete Account").frame(maxWidth: .infinity) + } + } + .disabled(isDeletingAccount) + .accessibilityLabel("Delete account") + } header: { + Text("Danger Zone") + } footer: { + Text("Permanently removes your account and all associated data.") + } } .navigationTitle("Edit Profile") .navigationBarTitleDisplayMode(.inline) @@ -58,6 +125,126 @@ struct EditProfileView: View { Button("Cancel") { dismiss() } } } + .confirmationDialog("Change profile picture", isPresented: $avatarActionSheetPresented, titleVisibility: .visible) { + Button("Choose Photo") { + photosPickerPresented = true + } + Button("Use URL") { + avatarURLEntryPresented = true + } + Button("Cancel", role: .cancel) {} + } + .photosPicker(isPresented: $photosPickerPresented, selection: $selectedPhoto, matching: .images) + .sheet(isPresented: $avatarURLEntryPresented) { + AvatarURLEntryView { urlString in + Task { await setAvatarFromURL(urlString) } + } + } + .sheet(isPresented: $changeEmailPresented) { + ChangeEmailView() + .environmentObject(authState) + } + .onChange(of: selectedPhoto) { _, newItem in + guard let newItem else { return } + Task { await uploadAvatar(newItem) } + } + .alert("Delete your account?", isPresented: $deleteFirstAlertPresented) { + Button("Cancel", role: .cancel) {} + Button("Continue", role: .destructive) { + deleteConfirmationText = "" + deleteConfirmAlertPresented = true + } + } message: { + Text("This will permanently delete your account and all your data. This cannot be undone.") + } + .alert("Type DELETE to confirm", isPresented: $deleteConfirmAlertPresented) { + TextField("DELETE", text: $deleteConfirmationText) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + Button("Cancel", role: .cancel) { + deleteConfirmationText = "" + } + Button("Delete", role: .destructive) { + if deleteConfirmationText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == "DELETE" { + Task { await deleteAccount() } + } else { + deleteConfirmationText = "" + } + } + } message: { + Text("Enter DELETE in all caps to permanently remove your account.") + } + .alert("Couldn't delete account", isPresented: $deleteErrorAlertPresented) { + Button("OK", role: .cancel) {} + } message: { + Text("Couldn't delete account. Try again later.") + } + } + } + + @ViewBuilder + private var avatarRow: some View { + HStack(spacing: 16) { + avatarImage + .frame(width: 64, height: 64) + .clipShape(Circle()) + .overlay(Circle().strokeBorder(Color(.separator), lineWidth: 0.5)) + .accessibilityLabel("Profile picture") + .accessibilityHint("Change your profile picture") + + VStack(alignment: .leading, spacing: 4) { + Button { + avatarActionSheetPresented = true + } label: { + Text(isAvatarUploading ? "Uploading…" : "Change Photo") + } + .disabled(isAvatarUploading) + + if isAvatarUploading { + ProgressView() + .controlSize(.small) + } + } + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + if !isAvatarUploading { + avatarActionSheetPresented = true + } + } + } + + @ViewBuilder + private var avatarImage: some View { + if let urlString = currentAvatarURL, + let url = URL(string: urlString), + !urlString.isEmpty { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + case .failure: + placeholderAvatar + case .empty: + ProgressView() + @unknown default: + placeholderAvatar + } + } + } else { + placeholderAvatar + } + } + + private var placeholderAvatar: some View { + ZStack { + Color(.secondarySystemFill) + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundStyle(.secondary) + .padding(4) } } @@ -83,6 +270,95 @@ struct EditProfileView: View { errorMessage = "Could not save profile. Please try again." } } + + private func uploadAvatar(_ item: PhotosPickerItem) async { + isAvatarUploading = true + avatarError = nil + defer { + isAvatarUploading = false + selectedPhoto = nil + } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { + avatarError = "Couldn't upload avatar." + return + } + let mimeType = item.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg" + let updated = try await APIClient.shared.uploadAvatar(data: data, mimeType: mimeType) + authState.updateUser(updated) + currentAvatarURL = updated.avatar + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + avatarError = "Couldn't upload avatar." + } + } + + private func setAvatarFromURL(_ urlString: String) async { + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + isAvatarUploading = true + avatarError = nil + defer { isAvatarUploading = false } + do { + let updated = try await APIClient.shared.setAvatarFromURL(trimmed) + authState.updateUser(updated) + currentAvatarURL = updated.avatar + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + avatarError = "Couldn't upload avatar." + } + } + + private func deleteAccount() async { + isDeletingAccount = true + defer { isDeletingAccount = false } + do { + try await APIClient.shared.deleteAccount() + authState.logout() + } catch { + deleteErrorAlertPresented = true + } + } +} + +private struct AvatarURLEntryView: View { + @Environment(\.dismiss) private var dismiss + @State private var urlString: String = "" + let onSubmit: (String) -> Void + + var body: some View { + NavigationStack { + Form { + Section { + TextField("https://example.com/avatar.jpg", text: $urlString) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel("Avatar image URL") + } footer: { + Text("Paste a direct link to an image.") + } + } + .navigationTitle("Use Image URL") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onSubmit(trimmed) + dismiss() + } + .disabled(urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } } #Preview { diff --git a/InterlinedList/Views/EmailVerificationBanner.swift b/InterlinedList/Views/EmailVerificationBanner.swift new file mode 100644 index 0000000..7d34a66 --- /dev/null +++ b/InterlinedList/Views/EmailVerificationBanner.swift @@ -0,0 +1,80 @@ +// +// EmailVerificationBanner.swift +// InterlinedList +// + +import SwiftUI + +/// Inline banner shown beneath the top bar when the signed-in user's email is unverified. +/// Tapping "Resend" calls `POST /api/auth/send-verification-email`. Renders nothing when the +/// user is verified or the verification state is unknown (nil) to avoid nagging on transient loads. +struct EmailVerificationBanner: View { + @EnvironmentObject var authState: AuthState + @State private var isSending = false + @State private var didSend = false + @State private var errorMessage: String? + + private var isUnverified: Bool { + authState.user?.emailVerified == false + } + + var body: some View { + if isUnverified { + HStack(spacing: 10) { + Image(systemName: "envelope.badge.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(didSend ? "Verification email sent" : "Verify your email address") + .font(.subheadline.weight(.medium)) + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } else if didSend { + Text("Check your inbox for the confirmation link.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 8) + if !didSend { + Button { + Task { await resend() } + } label: { + if isSending { + ProgressView().frame(width: 18, height: 18) + } else { + Text("Resend").font(.subheadline.weight(.semibold)) + } + } + .buttonStyle(.borderless) + .disabled(isSending) + .accessibilityLabel("Resend verification email") + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12)) + } + } + + private func resend() async { + errorMessage = nil + isSending = true + defer { isSending = false } + do { + try await APIClient.shared.sendVerificationEmail() + didSend = true + } catch APIError.server(let message) { + errorMessage = message + } catch { + errorMessage = "Couldn't send the email. Please try again." + } + } +} + +#Preview { + EmailVerificationBanner() + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/FeedView.swift b/InterlinedList/Views/FeedView.swift index d23b19d..5c31d94 100644 --- a/InterlinedList/Views/FeedView.swift +++ b/InterlinedList/Views/FeedView.swift @@ -20,10 +20,15 @@ struct FeedView: View { @State private var deleteError: String? @State private var showCompose = false @State private var messageToEdit: Message? + @State private var messageToRepost: Message? @State private var threadMessage: Message? @State private var digStates: [String: (count: Int, dugByMe: Bool)] = [:] @State private var profileUsername: String? = nil @State private var showScheduled = false + @State private var searchText = "" + @State private var searchResults: [Message] = [] + @State private var isSearching = false + @State private var searchPerformed = false private var distinctTags: [String] { var seen = Set() @@ -32,7 +37,9 @@ struct FeedView: View { @ViewBuilder private var feedContent: some View { - if isLoading && messages.isEmpty { + if !searchText.isEmpty { + searchResultsList + } else if isLoading && messages.isEmpty { FeedSkeletonView() } else if let error = errorMessage, messages.isEmpty { ContentUnavailableView { @@ -47,6 +54,32 @@ struct FeedView: View { } } + @ViewBuilder + private var searchResultsList: some View { + if isSearching { + ProgressView("Searching…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if searchResults.isEmpty && searchPerformed { + ContentUnavailableView.search(text: searchText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(searchResults) { message in + MessageRow( + message: message, + currentUserId: authState.user?.id, + showPreviews: showPreviews, + digState: digStates[message.id], + onReply: { threadMessage = message }, + onDelete: { messageToDelete = message }, + onEdit: { messageToEdit = message }, + onDig: { Task { await toggleDig(for: message) } }, + onRepost: { messageToRepost = message }, + onTapAuthor: { username in profileUsername = username } + ) + } + } + } + private var messageList: some View { List { Section { @@ -93,6 +126,7 @@ struct FeedView: View { }, onEdit: { messageToEdit = message }, onDig: { Task { await toggleDig(for: message) } }, + onRepost: { messageToRepost = message }, onTapAuthor: { username in profileUsername = username } ) } @@ -129,6 +163,10 @@ struct FeedView: View { if let username = profileUsername { UserProfileView(username: username) } } .sheet(isPresented: $showScheduled) { ScheduledMessagesView() } + .sheet(item: $messageToRepost) { message in + ComposeView(repostOf: message) + .environmentObject(authState) + } .sheet(item: $messageToEdit) { message in EditMessageView(message: message) { updated in if let index = messages.firstIndex(where: { $0.id == updated.id }) { @@ -156,6 +194,14 @@ struct FeedView: View { feedContent .navigationTitle("InterlinedList") .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Search posts") + .onSubmit(of: .search) { Task { await runSearch() } } + .onChange(of: searchText) { _, newValue in + if newValue.isEmpty { + searchResults = [] + searchPerformed = false + } + } .toolbar { feedToolbar } } .sheet(isPresented: $showCompose) { @@ -197,8 +243,12 @@ struct FeedView: View { } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 4) { - Button { showScheduled = true } label: { Image(systemName: "calendar") } - .accessibilityLabel("Scheduled posts") + // Scheduled posts are a subscriber-only feature; entry point hidden + // entirely for free users per the iOS-free-app direction. + if authState.user?.isSubscriber == true { + Button { showScheduled = true } label: { Image(systemName: "calendar") } + .accessibilityLabel("Scheduled posts") + } Button { showCompose = true } label: { Image(systemName: "square.and.pencil") } .accessibilityLabel("Compose") } @@ -249,6 +299,24 @@ struct FeedView: View { } } + private func runSearch() async { + let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !q.isEmpty else { return } + isSearching = true + defer { isSearching = false } + do { + let (results, _) = try await APIClient.shared.searchMessages(q: q) + searchResults = results + searchPerformed = true + initDigStates(from: results) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + searchResults = [] + searchPerformed = true + } + } + private func loadMessages() async { errorMessage = nil isLoading = true @@ -300,6 +368,7 @@ struct MessageRow: View { let onDelete: () -> Void let onEdit: () -> Void let onDig: () -> Void + var onRepost: (() -> Void)? = nil var onTapAuthor: ((String) -> Void)? = nil private var canDelete: Bool { @@ -387,6 +456,15 @@ struct MessageRow: View { .font(.caption) .foregroundStyle(.secondary) } + if let onRepost { + Button { + onRepost() + } label: { + Label("Repost", systemImage: "arrow.2.squarepath") + .font(.caption) + } + .buttonStyle(.borderless) + } if canDelete { Button { onEdit() diff --git a/InterlinedList/Views/FollowListView.swift b/InterlinedList/Views/FollowListView.swift new file mode 100644 index 0000000..ccac32d --- /dev/null +++ b/InterlinedList/Views/FollowListView.swift @@ -0,0 +1,205 @@ +// +// FollowListView.swift +// InterlinedList +// + +import SwiftUI + +/// A paginated list of a user's followers or accounts they follow. +/// On the current user's own followers list, each row can be removed. +struct FollowListView: View { + enum Mode { + case followers + case following + + var title: String { + switch self { + case .followers: return "Followers" + case .following: return "Following" + } + } + } + + let userId: String + let mode: Mode + /// When true and mode is `.followers`, rows expose a "Remove" action. + var isOwnProfile: Bool = false + + @EnvironmentObject private var authState: AuthState + @State private var users: [FollowUser] = [] + @State private var pagination: Pagination? + @State private var isLoading = false + @State private var error: String? + @State private var profileTarget: ProfileTarget? + + private let pageSize = 30 + + var body: some View { + Group { + if isLoading && users.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, users.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load(reset: true) } } + } + } else if users.isEmpty { + ContentUnavailableView { + Label(emptyTitle, systemImage: "person.2") + } description: { + Text(emptyMessage) + } + } else { + List { + ForEach(users) { user in + Button { + profileTarget = ProfileTarget(username: user.username) + } label: { + FollowUserRow(user: user) + } + .buttonStyle(.plain) + .swipeActions(edge: .trailing) { + if canRemove { + Button(role: .destructive) { + Task { await remove(user) } + } label: { + Label("Remove", systemImage: "person.fill.xmark") + } + } + } + } + if let pag = pagination, pag.hasMore, !isLoading { + HStack { Spacer(); ProgressView(); Spacer() } + .onAppear { Task { await load(reset: false) } } + } + } + .listStyle(.plain) + } + } + .navigationTitle(mode.title) + .navigationBarTitleDisplayMode(.inline) + .task { + if users.isEmpty { await load(reset: true) } + } + .sheet(item: $profileTarget) { target in + UserProfileView(username: target.username) + .environmentObject(authState) + } + } + + private var canRemove: Bool { isOwnProfile && mode == .followers } + + private var emptyTitle: String { + mode == .followers ? "No followers yet" : "Not following anyone" + } + + private var emptyMessage: String { + mode == .followers + ? "When people follow this account, they'll appear here." + : "Accounts this user follows will appear here." + } + + private func load(reset: Bool) async { + if reset { pagination = nil } + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + let offset = reset ? 0 : users.count + do { + let result: (users: [FollowUser], pagination: Pagination?) + switch mode { + case .followers: + result = try await APIClient.shared.followers(userId: userId, limit: pageSize, offset: offset) + case .following: + result = try await APIClient.shared.following(userId: userId, limit: pageSize, offset: offset) + } + if reset { + users = result.users + } else { + users.append(contentsOf: result.users) + } + pagination = result.pagination + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load \(mode.title.lowercased())." + } + } + + private func remove(_ user: FollowUser) async { + do { + try await APIClient.shared.removeFollower(userId: user.id) + users.removeAll { $0.id == user.id } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not remove follower." + } + } +} + +private struct FollowUserRow: View { + let user: FollowUser + + var body: some View { + HStack(spacing: 12) { + avatar + VStack(alignment: .leading, spacing: 2) { + Text(user.displayNameOrUsername) + .font(.body) + .foregroundStyle(.primary) + Text("@\(user.username)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if user.status == "pending" { + Text("Pending") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel(user.displayNameOrUsername) + } + + @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) + } + } +} + +/// Identifiable wrapper so a tapped username can drive a profile sheet. +struct ProfileTarget: Identifiable { + let id = UUID() + let username: String +} + +#Preview { + NavigationStack { + FollowListView(userId: "u1", mode: .followers, isOwnProfile: true) + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/ForgotPasswordView.swift b/InterlinedList/Views/ForgotPasswordView.swift new file mode 100644 index 0000000..d6d7dd1 --- /dev/null +++ b/InterlinedList/Views/ForgotPasswordView.swift @@ -0,0 +1,89 @@ +// +// ForgotPasswordView.swift +// InterlinedList +// + +import SwiftUI + +struct ForgotPasswordView: View { + @Environment(\.dismiss) private var dismiss + @State private var email = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var didRequest = false + + var body: some View { + NavigationStack { + Form { + Section { + Text("Enter your email and we'll send you a link to reset your password.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Section { + TextField("Email", text: $email) + .textContentType(.emailAddress) + .autocapitalization(.none) + .keyboardType(.emailAddress) + .disabled(didRequest) + } + if let error = errorMessage { + Section { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + if didRequest { + Section { + Label("If an account exists for that email, a reset link has been sent.", systemImage: "envelope.badge") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } else { + Section { + Button { + Task { await submit() } + } label: { + HStack { + if isLoading { + ProgressView().frame(width: 20, height: 20) + } + Text("Send reset link") + .frame(maxWidth: .infinity) + } + } + .disabled(isLoading || email.isEmpty) + .accessibilityLabel("Send password reset link") + } + } + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Forgot password") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(didRequest ? "Done" : "Cancel") { dismiss() } + } + } + } + } + + private func submit() async { + errorMessage = nil + isLoading = true + defer { isLoading = false } + do { + try await APIClient.shared.forgotPassword(email: email.trimmingCharacters(in: .whitespacesAndNewlines)) + didRequest = true + } catch APIError.server(let message) { + errorMessage = message + } catch { + errorMessage = "Could not send reset email. Please try again." + } + } +} + +#Preview { + ForgotPasswordView() +} diff --git a/InterlinedList/Views/LinkedIdentitiesView.swift b/InterlinedList/Views/LinkedIdentitiesView.swift new file mode 100644 index 0000000..066e819 --- /dev/null +++ b/InterlinedList/Views/LinkedIdentitiesView.swift @@ -0,0 +1,196 @@ +// +// LinkedIdentitiesView.swift +// InterlinedList +// + +import SwiftUI + +/// Lists the OAuth providers linked to the signed-in account and lets the user disconnect +/// them (`DELETE /api/user/identities`). In-app *linking* of a new provider is gated off +/// (see `linkingEnabled`). Reachable only for subscribers (gated by the caller in `MainTabView`). +struct LinkedIdentitiesView: View { + @EnvironmentObject var authState: AuthState + + /// In-app provider linking is disabled: the backend `?link=true` callback + /// authenticates via the web session cookie (`getCurrentUser()`), not the + /// Bearer token, so a native (Bearer-only) client can't link a new provider + /// through it. Flip to `true` once the backend exposes a Bearer-authenticated + /// link endpoint. (Backend auth contract — Open Dependency #2.) + private let linkingEnabled = false + + @State private var identities: [APIClient.LinkedIdentity] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var pendingUnlink: APIClient.LinkedIdentity? + @State private var linkInFlight = false + @State private var showMastodonPrompt = false + @State private var mastodonInstance = "" + + var body: some View { + List { + if let errorMessage { + Section { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + + Section { + if isLoading { + HStack { ProgressView(); Text("Loading…").foregroundStyle(.secondary) } + } else if identities.isEmpty { + Text("No connected accounts yet.") + .foregroundStyle(.secondary) + } else { + ForEach(identities) { identity in + identityRow(identity) + } + } + } header: { + Text("Connected accounts") + } + + if linkingEnabled { + Section { + Menu { + ForEach(OAuthProvider.allCases.filter(\.supportsNativeAuth), id: \.rawValue) { provider in + Button { + startLink(provider: provider) + } label: { + Label(provider.displayName, systemImage: provider.systemImageName) + } + } + } label: { + HStack { + Label("Link another provider", systemImage: "plus.circle") + Spacer() + if linkInFlight { ProgressView() } + } + } + .disabled(linkInFlight) + } + } else { + Section { + Text("To connect another account, sign in at interlinedlist.com. In-app linking will return once it's supported for app sign-ins.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Linked accounts") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + .refreshable { await load() } + .alert("Disconnect account?", isPresented: Binding( + get: { pendingUnlink != nil }, + set: { if !$0 { pendingUnlink = nil } } + ), presenting: pendingUnlink) { identity in + Button("Disconnect", role: .destructive) { + Task { await unlink(identity) } + } + Button("Cancel", role: .cancel) { pendingUnlink = nil } + } message: { identity in + Text("You'll no longer be able to sign in with \(displayName(for: identity.provider)).") + } + .alert("Mastodon instance", isPresented: $showMastodonPrompt) { + TextField("mastodon.social", text: $mastodonInstance) + .textInputAutocapitalization(.never) + Button("Continue") { + runLink(provider: .mastodon, instance: mastodonInstance) + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Enter your Mastodon server hostname.") + } + } + + @ViewBuilder + private func identityRow(_ identity: APIClient.LinkedIdentity) -> some View { + HStack(spacing: 12) { + Image(systemName: OAuthProvider(rawValue: identity.provider)?.systemImageName ?? "link") + .frame(width: 24) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(displayName(for: identity.provider)) + .font(.body) + if let username = identity.providerUsername, !username.isEmpty { + Text("@\(username)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Button("Disconnect", role: .destructive) { + pendingUnlink = identity + } + .buttonStyle(.borderless) + .font(.subheadline) + .accessibilityLabel("Disconnect \(displayName(for: identity.provider))") + } + } + + private func displayName(for provider: String) -> String { + OAuthProvider(rawValue: provider)?.displayName ?? provider.capitalized + } + + private func load() async { + errorMessage = nil + isLoading = true + defer { isLoading = false } + do { + identities = try await APIClient.shared.linkedIdentities() + } catch APIError.server(let message) { + errorMessage = message + } catch { + errorMessage = "Couldn't load connected accounts." + } + } + + private func unlink(_ identity: APIClient.LinkedIdentity) async { + errorMessage = nil + pendingUnlink = nil + do { + try await APIClient.shared.unlinkIdentity(provider: identity.provider, providerId: identity.id) + await load() + } catch APIError.server(let message) { + errorMessage = message + } catch { + errorMessage = "Couldn't disconnect \(displayName(for: identity.provider))." + } + } + + private func startLink(provider: OAuthProvider) { + if provider == .mastodon { + mastodonInstance = "" + showMastodonPrompt = true + return + } + runLink(provider: provider, instance: nil) + } + + private func runLink(provider: OAuthProvider, instance: String?) { + Task { + errorMessage = nil + linkInFlight = true + defer { linkInFlight = false } + do { + _ = try await OAuthCoordinator.shared.authenticate(provider: provider, instance: instance, link: true) + await load() + } catch OAuthError.cancelled { + // User backed out — nothing to surface. + } catch OAuthError.providerError(let message) { + errorMessage = message + } catch { + errorMessage = "Couldn't link \(provider.displayName). Please try again." + } + } + } +} + +#Preview { + NavigationStack { + LinkedIdentitiesView() + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/ListSchemaEditorView.swift b/InterlinedList/Views/ListSchemaEditorView.swift index fbdab3a..1fa0247 100644 --- a/InterlinedList/Views/ListSchemaEditorView.swift +++ b/InterlinedList/Views/ListSchemaEditorView.swift @@ -20,6 +20,8 @@ struct ListSchemaEditorView: View { @State private var isSaving = false @State private var errorMessage: String? @State private var showUnsavedConfirm = false + @State private var showForceDeleteConfirm = false + @State private var conflictMessage: String? private let originalTitle: String private let originalDescription: String @@ -181,6 +183,18 @@ struct ListSchemaEditorView: View { Button("Discard", role: .destructive) { dismiss() } Button("Keep Editing", role: .cancel) {} } + .confirmationDialog( + "Some columns still contain data", + isPresented: $showForceDeleteConfirm, + titleVisibility: .visible + ) { + Button("Delete anyway", role: .destructive) { + Task { await save(force: true) } + } + Button("Keep Editing", role: .cancel) {} + } message: { + Text(conflictMessage ?? "Deleting these properties will remove their values from every row.") + } .alert("Save Failed", isPresented: .constant(errorMessage != nil && !isSaving), actions: { Button("OK") { errorMessage = nil } }, message: { @@ -189,7 +203,7 @@ struct ListSchemaEditorView: View { } } - private func save() async { + private func save(force: Bool = false) async { guard isTitleValid else { return } isSaving = true errorMessage = nil @@ -202,15 +216,19 @@ struct ListSchemaEditorView: View { isPublic: isPublic ) if schemaChanged { - // PUT /api/lists/[id]/schema body format is inferred from the POST /api/lists - // example ("schema": "Name:type, ..."); response shape isn't documented, so - // updateListSchema tolerates missing/varying fields. Reload of schema is the - // caller's responsibility on next view appearance. - let dsl = ListSchemaDraft.serializeSchemaDSL(properties) - _ = try await APIClient.shared.updateListSchema(listId: list.id, schemaDSL: dsl) + // Structured form (GAP §B0): round-trips isVisible / isRequired / + // order. New rows are created, existing rows updated in place, and + // omitted properties soft-deleted. `force` confirms dropping a + // column that still has data (the server returns 409 otherwise). + let structured = ListSchemaDraft.structuredProperties(properties) + _ = try await APIClient.shared.updateListSchemaStructured( + listId: list.id, properties: structured, force: force) } onSave(updated) dismiss() + } catch APIError.conflict(let msg) { + conflictMessage = msg + showForceDeleteConfirm = true } catch APIError.status(401) { authState.handleUnauthorized() errorMessage = "Session expired. Please sign in again." diff --git a/InterlinedList/Views/ListsView.swift b/InterlinedList/Views/ListsView.swift index 2a7569b..12870a1 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -15,8 +15,16 @@ struct ListsView: View { @State private var searchResults: [UserList] = [] @State private var isSearching = false + private var canCreateFolders: Bool { + authState.user?.isSubscriber == true + } + private var treeNodes: [ListTreeNode] { - ListTreeNode.buildTree(folders: store.listFolders, lists: store.userLists) + // Folders are a subscriber-only feature. For free users we pass an empty + // folder array so any lists that were nested under folders (e.g. from when + // the user was a subscriber) surface at root via buildTree's orphan rule. + let visibleFolders = canCreateFolders ? store.listFolders : [] + return ListTreeNode.buildTree(folders: visibleFolders, lists: store.userLists) } var body: some View { @@ -81,10 +89,12 @@ struct ListsView: View { } label: { Label("New List", systemImage: "plus.rectangle") } - Button { - showCreateFolder = true - } label: { - Label("New Folder", systemImage: "folder.badge.plus") + if canCreateFolders { + Button { + showCreateFolder = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } } } label: { Image(systemName: "plus") @@ -211,17 +221,16 @@ private struct CreateListFolderView: View { ) onSave() dismiss() - } catch APIError.status(403) { - errorMessage = Self.paywallMessage } catch APIError.server(let msg) { errorMessage = msg } catch { + // 403 falls through here — the New Folder button is hidden for + // non-subscribers, so this catch should only trigger on transient + // errors. Per the iOS-free-app direction, no subscription copy is + // ever surfaced. errorMessage = "Failed to create folder." } } - - private static let paywallMessage = - "Creating folders requires a subscription. You can subscribe at interlinedlist.com." } // MARK: - Rename list sheet @@ -439,6 +448,7 @@ struct ListDetailView: View { @State private var editingItem: ListItem? = nil @State private var deletingItem: ListItem? = nil @State private var showDeleteConfirm = false + @State private var showWatchers = false var body: some View { Group { @@ -535,12 +545,26 @@ struct ListDetailView: View { } .navigationTitle(list.name) .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showWatchers = true + } label: { + Image(systemName: "person.2") + } + .accessibilityLabel("Manage watchers") + } + } .task { await loadData() } .refreshable { await loadData() } + .sheet(isPresented: $showWatchers) { + WatchersListView(listId: list.id) + .environmentObject(authState) + } .sheet(isPresented: $showAddConnection) { NavigationStack { List { diff --git a/InterlinedList/Views/LoginView.swift b/InterlinedList/Views/LoginView.swift index 3114bb2..5da469c 100644 --- a/InterlinedList/Views/LoginView.swift +++ b/InterlinedList/Views/LoginView.swift @@ -12,6 +12,12 @@ struct LoginView: View { @State private var errorMessage: String? @State private var isLoading = false @State private var showRegister = false + @State private var showForgotPassword = false + @State private var showMastodonPrompt = false + @State private var mastodonInstance = "" + @State private var oauthInFlight = false + @State private var linkedinVisible = false + @State private var twitterVisible = false var body: some View { NavigationStack { @@ -59,12 +65,27 @@ struct LoginView: View { } .disabled(isLoading || email.isEmpty || password.isEmpty) + Button("Forgot password?") { + showForgotPassword = true + } + .font(.subheadline) + .frame(maxWidth: .infinity) + .accessibilityLabel("Forgot password") + Button("Create account") { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) showRegister = true } .frame(maxWidth: .infinity) } + + Section("Or continue with") { + ForEach(visibleProviders, id: \.rawValue) { provider in + OAuthSignInButton(provider: provider, inFlight: oauthInFlight) { + handleOAuthTap(provider: provider) + } + } + } } .scrollDismissesKeyboard(.interactively) .navigationTitle("Login") @@ -73,6 +94,24 @@ struct LoginView: View { RegisterView() .environmentObject(authState) } + .sheet(isPresented: $showForgotPassword) { + ForgotPasswordView() + } + .alert("Mastodon instance", + isPresented: $showMastodonPrompt) { + TextField("mastodon.social", text: $mastodonInstance) + .autocapitalization(.none) + .disableAutocorrection(true) + Button("Continue") { + Task { await runOAuth(provider: .mastodon, instance: mastodonInstance) } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Enter your Mastodon server hostname.") + } + } + .task { + await refreshOAuthVisibility() } .onAppear { errorMessage = nil } .onChange(of: showRegister) { _, isShowing in @@ -82,6 +121,54 @@ struct LoginView: View { } } + private var visibleProviders: [OAuthProvider] { + OAuthProvider.allCases.filter { + guard $0.supportsNativeAuth else { return false } + switch $0 { + case .linkedin: return linkedinVisible + case .twitter: return twitterVisible + default: return true + } + } + } + + private func refreshOAuthVisibility() async { + async let li = APIClient.shared.linkedinStatus() + async let tw = APIClient.shared.twitterStatus() + if let liStatus = try? await li { linkedinVisible = liStatus.configured } else { linkedinVisible = false } + if let twStatus = try? await tw { twitterVisible = twStatus.configured } else { twitterVisible = false } + } + + private func handleOAuthTap(provider: OAuthProvider) { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + if provider == .mastodon { + mastodonInstance = "" + showMastodonPrompt = true + return + } + Task { await runOAuth(provider: provider, instance: nil) } + } + + private func runOAuth(provider: OAuthProvider, instance: String?) async { + errorMessage = nil + oauthInFlight = true + defer { oauthInFlight = false } + do { + let token = try await OAuthCoordinator.shared.authenticate( + provider: provider, + instance: instance, + link: false + ) + try await authState.completeOAuthLogin(token: token) + } catch OAuthError.cancelled { + // User cancelled — no surfaced error. + } catch OAuthError.providerError(let message) { + errorMessage = message + } catch { + errorMessage = "Sign-in with \(provider.displayName) failed. Please try again." + } + } + private func signIn() async { errorMessage = nil isLoading = true diff --git a/InterlinedList/Views/MainTabView.swift b/InterlinedList/Views/MainTabView.swift index 90dffd7..c05611e 100644 --- a/InterlinedList/Views/MainTabView.swift +++ b/InterlinedList/Views/MainTabView.swift @@ -22,6 +22,8 @@ struct MainTabView: View { var body: some View { VStack(spacing: 0) { topBar + EmailVerificationBanner() + .environmentObject(authState) sectionContent } .background(Color(.systemGroupedBackground)) @@ -153,6 +155,7 @@ struct MainTabView: View { private struct ProfileView: View { @EnvironmentObject var authState: AuthState @State private var showEditProfile = false + @State private var showSettings = false var body: some View { NavigationStack { @@ -163,9 +166,25 @@ private struct ProfileView: View { preferencesSection(user: user) } Section("Social") { + if let userId = authState.user?.id { + NavigationLink(destination: FollowListView(userId: userId, mode: .followers, isOwnProfile: true).environmentObject(authState)) { + Label("Followers", systemImage: "person.2") + } + NavigationLink(destination: FollowListView(userId: userId, mode: .following, isOwnProfile: true).environmentObject(authState)) { + Label("Following", systemImage: "person.2.fill") + } + } NavigationLink(destination: FollowRequestsView().environmentObject(authState)) { Label("Follow Requests", systemImage: "person.crop.circle.badge.plus") } + NavigationLink(destination: OrganizationsListView().environmentObject(authState)) { + Label("Organizations", systemImage: "building.2") + } + if authState.user?.isSubscriber == true { + NavigationLink(destination: LinkedIdentitiesView().environmentObject(authState)) { + Label("Linked accounts", systemImage: "link") + } + } } Section { Button(role: .destructive) { @@ -177,6 +196,14 @@ private struct ProfileView: View { } .navigationTitle("Profile") .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + } + .accessibilityLabel("Settings") + } ToolbarItem(placement: .topBarTrailing) { Button("Edit") { showEditProfile = true @@ -189,6 +216,10 @@ private struct ProfileView: View { .environmentObject(authState) } } + .sheet(isPresented: $showSettings) { + SettingsView() + .environmentObject(authState) + } } } diff --git a/InterlinedList/Views/OAuthSignInButton.swift b/InterlinedList/Views/OAuthSignInButton.swift new file mode 100644 index 0000000..e00f763 --- /dev/null +++ b/InterlinedList/Views/OAuthSignInButton.swift @@ -0,0 +1,42 @@ +// +// OAuthSignInButton.swift +// InterlinedList +// + +import SwiftUI + +/// A single provider row used in the "continue with" sections of `LoginView` and `RegisterView`. +/// Shows the provider icon + name, and a spinner while an OAuth round-trip is in flight. +struct OAuthSignInButton: View { + let provider: OAuthProvider + let inFlight: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: provider.systemImageName) + .frame(width: 24) + Text(provider.displayName) + Spacer() + if inFlight { + ProgressView() + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(inFlight) + .accessibilityLabel("Continue with \(provider.displayName)") + } +} + +#Preview { + Form { + Section("Or continue with") { + ForEach(OAuthProvider.allCases, id: \.rawValue) { provider in + OAuthSignInButton(provider: provider, inFlight: false) {} + } + } + } +} diff --git a/InterlinedList/Views/OrganizationsView.swift b/InterlinedList/Views/OrganizationsView.swift new file mode 100644 index 0000000..9cddbbf --- /dev/null +++ b/InterlinedList/Views/OrganizationsView.swift @@ -0,0 +1,510 @@ +// +// OrganizationsView.swift +// InterlinedList +// + +import SwiftUI + +/// The organizations the current user belongs to, with create / open actions. +struct OrganizationsListView: View { + @EnvironmentObject private var authState: AuthState + @State private var organizations: [Organization] = [] + @State private var isLoading = true + @State private var error: String? + @State private var showCreate = false + + var body: some View { + Group { + if isLoading && organizations.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, organizations.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else if organizations.isEmpty { + ContentUnavailableView { + Label("No Organizations", systemImage: "building.2") + } description: { + Text("Create an organization to collaborate with others.") + } actions: { + Button("Create Organization") { showCreate = true } + } + } else { + List(organizations) { org in + NavigationLink { + OrganizationDetailView(orgId: org.id, initialName: org.name) + .environmentObject(authState) + } label: { + OrganizationRow(org: org) + } + } + } + } + .navigationTitle("Organizations") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showCreate = true } label: { Image(systemName: "plus") } + .accessibilityLabel("New organization") + } + } + .task { await load() } + .sheet(isPresented: $showCreate, onDismiss: { Task { await load() } }) { + CreateOrganizationView() + .environmentObject(authState) + } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + organizations = try await APIClient.shared.userOrganizations() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load organizations." + } + } +} + +private struct OrganizationRow: View { + let org: Organization + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "building.2.fill") + .foregroundStyle(.secondary) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(org.name).font(.body) + if let role = org.role { + Text(role.label).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if let count = org.memberCount { + Text("\(count)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Detail + +struct OrganizationDetailView: View { + let orgId: String + var initialName: String? + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var org: Organization? + @State private var isLoading = true + @State private var error: String? + @State private var showEdit = false + @State private var showDeleteConfirm = false + @State private var actionError: String? + + private var myRole: OrgRole? { org?.role } + private var isOwner: Bool { myRole == .owner } + + var body: some View { + List { + if let actionError { + Section { Text(actionError).font(.caption).foregroundStyle(.red) } + } + if let org { + Section { + if let desc = org.description, !desc.isEmpty { + Text(desc) + } + LabeledContent("Visibility", value: org.isPublic == true ? "Public" : "Private") + if let count = org.memberCount { + LabeledContent("Members", value: "\(count)") + } + if let role = org.role { + LabeledContent("Your role", value: role.label) + } + } + Section { + NavigationLink { + OrganizationMembersView(orgId: orgId, myRole: myRole) + .environmentObject(authState) + } label: { + Label("Members", systemImage: "person.3") + } + } + if isOwner { + Section { + Button { + showEdit = true + } label: { + Label("Edit organization", systemImage: "pencil") + } + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Label("Delete organization", systemImage: "trash") + } + } + } + } else if isLoading { + ProgressView() + } else if let error { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } + } + .navigationTitle(org?.name ?? initialName ?? "Organization") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + .sheet(isPresented: $showEdit, onDismiss: { Task { await load() } }) { + if let org { + EditOrganizationView(org: org) + .environmentObject(authState) + } + } + .confirmationDialog("Delete this organization?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + Button("Delete", role: .destructive) { Task { await delete() } } + Button("Cancel", role: .cancel) {} + } message: { + Text("This permanently removes the organization for all members.") + } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + org = try await APIClient.shared.organization(id: orgId) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load this organization." + } + } + + private func delete() async { + actionError = nil + do { + try await APIClient.shared.deleteOrganization(id: orgId) + dismiss() + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not delete the organization." + } + } +} + +// MARK: - Members + +struct OrganizationMembersView: View { + let orgId: String + let myRole: OrgRole? + + @EnvironmentObject private var authState: AuthState + @State private var members: [OrganizationMember] = [] + @State private var isLoading = true + @State private var error: String? + @State private var actionError: String? + + private var ownerCount: Int { members.filter { $0.orgRole == .owner }.count } + + /// Owners and admins can manage. The last remaining owner can't be changed, + /// and admins can't manage owners. + private func canManage(_ member: OrganizationMember) -> Bool { + guard let myRole, myRole >= .admin else { return false } + if member.orgRole == .owner && ownerCount <= 1 { return false } + if member.orgRole == .owner && myRole != .owner { return false } + return true + } + + var body: some View { + Group { + if isLoading && members.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, members.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else { + List { + if let actionError { + Section { Text(actionError).font(.caption).foregroundStyle(.red) } + } + ForEach(members) { member in + MemberRow( + member: member, + canManage: canManage(member), + onChangeRole: { role in Task { await changeRole(member, to: role) } } + ) + .swipeActions(edge: .trailing) { + if canManage(member) { + Button(role: .destructive) { + Task { await remove(member) } + } label: { + Label("Remove", systemImage: "person.fill.xmark") + } + } + } + } + } + } + } + .navigationTitle("Members") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + let (list, _) = try await APIClient.shared.organizationMembers(id: orgId) + members = list + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load members." + } + } + + private func changeRole(_ member: OrganizationMember, to role: OrgRole) async { + guard member.orgRole != role else { return } + actionError = nil + do { + try await APIClient.shared.setOrganizationMemberRole(id: orgId, userId: member.id, role: role) + await load() + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not change role." + } + } + + private func remove(_ member: OrganizationMember) async { + actionError = nil + do { + try await APIClient.shared.removeOrganizationMember(id: orgId, userId: member.id) + members.removeAll { $0.id == member.id } + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not remove this member." + } + } +} + +private struct MemberRow: View { + let member: OrganizationMember + let canManage: Bool + let onChangeRole: (OrgRole) -> Void + + var body: some View { + HStack(spacing: 12) { + avatar + VStack(alignment: .leading, spacing: 2) { + Text(member.displayNameOrUsername).font(.body) + Text("@\(member.username)").font(.caption).foregroundStyle(.secondary) + } + Spacer() + if canManage { + Menu { + ForEach(OrgRole.allCases, id: \.self) { role in + Button { + onChangeRole(role) + } label: { + if member.orgRole == role { + Label(role.label, systemImage: "checkmark") + } else { + Text(role.label) + } + } + } + } label: { + roleBadge + } + } else { + roleBadge + } + } + .padding(.vertical, 2) + } + + private var roleBadge: some View { + Text((member.orgRole ?? .member).label) + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color(.secondarySystemFill)) + .clipShape(Capsule()) + } + + @ViewBuilder + private var avatar: some View { + if let url = member.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) + } + } +} + +// MARK: - Create / Edit + +struct CreateOrganizationView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var name = "" + @State private var description = "" + @State private var isPublic = false + @State private var isLoading = false + @State private var error: String? + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("Organization name", text: $name) + } + Section("Description") { + TextField("Description (optional)", text: $description, axis: .vertical) + .lineLimit(2...5) + } + Section { + Toggle("Public", isOn: $isPublic) + } + if let error { + Section { Text(error).foregroundStyle(.red).font(.caption) } + } + } + .navigationTitle("New Organization") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { Task { await create() } } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) + } + } + } + } + + private func create() async { + isLoading = true + error = nil + defer { isLoading = false } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let desc = description.trimmingCharacters(in: .whitespacesAndNewlines) + do { + _ = try await APIClient.shared.createOrganization(name: trimmed, description: desc.isEmpty ? nil : desc, isPublic: isPublic) + dismiss() + } catch APIError.server(let msg) { + error = msg + } catch { + self.error = "Could not create the organization." + } + } +} + +struct EditOrganizationView: View { + let org: Organization + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var name: String + @State private var description: String + @State private var isPublic: Bool + @State private var isLoading = false + @State private var error: String? + + init(org: Organization) { + self.org = org + _name = State(initialValue: org.name) + _description = State(initialValue: org.description ?? "") + _isPublic = State(initialValue: org.isPublic ?? false) + } + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("Organization name", text: $name) + } + Section("Description") { + TextField("Description (optional)", text: $description, axis: .vertical) + .lineLimit(2...5) + } + Section { + Toggle("Public", isOn: $isPublic) + } + if let error { + Section { Text(error).foregroundStyle(.red).font(.caption) } + } + } + .navigationTitle("Edit Organization") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { Task { await save() } } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) + } + } + } + } + + private func save() async { + isLoading = true + error = nil + defer { isLoading = false } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let desc = description.trimmingCharacters(in: .whitespacesAndNewlines) + do { + try await APIClient.shared.updateOrganization(id: org.id, name: trimmed, description: desc, isPublic: isPublic) + dismiss() + } catch APIError.server(let msg) { + error = msg + } catch { + self.error = "Could not save changes." + } + } +} + +#Preview { + NavigationStack { + OrganizationsListView() + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/PublicDocumentsView.swift b/InterlinedList/Views/PublicDocumentsView.swift new file mode 100644 index 0000000..ccf3aef --- /dev/null +++ b/InterlinedList/Views/PublicDocumentsView.swift @@ -0,0 +1,133 @@ +// +// PublicDocumentsView.swift +// InterlinedList +// + +import SwiftUI + +/// A read-only list of another user's public documents. +struct PublicDocumentsView: View { + let username: String + + @EnvironmentObject private var authState: AuthState + @State private var documents: [PublicDocumentSummary] = [] + @State private var isLoading = true + @State private var error: String? + + var body: some View { + Group { + if isLoading && documents.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, documents.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else if documents.isEmpty { + ContentUnavailableView { + Label("No Documents", systemImage: "doc.text") + } description: { + Text("@\(username) has no public documents.") + } + } else { + List(documents) { doc in + NavigationLink { + PublicDocumentReader(documentId: doc.id, title: doc.title) + .environmentObject(authState) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(doc.title).font(.body) + if let path = doc.relativePath, !path.isEmpty { + Text(path).font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + } + .navigationTitle("Documents") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + let response = try await APIClient.shared.publicDocuments(username: username) + documents = response.documents + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load documents." + } + } +} + +/// Read-only renderer for a single public document. +struct PublicDocumentReader: View { + let documentId: String + let title: String + + @EnvironmentObject private var authState: AuthState + @State private var document: Document? + @State private var isLoading = true + @State private var error: String? + + var body: some View { + ScrollView { + if isLoading { + ProgressView().padding(.top, 40) + } else if let error { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } + .padding(.top, 40) + } else { + VStack(alignment: .leading, spacing: 12) { + Text(document?.title ?? title) + .font(.title2.bold()) + if let content = document?.content, !content.isEmpty { + Text(content) + .font(.body) + .textSelection(.enabled) + } else { + Text("This document is empty.") + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } + .navigationTitle(document?.title ?? title) + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + document = try await APIClient.shared.publicDocument(id: documentId) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load this document." + } + } +} + +#Preview { + NavigationStack { + PublicDocumentsView(username: "someone") + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/PublicListDetailView.swift b/InterlinedList/Views/PublicListDetailView.swift new file mode 100644 index 0000000..2d6e60e --- /dev/null +++ b/InterlinedList/Views/PublicListDetailView.swift @@ -0,0 +1,201 @@ +// +// PublicListDetailView.swift +// InterlinedList +// + +import SwiftUI + +/// Read-only view of another user's public list, with a Watch / Unwatch CTA. +struct PublicListDetailView: View { + let username: String + let listId: String + var listTitle: String? + + @EnvironmentObject private var authState: AuthState + @State private var detail: PublicListDetail? + @State private var rows: [ListItem] = [] + @State private var properties: [ListPropertyDef] = [] + @State private var isLoading = true + @State private var error: String? + + @State private var isWatching: Bool? + @State private var isWatchLoading = false + @State private var watchError: String? + + var body: some View { + Group { + if isLoading && rows.isEmpty && detail == nil { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, detail == nil { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else { + listBody + } + } + .navigationTitle(detail?.title ?? listTitle ?? "List") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + watchButton + } + } + .task { await load() } + } + + @ViewBuilder + private var listBody: some View { + List { + if let desc = detail?.description, !desc.isEmpty { + Section { + Text(desc).font(.subheadline).foregroundStyle(.secondary) + } + } + if let watchError { + Section { Text(watchError).font(.caption).foregroundStyle(.red) } + } + if let children = detail?.children, !children.isEmpty { + Section("Sub-lists") { + ForEach(children) { child in + NavigationLink { + PublicListDetailView(username: username, listId: child.id, listTitle: child.title) + .environmentObject(authState) + } label: { + Label(child.title ?? "Untitled", systemImage: "list.bullet.indent") + } + } + } + } + Section(rows.isEmpty ? "" : "Items") { + if rows.isEmpty { + Text("This list has no items.") + .foregroundStyle(.secondary) + .font(.subheadline) + } else { + ForEach(rows) { row in + PublicListRow(row: row, properties: orderedProperties) + } + } + } + } + .listStyle(.insetGrouped) + } + + /// Visible properties in display order; falls back to whatever the data call returned. + private var orderedProperties: [ListPropertyDef] { + let source = properties.isEmpty ? (detail?.properties ?? []) : properties + return source.filter { $0.isVisible }.sorted { $0.displayOrder < $1.displayOrder } + } + + @ViewBuilder + private var watchButton: some View { + if isWatchLoading { + ProgressView() + } else if let watching = isWatching { + Button { + Task { await toggleWatch() } + } label: { + Label(watching ? "Watching" : "Watch", + systemImage: watching ? "eye.fill" : "eye") + } + .accessibilityLabel(watching ? "Stop watching this list" : "Watch this list") + } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + async let detailTask = APIClient.shared.publicListDetail(username: username, listId: listId) + async let dataTask = APIClient.shared.publicListData(username: username, listId: listId) + let (d, data) = try await (detailTask, dataTask) + detail = d + rows = data.rows + properties = data.properties ?? d.properties ?? [] + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load this list." + } + // Watch status is best-effort and independent of the list body. + isWatching = try? await APIClient.shared.isWatchingList(listId: listId) + } + + private func toggleWatch() async { + guard let userId = authState.user?.id, let watching = isWatching else { return } + isWatchLoading = true + watchError = nil + defer { isWatchLoading = false } + do { + if watching { + try await APIClient.shared.removeWatcher(listId: listId, userId: userId) + isWatching = false + } else { + _ = try await APIClient.shared.addWatcher(listId: listId, userId: userId, role: .watcher) + isWatching = true + } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch APIError.server(let msg) { + watchError = msg + } catch { + watchError = "Could not update watch status." + } + } +} + +/// One read-only data row rendered as labelled key/value pairs. +private struct PublicListRow: View { + let row: ListItem + let properties: [ListPropertyDef] + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(pairs, id: \.label) { pair in + HStack(alignment: .top, spacing: 8) { + Text(pair.label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 110, alignment: .leading) + Text(pair.value) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(.vertical, 2) + } + + /// Build ordered (label, value) pairs. When schema properties are known, use + /// them for labels and order; otherwise fall back to the raw row keys. + private var pairs: [(label: String, value: String)] { + if !properties.isEmpty { + return properties.compactMap { prop in + guard let value = row.rowData[prop.propertyKey], value != .null else { return nil } + let display = value.displayString + guard !display.isEmpty else { return nil } + return (prop.propertyName, display) + } + } + return row.rowData + .sorted { $0.key < $1.key } + .compactMap { key, value in + let display = value.displayString + guard !display.isEmpty else { return nil } + return (key, display) + } + } +} + +#Preview { + NavigationStack { + PublicListDetailView(username: "someone", listId: "list-1", listTitle: "Books") + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/RegisterView.swift b/InterlinedList/Views/RegisterView.swift index 5693975..f5f6c46 100644 --- a/InterlinedList/Views/RegisterView.swift +++ b/InterlinedList/Views/RegisterView.swift @@ -14,6 +14,11 @@ struct RegisterView: View { @State private var password = "" @State private var errorMessage: String? @State private var isLoading = false + @State private var showMastodonPrompt = false + @State private var mastodonInstance = "" + @State private var oauthInFlight = false + @State private var linkedinVisible = false + @State private var twitterVisible = false var body: some View { NavigationStack { @@ -59,6 +64,14 @@ struct RegisterView: View { } .disabled(isLoading || email.isEmpty || username.isEmpty || password.count < 8) } + + Section("Or sign up with") { + ForEach(visibleProviders, id: \.rawValue) { provider in + OAuthSignInButton(provider: provider, inFlight: oauthInFlight) { + handleOAuthTap(provider: provider) + } + } + } } .scrollDismissesKeyboard(.interactively) .navigationTitle("Sign up") @@ -70,10 +83,71 @@ struct RegisterView: View { } } } + .alert("Mastodon instance", + isPresented: $showMastodonPrompt) { + TextField("mastodon.social", text: $mastodonInstance) + .autocapitalization(.none) + .disableAutocorrection(true) + Button("Continue") { + Task { await runOAuth(provider: .mastodon, instance: mastodonInstance) } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Enter your Mastodon server hostname.") + } } + .task { await refreshOAuthVisibility() } .onAppear { errorMessage = nil } } + private var visibleProviders: [OAuthProvider] { + OAuthProvider.allCases.filter { + guard $0.supportsNativeAuth else { return false } + switch $0 { + case .linkedin: return linkedinVisible + case .twitter: return twitterVisible + default: return true + } + } + } + + private func refreshOAuthVisibility() async { + async let li = APIClient.shared.linkedinStatus() + async let tw = APIClient.shared.twitterStatus() + if let liStatus = try? await li { linkedinVisible = liStatus.configured } else { linkedinVisible = false } + if let twStatus = try? await tw { twitterVisible = twStatus.configured } else { twitterVisible = false } + } + + private func handleOAuthTap(provider: OAuthProvider) { + if provider == .mastodon { + mastodonInstance = "" + showMastodonPrompt = true + return + } + Task { await runOAuth(provider: provider, instance: nil) } + } + + private func runOAuth(provider: OAuthProvider, instance: String?) async { + errorMessage = nil + oauthInFlight = true + defer { oauthInFlight = false } + do { + let token = try await OAuthCoordinator.shared.authenticate( + provider: provider, + instance: instance, + link: false + ) + try await authState.completeOAuthLogin(token: token) + dismiss() + } catch OAuthError.cancelled { + // No surfaced error. + } catch OAuthError.providerError(let message) { + errorMessage = message + } catch { + errorMessage = "Sign-up with \(provider.displayName) failed. Please try again." + } + } + private func register() async { errorMessage = nil isLoading = true diff --git a/InterlinedList/Views/ResetPasswordView.swift b/InterlinedList/Views/ResetPasswordView.swift new file mode 100644 index 0000000..826e48b --- /dev/null +++ b/InterlinedList/Views/ResetPasswordView.swift @@ -0,0 +1,116 @@ +// +// ResetPasswordView.swift +// InterlinedList +// + +import SwiftUI + +struct ResetPasswordView: View { + @Environment(\.dismiss) private var dismiss + @State private var token: String + @State private var password = "" + @State private var confirmPassword = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var didReset = false + + init(token: String = "") { + _token = State(initialValue: token) + } + + var body: some View { + NavigationStack { + Form { + Section { + Text("Paste the reset token from your email or open the link in this app to fill it in automatically.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Section("Reset token") { + TextField("Token", text: $token) + .autocapitalization(.none) + .disableAutocorrection(true) + .disabled(didReset) + } + Section { + SecureField("New password", text: $password) + .textContentType(.newPassword) + .disabled(didReset) + SecureField("Confirm new password", text: $confirmPassword) + .textContentType(.newPassword) + .disabled(didReset) + } header: { + Text("New password") + } footer: { + Text("Password must be at least 8 characters.") + } + if let error = errorMessage { + Section { + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + if didReset { + Section { + Label("Password reset. You can now log in.", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + } + } else { + Section { + Button { + Task { await submit() } + } label: { + HStack { + if isLoading { + ProgressView().frame(width: 20, height: 20) + } + Text("Reset password") + .frame(maxWidth: .infinity) + } + } + .disabled(isLoading || !canSubmit) + .accessibilityLabel("Reset password") + } + } + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Reset password") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(didReset ? "Done" : "Cancel") { dismiss() } + } + } + } + } + + private var canSubmit: Bool { + !token.isEmpty && password.count >= 8 && password == confirmPassword + } + + private func submit() async { + errorMessage = nil + isLoading = true + defer { isLoading = false } + do { + try await APIClient.shared.resetPassword( + token: token.trimmingCharacters(in: .whitespacesAndNewlines), + password: password + ) + didReset = true + } catch APIError.server(let message) { + errorMessage = message + } catch APIError.status(400) { + errorMessage = "Reset link is invalid or expired." + } catch APIError.status(let code) { + errorMessage = "Reset failed (HTTP \(code))." + } catch { + errorMessage = "Connection failed. Please try again." + } + } +} + +#Preview { + ResetPasswordView(token: "demo-token") +} diff --git a/InterlinedList/Views/RootView.swift b/InterlinedList/Views/RootView.swift index b2ac17f..e1311ce 100644 --- a/InterlinedList/Views/RootView.swift +++ b/InterlinedList/Views/RootView.swift @@ -16,5 +16,16 @@ struct RootView: View { LoginView() } } + .preferredColorScheme(preferredScheme) + } + + /// Honor the user's saved theme preference ("light" / "dark"); "system" or + /// missing leaves the OS appearance in control. + private var preferredScheme: ColorScheme? { + switch authState.user?.theme { + case "light": return .light + case "dark": return .dark + default: return nil + } } } diff --git a/InterlinedList/Views/ScheduledMessagesView.swift b/InterlinedList/Views/ScheduledMessagesView.swift index 89e177a..f7ada37 100644 --- a/InterlinedList/Views/ScheduledMessagesView.swift +++ b/InterlinedList/Views/ScheduledMessagesView.swift @@ -11,6 +11,7 @@ struct ScheduledMessagesView: View { @State private var messages: [Message] = [] @State private var isLoading = true @State private var errorMessage: String? + @State private var messageToEdit: Message? private let ranges = [ ("today", "Today"), @@ -42,7 +43,12 @@ struct ScheduledMessagesView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(messages) { message in - ScheduledMessageRow(message: message) + Button { + messageToEdit = message + } label: { + ScheduledMessageRow(message: message) + } + .buttonStyle(.plain) } .refreshable { await load() } } @@ -65,6 +71,9 @@ struct ScheduledMessagesView: View { } .task { await load() } .onChange(of: range) { _, _ in Task { await load() } } + .sheet(item: $messageToEdit, onDismiss: { Task { await load() } }) { message in + EditMessageView(message: message) { _ in } + } } } @@ -74,11 +83,13 @@ struct ScheduledMessagesView: View { defer { isLoading = false } do { messages = try await APIClient.shared.scheduledMessages(range: range) - } catch APIError.status(403) { - errorMessage = "Scheduled posts require an active subscription." } catch APIError.server(let msg) { errorMessage = msg } catch { + // The calendar entry point in FeedView is hidden for free users, + // so 403 from this subscriber-only endpoint shouldn't normally + // reach the UI. Generic message preserves strict-silence on + // subscription state. errorMessage = "Could not load scheduled posts." } } diff --git a/InterlinedList/Views/SettingsView.swift b/InterlinedList/Views/SettingsView.swift new file mode 100644 index 0000000..898fc80 --- /dev/null +++ b/InterlinedList/Views/SettingsView.swift @@ -0,0 +1,269 @@ +// +// SettingsView.swift +// InterlinedList +// + +import SwiftUI +import SafariServices + +/// App settings: appearance, posting defaults, connected accounts, notification +/// preferences, informational links, and sign-out. Consolidates account-level +/// controls that previously lived only on the profile / edit-profile screens. +struct SettingsView: View { + @EnvironmentObject private var authState: AuthState + @Environment(\.dismiss) private var dismiss + + @State private var theme: String = "system" + @State private var defaultPublic: Bool = true + @State private var showAdvanced: Bool = false + @State private var settingsError: String? + @State private var safariLink: SafariLink? + + private let aboutLinks: [(title: String, path: String)] = [ + ("Blog", "/blog"), + ("Pricing", "/pricing"), + ("Terms of Service", "/terms"), + ("Privacy Policy", "/privacy"), + ("Branding", "/help/branding"), + ] + + var body: some View { + NavigationStack { + Form { + appearanceSection + postingSection + accountsSection + notificationsSection + aboutSection + if let settingsError { + Section { Text(settingsError).font(.caption).foregroundStyle(.red) } + } + Section { + Button(role: .destructive) { + authState.logout() + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .onAppear(perform: syncFromUser) + .sheet(item: $safariLink) { link in + SafariView(url: link.url).ignoresSafeArea() + } + } + } + + private func syncFromUser() { + guard let user = authState.user else { return } + theme = user.theme ?? "system" + defaultPublic = user.defaultPubliclyVisible ?? true + showAdvanced = user.showAdvancedPostSettings ?? false + } + + // MARK: - Sections + + private var appearanceSection: some View { + Section("Appearance") { + Picker("Theme", selection: $theme) { + Text("System").tag("system") + Text("Light").tag("light") + Text("Dark").tag("dark") + } + .onChange(of: theme) { _, newValue in + Task { await save(theme: newValue) } + } + } + } + + private var postingSection: some View { + Section("Posting") { + Toggle("Default to public", isOn: $defaultPublic) + .onChange(of: defaultPublic) { _, newValue in + Task { await save(defaultVisibility: newValue) } + } + Toggle("Show advanced post settings", isOn: $showAdvanced) + .onChange(of: showAdvanced) { _, newValue in + Task { await save(showAdvancedPostSettings: newValue) } + } + if let maxLen = authState.user?.maxMessageLength { + LabeledContent("Max message length", value: maxLen, format: .number) + } + } + } + + @ViewBuilder + private var accountsSection: some View { + if authState.user?.isSubscriber == true { + Section("Accounts") { + NavigationLink { + LinkedIdentitiesView().environmentObject(authState) + } label: { + Label("Connected accounts", systemImage: "link") + } + } + } + } + + private var notificationsSection: some View { + Section("Notifications") { + NavigationLink { + NotificationPreferencesView().environmentObject(authState) + } label: { + Label("Notification preferences", systemImage: "bell.badge") + } + } + } + + private var aboutSection: some View { + Section("About") { + ForEach(aboutLinks, id: \.path) { link in + Button { + if let url = URL(string: "https://interlinedlist.com" + link.path) { + safariLink = SafariLink(url: url) + } + } label: { + HStack { + Text(link.title).foregroundStyle(.primary) + Spacer() + Image(systemName: "arrow.up.right.square").foregroundStyle(.secondary) + } + } + } + } + } + + // MARK: - Persistence + + private func save(theme: String? = nil, defaultVisibility: Bool? = nil, showAdvancedPostSettings: Bool? = nil) async { + settingsError = nil + do { + let updated = try await APIClient.shared.updateUserSettings( + theme: theme, + defaultVisibility: defaultVisibility, + showAdvancedPostSettings: showAdvancedPostSettings + ) + authState.updateUser(updated) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + settingsError = "Could not save settings." + } + } +} + +/// Identifiable wrapper so a URL can drive a Safari sheet via `.sheet(item:)`. +struct SafariLink: Identifiable { + let id = UUID() + let url: URL +} + +/// Thin wrapper around SFSafariViewController for in-app web content. +struct SafariView: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: Context) -> SFSafariViewController { + SFSafariViewController(url: url) + } + func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {} +} + +// MARK: - Notification preferences + +/// Settings → Notifications. Renders one row per supported channel for each +/// event the server actually emits (GAP §B3: real catalog only — dig/push/follow, +/// channels limited to push/inApp, no email). Toggles persist immediately. +struct NotificationPreferencesView: View { + @EnvironmentObject private var authState: AuthState + @State private var events: [NotificationPreference] = [] + @State private var isLoading = true + @State private var error: String? + @State private var actionError: String? + + var body: some View { + Group { + if isLoading && events.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, events.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else { + List { + if let actionError { + Section { Text(actionError).font(.caption).foregroundStyle(.red) } + } + ForEach(Array(events.enumerated()), id: \.element.id) { index, event in + Section { + if event.supportsPush { + channelToggle(index: index, label: "Push", keyPath: \.push) + } + if event.supportsInApp { + channelToggle(index: index, label: "In-app", keyPath: \.inApp) + } + } header: { + Text(event.label) + } footer: { + if let desc = event.description, !desc.isEmpty { + Text(desc) + } + } + } + } + } + } + .navigationTitle("Notifications") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func channelToggle(index: Int, label: String, keyPath: WritableKeyPath) -> some View { + Toggle(label, isOn: Binding( + get: { events[index].channels[keyPath: keyPath] ?? false }, + set: { newValue in + events[index].channels[keyPath: keyPath] = newValue + Task { await save(events[index]) } + } + )) + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + events = try await APIClient.shared.notificationPreferences() + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load notification settings." + } + } + + private func save(_ event: NotificationPreference) async { + actionError = nil + do { + let updated = try await APIClient.shared.updateNotificationPreference(key: event.key, channels: event.channels) + if let idx = events.firstIndex(where: { $0.key == updated.key }) { + events[idx] = updated + } + } catch { + actionError = "Could not update “\(event.label).”" + await load() + } + } +} + +#Preview { + SettingsView() + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/UserProfileView.swift b/InterlinedList/Views/UserProfileView.swift index 0918353..b09c106 100644 --- a/InterlinedList/Views/UserProfileView.swift +++ b/InterlinedList/Views/UserProfileView.swift @@ -22,6 +22,7 @@ struct UserProfileView: View { @State private var targetUserId: String? @State private var followStatus: FollowStatus? @State private var followCounts: FollowCounts? + @State private var mutualCounts: MutualCounts? @State private var isFollowLoading = false @State private var followError: String? @State private var isExporting: ExportType? = nil @@ -29,6 +30,12 @@ struct UserProfileView: View { @State private var exportFilename: String = "export.csv" @State private var showShareSheet = false @State private var exportError: String? = nil + @State private var organizations: [Organization] = [] + @State private var organizationsLoaded = false + @State private var documents: [PublicDocumentSummary] = [] + @State private var isLoadingDocuments = false + @State private var documentsError: String? + @State private var documentsLoaded = false var body: some View { NavigationStack { @@ -40,17 +47,19 @@ struct UserProfileView: View { Picker("Content", selection: $selectedTab) { Text("Posts").tag(0) Text("Lists").tag(1) + Text("Documents").tag(2) } .pickerStyle(.segmented) .padding() - if selectedTab == 0 { - messagesTab - } else { - listsTab + switch selectedTab { + case 0: messagesTab + case 1: listsTab + default: documentsTab } if authState.user?.username == username { + organizationsSection exportSection } } @@ -63,10 +72,15 @@ struct UserProfileView: View { } .task { await loadMessages() + if authState.user?.username == username && !organizationsLoaded { + await loadOrganizations() + } } .onChange(of: selectedTab) { _, tab in if tab == 1 && lists.isEmpty && listsError == nil { Task { await loadLists() } + } else if tab == 2 && !documentsLoaded { + Task { await loadDocuments() } } } .sheet(isPresented: $showShareSheet) { @@ -81,21 +95,21 @@ struct UserProfileView: View { private var followHeader: some View { VStack(spacing: 8) { HStack(spacing: 24) { - if let counts = followCounts { - VStack(spacing: 2) { - Text("\(counts.followers)") - .font(.headline) - Text("Followers") - .font(.caption) - .foregroundStyle(.secondary) + if let counts = followCounts, let uid = targetUserId { + NavigationLink { + FollowListView(userId: uid, mode: .followers) + .environmentObject(authState) + } label: { + countView(value: counts.followers, label: "Followers") } - VStack(spacing: 2) { - Text("\(counts.following)") - .font(.headline) - Text("Following") - .font(.caption) - .foregroundStyle(.secondary) + .buttonStyle(.plain) + NavigationLink { + FollowListView(userId: uid, mode: .following) + .environmentObject(authState) + } label: { + countView(value: counts.following, label: "Following") } + .buttonStyle(.plain) } Spacer() if targetUserId != nil { @@ -105,6 +119,14 @@ struct UserProfileView: View { .padding(.horizontal) .padding(.top, 8) + if let mutual = mutualCounts, mutual.mutualFollowers > 0 || mutual.mutualFollowing > 0 { + Text(mutualSummary(mutual)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + } + if let error = followError { Text(error) .font(.caption) @@ -115,6 +137,27 @@ struct UserProfileView: View { Divider() } + private func countView(value: Int, label: String) -> some View { + VStack(spacing: 2) { + Text("\(value)") + .font(.headline) + .foregroundStyle(.primary) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(value) \(label)") + .accessibilityHint("Shows the full list") + } + + private func mutualSummary(_ m: MutualCounts) -> String { + var parts: [String] = [] + if m.mutualFollowers > 0 { parts.append("\(m.mutualFollowers) mutual follower\(m.mutualFollowers == 1 ? "" : "s")") } + if m.mutualFollowing > 0 { parts.append("\(m.mutualFollowing) followed in common") } + return parts.joined(separator: " · ") + } + @ViewBuilder private var followButton: some View { if isFollowLoading { @@ -193,26 +236,69 @@ struct UserProfileView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(lists) { list in - VStack(alignment: .leading, spacing: 4) { - Text(list.name) - .font(.body) - if let desc = list.description, !desc.isEmpty { - Text(desc) - .font(.caption) - .foregroundStyle(.secondary) - } - if let count = list.itemCount { - Text("\(count) items") - .font(.caption2) - .foregroundStyle(.secondary) + NavigationLink { + PublicListDetailView(username: username, listId: list.id, listTitle: list.name) + .environmentObject(authState) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(list.name) + .font(.body) + if let desc = list.description, !desc.isEmpty { + Text(desc) + .font(.caption) + .foregroundStyle(.secondary) + } + if let count = list.itemCount { + Text("\(count) items") + .font(.caption2) + .foregroundStyle(.secondary) + } } + .padding(.vertical, 2) } - .padding(.vertical, 2) } .refreshable { await loadLists() } } } + @ViewBuilder + private var documentsTab: some View { + if isLoadingDocuments && documents.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = documentsError { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await loadDocuments() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if documents.isEmpty { + ContentUnavailableView { + Label("No Documents", systemImage: "doc.text") + } description: { + Text("@\(username) has no public documents.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(documents) { doc in + NavigationLink { + PublicDocumentReader(documentId: doc.id, title: doc.title) + .environmentObject(authState) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(doc.title).font(.body) + if let path = doc.relativePath, !path.isEmpty { + Text(path).font(.caption).foregroundStyle(.secondary) + } + } + } + } + .refreshable { await loadDocuments() } + } + } + private func loadMessages() async { messagesError = nil isLoadingMessages = true @@ -254,6 +340,18 @@ struct UserProfileView: View { } } + private func loadDocuments() async { + documentsError = nil + isLoadingDocuments = true + documentsLoaded = true + defer { isLoadingDocuments = false } + do { + documents = try await APIClient.shared.publicDocuments(username: username).documents + } catch { + documentsError = "Could not load documents." + } + } + private func loadFollowInfo(userId: String) async { do { async let statusTask = APIClient.shared.followStatus(userId: userId) @@ -264,6 +362,55 @@ struct UserProfileView: View { } catch { // Follow info is supplementary — silently ignore errors } + // Mutual counts are a separate, best-effort call. + mutualCounts = try? await APIClient.shared.mutualCounts(userId: userId) + } + + @ViewBuilder + private var organizationsSection: some View { + if !organizations.isEmpty { + Divider() + VStack(alignment: .leading, spacing: 0) { + Text("Organizations") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 8) + ForEach(organizations) { org in + VStack(alignment: .leading, spacing: 4) { + Text(org.name) + .font(.body) + if let desc = org.description, !desc.isEmpty { + Text(desc) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 8) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel(for: org)) + } + } + } + } + + private func accessibilityLabel(for org: Organization) -> String { + if let desc = org.description, !desc.isEmpty { + return "\(org.name). \(desc)" + } + return org.name + } + + private func loadOrganizations() async { + organizationsLoaded = true + do { + organizations = try await APIClient.shared.userOrganizations() + } catch { + organizations = [] + } } @ViewBuilder diff --git a/InterlinedList/Views/WatchersListView.swift b/InterlinedList/Views/WatchersListView.swift new file mode 100644 index 0000000..a1b6a62 --- /dev/null +++ b/InterlinedList/Views/WatchersListView.swift @@ -0,0 +1,280 @@ +// +// WatchersListView.swift +// InterlinedList +// + +import SwiftUI + +/// Manager view of a list's watchers: see roles, change them, remove members, +/// and add new watchers/collaborators/managers. Presented from a list the +/// current user owns (and is therefore a manager of). +struct WatchersListView: View { + let listId: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var watchers: [ListWatcher] = [] + @State private var isLoading = true + @State private var error: String? + @State private var actionError: String? + @State private var showAdd = false + + var body: some View { + NavigationStack { + Group { + if isLoading && watchers.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, watchers.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else { + watchersList + } + } + .navigationTitle("Watchers") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button { showAdd = true } label: { Image(systemName: "person.badge.plus") } + .accessibilityLabel("Add watcher") + } + } + .task { await load() } + .sheet(isPresented: $showAdd, onDismiss: { Task { await load() } }) { + AddWatcherView(listId: listId) + .environmentObject(authState) + } + } + } + + @ViewBuilder + private var watchersList: some View { + List { + if let actionError { + Section { Text(actionError).font(.caption).foregroundStyle(.red) } + } + if watchers.isEmpty { + ContentUnavailableView { + Label("No watchers yet", systemImage: "eye") + } description: { + Text("Add people to share this list with.") + } + } else { + ForEach(watchers) { watcher in + WatcherRow( + watcher: watcher, + onChangeRole: { role in Task { await changeRole(watcher, to: role) } } + ) + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { await remove(watcher) } + } label: { + Label("Remove", systemImage: "person.fill.xmark") + } + } + } + } + } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + watchers = try await APIClient.shared.listWatchers(listId: listId) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load watchers." + } + } + + private func changeRole(_ watcher: ListWatcher, to role: WatcherRole) async { + guard watcher.watcherRole != role else { return } + actionError = nil + do { + _ = try await APIClient.shared.setWatcherRole(listId: listId, userId: watcher.userId, role: role) + await load() + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not change role." + } + } + + private func remove(_ watcher: ListWatcher) async { + actionError = nil + do { + try await APIClient.shared.removeWatcher(listId: listId, userId: watcher.userId) + watchers.removeAll { $0.id == watcher.id } + } catch APIError.server(let msg) { + actionError = msg + } catch { + actionError = "Could not remove this person." + } + } +} + +private struct WatcherRow: View { + let watcher: ListWatcher + let onChangeRole: (WatcherRole) -> Void + + var body: some View { + HStack(spacing: 12) { + avatar + VStack(alignment: .leading, spacing: 2) { + Text(watcher.user?.displayNameOrUsername ?? "User") + .font(.body) + if let username = watcher.user?.username { + Text("@\(username)").font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + Menu { + ForEach(WatcherRole.allCases, id: \.self) { role in + Button { + onChangeRole(role) + } label: { + if watcher.watcherRole == role { + Label(role.label, systemImage: "checkmark") + } else { + Text(role.label) + } + } + } + } label: { + Text((watcher.watcherRole ?? .watcher).label) + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color(.secondarySystemFill)) + .clipShape(Capsule()) + } + } + .padding(.vertical, 2) + } + + @ViewBuilder + private var avatar: some View { + if let url = watcher.user?.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 watcher with a chosen role. +private struct AddWatcherView: View { + let listId: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authState: AuthState + @State private var role: WatcherRole = .watcher + @State private var candidates: [WatcherCandidate] = [] + @State private var isLoading = 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).font(.caption).foregroundStyle(.secondary) + } + + Section("People") { + if isLoading { + ProgressView() + } else if let error { + Text(error).font(.caption).foregroundStyle(.red) + } else if candidates.isEmpty { + Text("No people available to add.") + .font(.subheadline).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(.caption).foregroundStyle(.secondary) + } + Spacer() + if addingId == candidate.id { + ProgressView() + } else { + Image(systemName: "plus.circle").foregroundStyle(Color.accentColor) + } + } + } + .disabled(addingId != nil) + } + } + } + } + .navigationTitle("Add Watcher") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .task { await loadCandidates() } + } + } + + private func loadCandidates() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + candidates = try await APIClient.shared.searchWatcherCandidates(listId: listId) + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load people." + } + } + + private func add(_ candidate: WatcherCandidate) async { + addingId = candidate.id + defer { addingId = nil } + do { + _ = try await APIClient.shared.addWatcher(listId: listId, userId: candidate.id, role: role) + dismiss() + } catch APIError.server(let msg) { + self.error = msg + } catch { + self.error = "Could not add this person." + } + } +} + +#Preview { + WatchersListView(listId: "list-1") + .environmentObject(AuthState()) +} diff --git a/InterlinedListTests/APIClientTests/APIClientAvatarTests.swift b/InterlinedListTests/APIClientTests/APIClientAvatarTests.swift new file mode 100644 index 0000000..374a307 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientAvatarTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import InterlinedList + +final class APIClientAvatarTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + private var userJSON: String { + #"{"user":{"id":"u1","email":"a@b.com","username":"alice","avatar":"https://cdn/avatar.png"}}"# + } + + // MARK: uploadAvatar + + func test_uploadAvatar_sendsPostToCorrectPath() async throws { + session.enqueue(json: #"{"url":"https://cdn/avatar.png"}"#) + session.enqueue(json: userJSON) + _ = try await sut.uploadAvatar(data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + // First request is the upload; track via requestHistory. + XCTAssertEqual(session.requestHistory.first?.httpMethod, "POST") + XCTAssertEqual(session.requestHistory.first?.url?.path, "/api/user/avatar/upload") + } + + func test_uploadAvatar_usesMultipart() async throws { + session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) + session.enqueue(json: userJSON) + _ = try await sut.uploadAvatar(data: Data([0xFF]), mimeType: "image/png") + let ct = session.requestHistory.first?.value(forHTTPHeaderField: "Content-Type") ?? "" + XCTAssertTrue(ct.hasPrefix("multipart/form-data")) + } + + func test_uploadAvatar_pngUsesPngExtension() async throws { + session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) + session.enqueue(json: userJSON) + _ = try await sut.uploadAvatar(data: Data([0x89]), mimeType: "image/png") + // The multipart body carries raw (non-UTF8) image bytes, so search the raw + // Data for the filename rather than decoding the whole body as a String. + let body = session.requestHistory.first?.httpBody ?? Data() + XCTAssertNotNil(body.range(of: Data(#"filename="avatar.png""#.utf8)), + "Multipart body should declare a .png filename") + } + + func test_uploadAvatar_returnsUser() async throws { + session.enqueue(json: #"{"url":"https://cdn/x.jpg"}"#) + session.enqueue(json: userJSON) + let user = try await sut.uploadAvatar(data: Data([0xFF]), mimeType: "image/jpeg") + XCTAssertEqual(user.id, "u1") + XCTAssertEqual(user.avatar, "https://cdn/avatar.png") + } + + func test_uploadAvatar_403_throws() async throws { + session.stub(data: Data(), statusCode: 403) + do { + _ = try await sut.uploadAvatar(data: Data([0xFF]), mimeType: "image/jpeg") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } + } + + // MARK: setAvatarFromURL + + func test_setAvatarFromURL_sendsCorrectPath() async throws { + session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) + session.enqueue(json: userJSON) + _ = try await sut.setAvatarFromURL("https://external/img.png") + XCTAssertEqual(session.requestHistory.first?.url?.path, "/api/user/avatar/from-url") + XCTAssertEqual(session.requestHistory.first?.httpMethod, "POST") + } + + func test_setAvatarFromURL_bodyContainsURL() async throws { + session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) + session.enqueue(json: userJSON) + _ = try await sut.setAvatarFromURL("https://external/img.png") + let body = String(data: session.requestHistory.first?.httpBody ?? Data(), encoding: .utf8) ?? "" + XCTAssertTrue(body.contains("\"url\":\"https:\\/\\/external\\/img.png\"")) + } + + func test_setAvatarFromURL_returnsUser() async throws { + session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) + session.enqueue(json: userJSON) + let user = try await sut.setAvatarFromURL("https://external/img.png") + XCTAssertEqual(user.id, "u1") + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientDeleteAccountTests.swift b/InterlinedListTests/APIClientTests/APIClientDeleteAccountTests.swift new file mode 100644 index 0000000..c3c7dfe --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientDeleteAccountTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import InterlinedList + +final class APIClientDeleteAccountTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + func test_deleteAccount_sendsCorrectPath() async throws { + session.stub(json: #"{"message":"deleted"}"#) + try await sut.deleteAccount() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/delete") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + } + + func test_deleteAccount_sendsBearerToken() async throws { + session.stub(json: #"{"message":"deleted"}"#) + try await sut.deleteAccount() + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_deleteAccount_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.deleteAccount() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_deleteAccount_500_throws() async throws { + session.stub(data: Data(), statusCode: 500) + do { + try await sut.deleteAccount() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 500) + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift b/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift index d88953b..efa64d3 100644 --- a/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientDocumentsTests.swift @@ -15,6 +15,11 @@ final class APIClientDocumentsTests: XCTestCase { sut.setBearerToken("tok") } + private func bodyString() -> String { + guard let data = session.lastRequest?.httpBody else { return "" } + return String(data: data, encoding: .utf8) ?? "" + } + // MARK: documents() func test_documents_sendsGetToCorrectPath() async throws { @@ -24,18 +29,20 @@ final class APIClientDocumentsTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents") } - func test_documents_withFolderId_appendsQuery() async throws { + func test_documents_withFolderId_usesFolderScopedPath() async throws { + // `GET /api/documents` ignores a folderId query and returns root docs, so a folder's + // contents must come from the folder-scoped endpoint. session.stub(json: #"{"documents":[]}"#) _ = try await sut.documents(folderId: "f1") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/folders/f1/documents") let url = session.lastRequest?.url?.absoluteString ?? "" - XCTAssertTrue(url.contains("folderId=f1")) + XCTAssertFalse(url.contains("folderId=")) } - func test_documents_emptyFolderId_noQuery() async throws { + func test_documents_emptyFolderId_usesRootPath() async throws { session.stub(json: #"{"documents":[]}"#) _ = try await sut.documents(folderId: "") - let url = session.lastRequest?.url?.absoluteString ?? "" - XCTAssertFalse(url.contains("folderId")) + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents") } func test_documents_decodesResult() async throws { @@ -57,13 +64,31 @@ final class APIClientDocumentsTests: XCTestCase { // MARK: createDocument() - func test_createDocument_sendsPostToCorrectPath() async throws { + func test_createDocument_root_sendsPostToDocumentsPath() async throws { session.stub(json: #"{"document":\#(docJSON)}"#) _ = try await sut.createDocument(title: "Doc", content: nil, isPublic: false, folderId: nil) XCTAssertEqual(session.lastRequest?.httpMethod, "POST") XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents") } + func test_createDocument_withFolderId_postsToFolderScopedPath() async throws { + session.stub(json: #"{"document":\#(docJSON)}"#) + _ = try await sut.createDocument(title: "Doc", content: nil, isPublic: true, folderId: "f1") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/folders/f1/documents") + } + + func test_createDocument_bodyUsesCamelCaseAndNoFolderField() async throws { + session.stub(json: #"{"document":\#(docJSON)}"#) + _ = try await sut.createDocument(title: "Doc", content: "x", isPublic: true, folderId: "f1") + let body = bodyString() + XCTAssertTrue(body.contains("\"isPublic\""), "expected camelCase isPublic, got: \(body)") + XCTAssertFalse(body.contains("is_public"), "snake_case isPublic is dropped server-side") + // Folder is conveyed by the path; the body must not carry a folder field. + XCTAssertFalse(body.contains("folderId")) + XCTAssertFalse(body.contains("folder_id")) + } + // MARK: updateDocument() func test_updateDocument_sendsPatchToCorrectPath() async throws { @@ -73,6 +98,16 @@ final class APIClientDocumentsTests: XCTestCase { XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/documents/d1") == true) } + func test_updateDocument_bodyUsesCamelCase() async throws { + session.stub(json: #"{"document":\#(docJSON)}"#) + _ = try await sut.updateDocument(id: "d1", title: "New", content: nil, isPublic: true, folderId: "f1") + let body = bodyString() + XCTAssertTrue(body.contains("\"folderId\""), "expected camelCase folderId, got: \(body)") + XCTAssertTrue(body.contains("\"isPublic\"")) + XCTAssertFalse(body.contains("folder_id")) + XCTAssertFalse(body.contains("is_public")) + } + // MARK: deleteDocument() func test_deleteDocument_sendsDeleteToCorrectPath() async throws { @@ -99,4 +134,12 @@ final class APIClientDocumentsTests: XCTestCase { XCTAssertEqual(session.lastRequest?.httpMethod, "POST") XCTAssertEqual(folder.name, "Folder") } + + func test_createDocumentFolder_nested_bodyUsesCamelCaseParentId() async throws { + session.stub(json: #"{"folder":\#(folderJSON)}"#) + _ = try await sut.createDocumentFolder(name: "Sub", parentId: "f1") + let body = bodyString() + XCTAssertTrue(body.contains("\"parentId\""), "expected camelCase parentId, got: \(body)") + XCTAssertFalse(body.contains("parent_id")) + } } diff --git a/InterlinedListTests/APIClientTests/APIClientEmailVerificationTests.swift b/InterlinedListTests/APIClientTests/APIClientEmailVerificationTests.swift new file mode 100644 index 0000000..acd6207 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientEmailVerificationTests.swift @@ -0,0 +1,123 @@ +import XCTest +@testable import InterlinedList + +final class APIClientEmailVerificationTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + // MARK: sendVerificationEmail + + func test_sendVerificationEmail_sendsCorrectPath() async throws { + session.stub(json: #"{"message":"sent"}"#) + try await sut.sendVerificationEmail() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/send-verification-email") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + } + + func test_sendVerificationEmail_sendsBearerToken() async throws { + session.stub(json: #"{"message":"sent"}"#) + try await sut.sendVerificationEmail() + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_sendVerificationEmail_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.sendVerificationEmail() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_sendVerificationEmail_500_throws() async throws { + session.stub(data: Data(), statusCode: 500) + do { + try await sut.sendVerificationEmail() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 500) + } + } + + // MARK: verifyEmail + + func test_verifyEmail_sendsCorrectPath() async throws { + session.stub(json: #"{"message":"verified"}"#) + try await sut.verifyEmail(token: "v-token") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/verify-email") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + } + + func test_verifyEmail_bodyContainsToken() async throws { + session.stub(json: #"{"message":"verified"}"#) + try await sut.verifyEmail(token: "v-token") + let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" + XCTAssertTrue(body.contains("\"token\":\"v-token\"")) + } + + func test_verifyEmail_doesNotSendBearerToken() async throws { + // verifyEmail is a verification step — works without an existing session. + session.stub(json: #"{"message":"verified"}"#) + try await sut.verifyEmail(token: "v-token") + XCTAssertNil(session.lastRequest?.value(forHTTPHeaderField: "Authorization")) + } + + // MARK: verifyEmailChange + + func test_verifyEmailChange_sendsCorrectPath() async throws { + session.stub(json: #"{"message":"ok"}"#) + try await sut.verifyEmailChange(token: "c-token") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/verify-email-change") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + } + + func test_verifyEmailChange_bodyContainsToken() async throws { + session.stub(json: #"{"message":"ok"}"#) + try await sut.verifyEmailChange(token: "c-token") + let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" + XCTAssertTrue(body.contains("\"token\":\"c-token\"")) + } + + // MARK: requestEmailChange + + func test_requestEmailChange_sendsCorrectPath() async throws { + session.stub(json: #"{"message":"check inbox"}"#) + try await sut.requestEmailChange(newEmail: "new@example.com", password: "pw") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/change-email/request") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + } + + func test_requestEmailChange_bodyUsesCamelCase() async throws { + session.stub(json: #"{"message":"check inbox"}"#) + try await sut.requestEmailChange(newEmail: "new@example.com", password: "pw") + let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" + XCTAssertTrue(body.contains("\"newEmail\":\"new@example.com\""), + "Body should use camelCase 'newEmail'. Got: \(body)") + XCTAssertFalse(body.contains("new_email"), + "Body must not snake_case 'newEmail'. Got: \(body)") + } + + func test_requestEmailChange_sendsBearerToken() async throws { + session.stub(json: #"{"message":"ok"}"#) + try await sut.requestEmailChange(newEmail: "new@e.com", password: "pw") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_requestEmailChange_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.requestEmailChange(newEmail: "new@e.com", password: "bad") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift b/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift new file mode 100644 index 0000000..00bae55 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift @@ -0,0 +1,280 @@ +import XCTest +@testable import InterlinedList + +/// Covers the APIClient surface added for the GAP roadmap phases: +/// follow lists, list watchers, public browse, organizations, structured schema, +/// notification preferences, message search, and scheduled-message editing. +final class APIClientGapPhasesTests: 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 { + String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" + } + + // MARK: - Phase 5: Follow lists + + func test_followers_getsPaginatedList() async throws { + session.stub(json: #"{"followers":[{"id":"u1","username":"alice","displayName":"Alice","avatar":null,"followId":"f1","status":"accepted","createdAt":"t"}],"pagination":{"total":1,"limit":30,"offset":0,"hasMore":false}}"#) + let (users, pagination) = try await sut.followers(userId: "me") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/follow/me/followers") == true) + XCTAssertEqual(users.first?.username, "alice") + XCTAssertEqual(pagination?.total, 1) + } + + func test_following_getsPaginatedList() async throws { + session.stub(json: #"{"following":[{"id":"u2","username":"bob","displayName":null,"avatar":null,"followId":"f2","status":"pending","createdAt":"t"}],"pagination":{"total":1,"limit":30,"offset":0,"hasMore":true}}"#) + let (users, _) = try await sut.following(userId: "me") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/follow/me/following") == true) + XCTAssertEqual(users.first?.displayNameOrUsername, "bob") + } + + func test_mutualCounts_decodes() async throws { + session.stub(json: #"{"mutualFollowers":4,"mutualFollowing":7}"#) + let counts = try await sut.mutualCounts(userId: "u1") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/follow/u1/mutual") == true) + XCTAssertEqual(counts.mutualFollowers, 4) + XCTAssertEqual(counts.mutualFollowing, 7) + } + + func test_removeFollower_sendsDelete() async throws { + session.stub(json: #"{"message":"removed"}"#) + try await sut.removeFollower(userId: "u1") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/follow/u1/remove") == true) + } + + // MARK: - Phase 6: Watchers + + func test_listWatchers_decodesRolesAndUsers() async throws { + session.stub(json: #"{"watchers":[{"id":"w1","userId":"u1","role":"manager","createdAt":"t","user":{"id":"u1","username":"alice","displayName":"Alice","avatar":null}}]}"#) + let watchers = try await sut.listWatchers(listId: "l1") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/lists/l1/watchers") == true) + XCTAssertEqual(watchers.first?.watcherRole, .manager) + XCTAssertEqual(watchers.first?.user?.username, "alice") + } + + func test_isWatchingList_decodesBool() async throws { + session.stub(json: #"{"watching":true}"#) + let watching = try await sut.isWatchingList(listId: "l1") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/lists/l1/watchers/me") == true) + XCTAssertTrue(watching) + } + + func test_addWatcher_postsUserIdAndRole() async throws { + session.stub(json: #"{"watching":true}"#, statusCode: 201) + let result = try await sut.addWatcher(listId: "l1", userId: "u9", role: .collaborator) + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/lists/l1/watchers") == true) + let body = bodyString() + XCTAssertTrue(body.contains("u9")) + XCTAssertTrue(body.contains("collaborator")) + XCTAssertTrue(result) + } + + func test_setWatcherRole_putsRole() async throws { + session.stub(json: #"{"role":"manager"}"#) + let role = try await sut.setWatcherRole(listId: "l1", userId: "u9", role: .manager) + XCTAssertEqual(session.lastRequest?.httpMethod, "PUT") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/lists/l1/watchers/u9") == true) + XCTAssertEqual(role, "manager") + } + + func test_removeWatcher_sendsDelete() async throws { + session.stub(json: #"{"removed":true}"#) + try await sut.removeWatcher(listId: "l1", userId: "u9") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/lists/l1/watchers/u9") == true) + } + + func test_searchWatcherCandidates_decodes() async throws { + session.stub(json: #"{"users":[{"id":"u1","username":"alice","displayName":"Alice","email":"a@x.com","avatar":null}],"total":1,"pagination":{"limit":20,"offset":0,"hasMore":false}}"#) + let users = try await sut.searchWatcherCandidates(listId: "l1") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/lists/l1/watchers/users") == true) + XCTAssertEqual(users.first?.email, "a@x.com") + } + + // MARK: - B0: Structured schema + + func test_updateListSchemaStructured_putsPropertiesArray() async throws { + session.stub(json: #"{"properties":[{"id":"p1","propertyKey":"title","propertyName":"Title","propertyType":"text","displayOrder":0,"isVisible":true,"isRequired":true,"defaultValue":null,"helpText":null,"placeholder":null}]}"#) + let input = [SchemaPropertyInput(id: "p1", propertyKey: "title", propertyName: "Title", propertyType: "text", displayOrder: 0, isVisible: true, isRequired: true, defaultValue: nil, helpText: nil, placeholder: nil)] + let props = try await sut.updateListSchemaStructured(listId: "l1", properties: input) + XCTAssertEqual(session.lastRequest?.httpMethod, "PUT") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/lists/l1/schema") == true) + XCTAssertTrue(bodyString().contains("properties")) + XCTAssertEqual(props.first?.propertyKey, "title") + } + + func test_updateListSchemaStructured_force_addsQueryParam() async throws { + session.stub(json: #"{"properties":[]}"#) + _ = try await sut.updateListSchemaStructured(listId: "l1", properties: [], force: true) + XCTAssertTrue(session.lastRequest?.url?.query?.contains("force=true") == true) + } + + func test_updateListSchemaStructured_409_throwsConflict() async throws { + session.stub(json: #"{"error":"column has data"}"#, statusCode: 409) + do { + _ = try await sut.updateListSchemaStructured(listId: "l1", properties: []) + XCTFail("Expected conflict") + } catch APIError.conflict(let msg) { + XCTAssertEqual(msg, "column has data") + } + } + + // MARK: - Phase 7: Public browse + + func test_publicListDetail_flatShape_decodes() async throws { + session.stub(json: #"{"id":"l1","title":"Books","description":"d","isPublic":true,"schema":"Title:text","owner":{"username":"alice","displayName":"Alice"}}"#) + let detail = try await sut.publicListDetail(username: "alice", listId: "l1") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/users/alice/lists/l1") == true) + XCTAssertEqual(detail.title, "Books") + XCTAssertEqual(detail.owner?.username, "alice") + } + + func test_publicListDetail_wrappedShape_decodes() async throws { + session.stub(json: #"{"list":{"id":"l1","title":"Books","children":[{"id":"l2","title":"Sub"}]},"ancestors":[{"id":"root","title":"Root"}]}"#) + let detail = try await sut.publicListDetail(username: "alice", listId: "l1") + XCTAssertEqual(detail.title, "Books") + XCTAssertEqual(detail.children?.first?.id, "l2") + XCTAssertEqual(detail.ancestors?.first?.title, "Root") + } + + func test_publicListData_decodesRows() async throws { + session.stub(json: #"{"rows":[{"id":"r1","rowData":{"title":"Dune"},"rowNumber":1,"createdAt":null}],"pagination":{"total":1,"limit":50,"offset":0,"hasMore":false}}"#) + let data = try await sut.publicListData(username: "alice", listId: "l1") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/users/alice/lists/l1/data") == true) + XCTAssertEqual(data.rows.count, 1) + } + + func test_publicDocuments_decodes() async throws { + session.stub(json: #"{"documents":[{"id":"d1","title":"Notes","folderId":null,"relativePath":"Notes","createdAt":null,"updatedAt":null}],"folders":[]}"#) + let response = try await sut.publicDocuments(username: "alice") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/users/alice/documents") == true) + XCTAssertEqual(response.documents.first?.title, "Notes") + } + + func test_publicDocument_wrappedOrBare_decodes() async throws { + session.stub(json: #"{"document":{"id":"d1","title":"Notes","content":"hello","folderId":null,"isPublic":true,"createdAt":null,"updatedAt":null}}"#) + let doc = try await sut.publicDocument(id: "d1") + XCTAssertEqual(doc.content, "hello") + } + + // MARK: - Phase 8: Organizations + + func test_organizations_decodesList() async throws { + session.stub(json: #"{"organizations":[{"id":"o1","name":"Acme"}],"pagination":null}"#) + let (orgs, _) = try await sut.organizations() + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/organizations") == true) + XCTAssertEqual(orgs.first?.name, "Acme") + } + + func test_organization_decodesRole() async throws { + session.stub(json: #"{"organization":{"id":"o1","name":"Acme","isPublic":false,"memberCount":3,"userRole":"owner"}}"#) + let org = try await sut.organization(id: "o1") + XCTAssertEqual(org.role, .owner) + XCTAssertEqual(org.memberCount, 3) + } + + func test_createOrganization_postsBody() async throws { + session.stub(json: #"{"organization":{"id":"o1","name":"Acme"}}"#, statusCode: 201) + _ = try await sut.createOrganization(name: "Acme", description: "d", isPublic: true) + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertTrue(bodyString().contains("Acme")) + } + + func test_updateOrganization_putsBody() async throws { + session.stub(json: #"{"ok":true}"#) + try await sut.updateOrganization(id: "o1", name: "New", description: nil, isPublic: nil) + XCTAssertEqual(session.lastRequest?.httpMethod, "PUT") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/organizations/o1") == true) + } + + func test_deleteOrganization_sendsDelete() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.deleteOrganization(id: "o1") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + } + + func test_organizationMembers_decodesRolesAndPagination() async throws { + session.stub(json: #"{"members":[{"id":"u1","username":"alice","displayName":"Alice","avatar":null,"emailVerified":true,"role":"owner","active":true,"joinedAt":"t"}],"pagination":{"total":1,"limit":50,"offset":0,"hasMore":false}}"#) + let (members, pagination) = try await sut.organizationMembers(id: "o1") + XCTAssertEqual(members.first?.orgRole, .owner) + XCTAssertEqual(pagination?.total, 1) + } + + func test_setOrganizationMemberRole_putsRole() async throws { + session.stub(json: #"{"ok":true}"#) + try await sut.setOrganizationMemberRole(id: "o1", userId: "u1", role: .admin) + XCTAssertEqual(session.lastRequest?.httpMethod, "PUT") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/organizations/o1/members/u1") == true) + XCTAssertTrue(bodyString().contains("admin")) + } + + func test_joinOrganization_postsId() async throws { + session.stub(json: #"{"ok":true}"#, statusCode: 201) + try await sut.joinOrganization(organizationId: "o1") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/user/organizations") == true) + XCTAssertTrue(bodyString().contains("o1")) + } + + // MARK: - Phase 12/13: Notification preferences + message search + + func test_notificationPreferences_decodesChannels() async throws { + session.stub(json: #"{"events":[{"key":"dig","label":"Digs","description":"d","channels":{"push":true,"inApp":false}}]}"#) + let events = try await sut.notificationPreferences() + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/user/notification-preferences") == true) + XCTAssertEqual(events.first?.channels.push, true) + XCTAssertEqual(events.first?.channels.inApp, false) + } + + func test_updateNotificationPreference_patchesBody() async throws { + session.stub(json: #"{"key":"dig","label":"Digs","description":"d","channels":{"push":false,"inApp":true}}"#) + let updated = try await sut.updateNotificationPreference(key: "dig", channels: NotificationChannels(push: false, inApp: true)) + XCTAssertEqual(session.lastRequest?.httpMethod, "PATCH") + XCTAssertTrue(bodyString().contains("dig")) + XCTAssertEqual(updated.channels.push, false) + } + + func test_searchMessages_getsWithQuery() async throws { + session.stub(json: #"{"messages":[],"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"#) + _ = try await sut.searchMessages(q: "swift ui") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/messages/search") == true) + XCTAssertTrue(session.lastRequest?.url?.query?.contains("q=") == true) + } + + // MARK: - Phase 4: Scheduled edit + cross-post + + func test_patchScheduledMessage_sendsPatch() async throws { + session.stub(json: #"{"data":{"id":"m1","content":"hi","userId":"u1","createdAt":"t"}}"#) + _ = try await sut.patchScheduledMessage(id: "m1", scheduledAt: "2026-07-01T10:00:00Z", config: nil) + XCTAssertEqual(session.lastRequest?.httpMethod, "PATCH") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/messages/m1") == true) + XCTAssertTrue(bodyString().contains("scheduledAt")) + } + + func test_postMessage_withCrossPost_includesResults() async throws { + session.stub(json: #"{"data":{"id":"m1","content":"hi","userId":"u1","createdAt":"t"},"crossPostResults":[{"platform":"bluesky","success":true,"error":null}]}"#, statusCode: 201) + let result = try await sut.postMessage(content: "hi", crossPostToBluesky: true) + XCTAssertTrue(bodyString().contains("crossPostToBluesky")) + XCTAssertEqual(result.crossPostResults.first?.platform, "bluesky") + XCTAssertEqual(result.crossPostResults.first?.success, true) + } + + func test_refreshMessageMetadata_decodesLinks() async throws { + session.stub(json: #"{"message":"ok","metadata":{"links":[{"url":"https://x.com","title":"X","description":"d","image":"i"}]}}"#) + let links = try await sut.refreshMessageMetadata(messageId: "m1") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/messages/m1/metadata") == true) + XCTAssertEqual(links.first?.title, "X") + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientIdentitiesTests.swift b/InterlinedListTests/APIClientTests/APIClientIdentitiesTests.swift new file mode 100644 index 0000000..823d7b9 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientIdentitiesTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import InterlinedList + +final class APIClientIdentitiesTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + // MARK: linkedIdentities + + func test_linkedIdentities_sendsCorrectPath() async throws { + session.stub(json: #"{"identities":[]}"#) + _ = try await sut.linkedIdentities() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/identities") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + } + + func test_linkedIdentities_decodesArray() async throws { + let json = """ + {"identities":[ + {"id":"a1","provider":"github","providerUsername":"octo","createdAt":"2026-01-01T00:00:00Z"}, + {"id":"b2","provider":"mastodon","providerUsername":"@me@mas.social","createdAt":null} + ]} + """ + session.stub(json: json) + let identities = try await sut.linkedIdentities() + XCTAssertEqual(identities.count, 2) + XCTAssertEqual(identities[0].provider, "github") + XCTAssertEqual(identities[0].providerUsername, "octo") + XCTAssertEqual(identities[1].provider, "mastodon") + XCTAssertNil(identities[1].createdAt) + } + + func test_linkedIdentities_403_throws() async throws { + session.stub(data: Data(), statusCode: 403) + do { + _ = try await sut.linkedIdentities() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } + } + + func test_linkedIdentities_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.linkedIdentities() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_linkedIdentities_emptyResponse_returnsEmptyArray() async throws { + session.stub(json: #"{}"#) + let identities = try await sut.linkedIdentities() + XCTAssertTrue(identities.isEmpty) + } + + // MARK: unlinkIdentity + + func test_unlinkIdentity_sendsDeleteWithBody() async throws { + session.stub(data: Data(), statusCode: 204) + try await sut.unlinkIdentity(provider: "github", providerId: "abc-123") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/identities") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" + XCTAssertTrue(body.contains("\"provider\":\"github\"")) + XCTAssertTrue(body.contains("\"providerId\":\"abc-123\""), + "Body must use camelCase providerId. Got: \(body)") + } + + func test_unlinkIdentity_sendsBearerToken() async throws { + session.stub(data: Data(), statusCode: 204) + try await sut.unlinkIdentity(provider: "github", providerId: "x") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_unlinkIdentity_403_throws() async throws { + session.stub(data: Data(), statusCode: 403) + do { + try await sut.unlinkIdentity(provider: "github", providerId: "x") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } + } + + // MARK: verifyIdentity + + func test_verifyIdentity_sendsCorrectPath() async throws { + session.stub(json: #"{"ok":true}"#) + try await sut.verifyIdentity(provider: "bluesky", providerId: "did:plc:abc") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/identities/verify") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + } + + func test_verifyIdentity_bodyUsesCamelCase() async throws { + session.stub(json: #"{"ok":true}"#) + try await sut.verifyIdentity(provider: "bluesky", providerId: "did:plc:abc") + let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" + XCTAssertTrue(body.contains("\"providerId\":\"did:plc:abc\""), "Got: \(body)") + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift b/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift index 4b0c01a..ba1dfb9 100644 --- a/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift @@ -37,9 +37,11 @@ final class APIClientImageUploadTests: XCTestCase { func test_uploadImage_pngUsesPngExtension() async throws { session.stub(json: #"{"url":"https://cdn.example.com/img.png"}"#) _ = try await sut.uploadImage(data: Data([0x89, 0x50]), mimeType: "image/png") + // The multipart body carries raw (non-UTF8) image bytes, so search the raw + // Data for the filename rather than decoding the whole body as a String. let body = session.lastRequest?.httpBody ?? Data() - let bodyString = String(data: body, encoding: .utf8) ?? "" - XCTAssertTrue(bodyString.contains("upload.png")) + XCTAssertNotNil(body.range(of: Data(#"filename="upload.png""#.utf8)), + "Multipart body should declare a .png filename") } func test_uploadImage_403_throws() async throws { diff --git a/InterlinedListTests/APIClientTests/APIClientOAuthStatusTests.swift b/InterlinedListTests/APIClientTests/APIClientOAuthStatusTests.swift new file mode 100644 index 0000000..475e6f3 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientOAuthStatusTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import InterlinedList + +final class APIClientOAuthStatusTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + } + + // MARK: linkedinStatus + + func test_linkedinStatus_sendsCorrectPath() async throws { + session.stub(json: #"{"configured":true,"redirectUri":"https://example.com/cb"}"#) + let status = try await sut.linkedinStatus() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/linkedin/status") + XCTAssertTrue(status.configured) + XCTAssertEqual(status.redirectUri, "https://example.com/cb") + } + + func test_linkedinStatus_configuredFalseWithNullRedirect() async throws { + session.stub(json: #"{"configured":false,"redirectUri":null}"#) + let status = try await sut.linkedinStatus() + XCTAssertFalse(status.configured) + XCTAssertNil(status.redirectUri) + } + + func test_linkedinStatus_500_throws() async throws { + session.stub(data: Data(), statusCode: 500) + do { + _ = try await sut.linkedinStatus() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 500) + } + } + + // MARK: twitterStatus + + func test_twitterStatus_sendsCorrectPath() async throws { + session.stub(json: #"{"configured":false,"redirectUri":null}"#) + let status = try await sut.twitterStatus() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/twitter/status") + XCTAssertFalse(status.configured) + } + + func test_twitterStatus_decodesConfigured() async throws { + session.stub(json: #"{"configured":true,"redirectUri":"https://x/cb"}"#) + let status = try await sut.twitterStatus() + XCTAssertTrue(status.configured) + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientOrganizationsTests.swift b/InterlinedListTests/APIClientTests/APIClientOrganizationsTests.swift new file mode 100644 index 0000000..7870bdd --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientOrganizationsTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import InterlinedList + +final class APIClientOrganizationsTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + func test_userOrganizations_sendsCorrectPath() async throws { + session.stub(json: #"{"organizations":[]}"#) + _ = try await sut.userOrganizations() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/user/organizations") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + } + + func test_userOrganizations_decodesArray() async throws { + let json = """ + {"organizations":[ + {"id":"o1","name":"Acme","description":"Co","isPublic":true}, + {"id":"o2","name":"Beta","description":null,"isPublic":false} + ]} + """ + session.stub(json: json) + let orgs = try await sut.userOrganizations() + XCTAssertEqual(orgs.count, 2) + XCTAssertEqual(orgs[0].name, "Acme") + XCTAssertEqual(orgs[0].isPublic, true) + XCTAssertNil(orgs[1].description) + } + + func test_userOrganizations_sendsBearerToken() async throws { + session.stub(json: #"{"organizations":[]}"#) + _ = try await sut.userOrganizations() + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_userOrganizations_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.userOrganizations() + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_userOrganizations_emptyResponse_returnsEmptyArray() async throws { + session.stub(json: #"{}"#) + let orgs = try await sut.userOrganizations() + XCTAssertTrue(orgs.isEmpty) + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientPasswordResetTests.swift b/InterlinedListTests/APIClientTests/APIClientPasswordResetTests.swift new file mode 100644 index 0000000..d3f3f10 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientPasswordResetTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import InterlinedList + +final class APIClientPasswordResetTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + } + + // MARK: forgotPassword + + func test_forgotPassword_sendsCorrectPath() async throws { + session.stub(json: #"{"message":"ok"}"#) + try await sut.forgotPassword(email: "a@b.com") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/forgot-password") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + } + + func test_forgotPassword_doesNotSendBearerToken() async throws { + sut.setBearerToken("tok") + session.stub(json: #"{"message":"ok"}"#) + try await sut.forgotPassword(email: "a@b.com") + XCTAssertNil(session.lastRequest?.value(forHTTPHeaderField: "Authorization")) + } + + func test_forgotPassword_bodyContainsEmail() async throws { + session.stub(json: #"{"message":"ok"}"#) + try await sut.forgotPassword(email: "a@b.com") + let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" + XCTAssertTrue(body.contains("\"email\":\"a@b.com\"")) + } + + func test_forgotPassword_serverErrorPropagates() async throws { + session.stub(json: #"{"error":"rate limited"}"#, statusCode: 500) + do { + try await sut.forgotPassword(email: "a@b.com") + XCTFail("Expected throw") + } catch APIError.server(let message) { + XCTAssertEqual(message, "rate limited") + } + } + + // MARK: resetPassword + + func test_resetPassword_sendsCorrectPath() async throws { + session.stub(json: #"{"message":"ok"}"#) + try await sut.resetPassword(token: "tok123", password: "newPass!") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/auth/reset-password") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + } + + func test_resetPassword_bodyContainsTokenAndPassword() async throws { + session.stub(json: #"{"message":"ok"}"#) + try await sut.resetPassword(token: "tok123", password: "newPass!") + let body = String(data: session.lastRequest?.httpBody ?? Data(), encoding: .utf8) ?? "" + XCTAssertTrue(body.contains("\"token\":\"tok123\"")) + XCTAssertTrue(body.contains("\"password\":\"newPass!\"")) + } + + func test_resetPassword_doesNotSendBearerToken() async throws { + sut.setBearerToken("tok") + session.stub(json: #"{"message":"ok"}"#) + try await sut.resetPassword(token: "abc", password: "pw") + XCTAssertNil(session.lastRequest?.value(forHTTPHeaderField: "Authorization")) + } + + func test_resetPassword_400_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 400) + do { + try await sut.resetPassword(token: "bad", password: "pw") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 400) + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientPeopleTests.swift b/InterlinedListTests/APIClientTests/APIClientPeopleTests.swift index 5668aa7..82a5097 100644 --- a/InterlinedListTests/APIClientTests/APIClientPeopleTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientPeopleTests.swift @@ -71,7 +71,10 @@ final class APIClientPeopleTests: XCTestCase { func test_publicLists_encodesSpecialCharacters() async throws { session.stub(json: #"{"lists":[]}"#) _ = try await sut.publicLists(username: "user name") - let path = session.lastRequest?.url?.path ?? "" - XCTAssertFalse(path.contains(" "), "Username with spaces must be percent-encoded") + // URL.path returns the percent-DECODED path, so inspect absoluteString to confirm + // the space was actually encoded on the wire. + let urlString = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertFalse(urlString.contains(" "), "Username with spaces must be percent-encoded") + XCTAssertTrue(urlString.contains("user%20name")) } } diff --git a/InterlinedListTests/APIClientTests/APIClientProfileTests.swift b/InterlinedListTests/APIClientTests/APIClientProfileTests.swift index add5315..15f8cd5 100644 --- a/InterlinedListTests/APIClientTests/APIClientProfileTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientProfileTests.swift @@ -28,7 +28,9 @@ final class APIClientProfileTests: XCTestCase { _ = 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]) - XCTAssertEqual(json["displayName"] as? String, "Alice") + // /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") } func test_updateProfile_returnsUser() async throws { diff --git a/InterlinedListTests/APIClientTests/APIClientVideoUploadTests.swift b/InterlinedListTests/APIClientTests/APIClientVideoUploadTests.swift index d267f9a..774a94a 100644 --- a/InterlinedListTests/APIClientTests/APIClientVideoUploadTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientVideoUploadTests.swift @@ -29,7 +29,10 @@ final class APIClientVideoUploadTests: XCTestCase { } func test_uploadVideo_403_throwsStatusError() async throws { - session.stub(json: #"{"error":"subscription required"}"#, statusCode: 403) + // A 403 with no decodable error body surfaces as `.status`; a 403 carrying a + // `{"error":...}` body surfaces as `.server(message)` (see checkResponse and the + // list-folder subscriber-403 test). This case exercises the status-error path. + session.stub(data: Data(), statusCode: 403) let data = Data("fake video bytes".utf8) do { _ = try await sut.uploadVideo(data: data, mimeType: "video/mp4") diff --git a/InterlinedListTests/E2E/E2EReadOnlyTests.swift b/InterlinedListTests/E2E/E2EReadOnlyTests.swift index 7371554..2c975b2 100644 --- a/InterlinedListTests/E2E/E2EReadOnlyTests.swift +++ b/InterlinedListTests/E2E/E2EReadOnlyTests.swift @@ -176,6 +176,19 @@ final class E2EReadOnlyTests: XCTestCase { } } + // MARK: - OAuth configuration status (read-only, unauthenticated) + + func test_e2e_linkedinStatus_returnsConfiguredField() async throws { + let status = try await client.linkedinStatus() + // Server may report either true or false; what matters is the decode succeeded. + _ = status.configured + } + + func test_e2e_twitterStatus_returnsConfiguredField() async throws { + let status = try await client.twitterStatus() + _ = status.configured + } + // MARK: - Bearer token rejected when stripped func test_e2e_unauthenticatedCall_returns401() async throws { diff --git a/InterlinedListTests/ModelTests/GapModelsTests.swift b/InterlinedListTests/ModelTests/GapModelsTests.swift new file mode 100644 index 0000000..99c66d4 --- /dev/null +++ b/InterlinedListTests/ModelTests/GapModelsTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import InterlinedList + +final class GapModelsTests: XCTestCase { + + // MARK: - ListSchemaDraft structured serialization (B0) + + func test_slugifyKey_producesSnakeCase() { + XCTAssertEqual(ListSchemaDraft.slugifyKey("Have Read?"), "have_read") + XCTAssertEqual(ListSchemaDraft.slugifyKey("Title"), "title") + XCTAssertEqual(ListSchemaDraft.slugifyKey(" Multi Word "), "multi_word") + XCTAssertEqual(ListSchemaDraft.slugifyKey("!!!"), "field") + } + + func test_structuredProperties_newPropertyOmitsIdAndSlugsKey() { + let drafts = [DraftProperty.newBlank()] + var draft = drafts[0] + draft.propertyName = "Author Name" + let result = ListSchemaDraft.structuredProperties([draft]) + XCTAssertEqual(result.count, 1) + XCTAssertNil(result[0].id, "New properties must omit id so the server creates them") + XCTAssertEqual(result[0].propertyKey, "author_name") + XCTAssertEqual(result[0].displayOrder, 0) + } + + func test_structuredProperties_existingKeepsIdAndKey() { + let def = ListPropertyDef(id: "p1", propertyKey: "title", propertyName: "Title", propertyType: "text", displayOrder: 0, isVisible: true, isRequired: true, defaultValue: nil, helpText: nil, placeholder: nil) + let result = ListSchemaDraft.structuredProperties([DraftProperty(from: def)]) + XCTAssertEqual(result[0].id, "p1") + XCTAssertEqual(result[0].propertyKey, "title") + } + + func test_structuredProperties_dropsEmptyNamesAndRenumbersOrder() { + var blank = DraftProperty.newBlank() + blank.propertyName = " " + let def = ListPropertyDef(id: "p2", propertyKey: "year", propertyName: "Year", propertyType: "number", displayOrder: 5, isVisible: true, isRequired: false, defaultValue: nil, helpText: nil, placeholder: nil) + let result = ListSchemaDraft.structuredProperties([blank, DraftProperty(from: def)]) + XCTAssertEqual(result.count, 1, "Empty-named drafts are dropped (soft-delete)") + XCTAssertEqual(result[0].displayOrder, 1, "displayOrder follows array index, not the original") + } + + // MARK: - WatcherRole + + func test_watcherRole_ordering_and_capabilities() { + XCTAssertTrue(WatcherRole.manager > WatcherRole.collaborator) + XCTAssertTrue(WatcherRole.collaborator > WatcherRole.watcher) + XCTAssertFalse(WatcherRole.watcher.canEditRows) + XCTAssertTrue(WatcherRole.collaborator.canEditRows) + XCTAssertTrue(WatcherRole.manager.canManage) + XCTAssertFalse(WatcherRole.collaborator.canManage) + } + + // MARK: - OrgRole + + func test_orgRole_ordering() { + XCTAssertTrue(OrgRole.owner > OrgRole.admin) + XCTAssertTrue(OrgRole.admin > OrgRole.member) + XCTAssertEqual(OrgRole(rawValue: "owner"), .owner) + XCTAssertNil(OrgRole(rawValue: "bogus")) + } + + // MARK: - NotificationPreference channel support + + func test_notificationPreference_supportFlags() { + let pushOnly = NotificationPreference(key: "follow", label: "Follow", description: nil, channels: NotificationChannels(push: true, inApp: nil)) + XCTAssertTrue(pushOnly.supportsPush) + XCTAssertFalse(pushOnly.supportsInApp) + + let both = NotificationPreference(key: "dig", label: "Dig", description: nil, channels: NotificationChannels(push: false, inApp: true)) + XCTAssertTrue(both.supportsPush) + XCTAssertTrue(both.supportsInApp) + } + + // MARK: - FollowUser display + + func test_followUser_displayNameFallsBackToUsername() { + let withName = FollowUser(id: "1", username: "alice", displayName: "Alice", avatar: nil, followId: nil, status: nil, createdAt: nil) + XCTAssertEqual(withName.displayNameOrUsername, "Alice") + let noName = FollowUser(id: "2", username: "bob", displayName: "", avatar: nil, followId: nil, status: nil, createdAt: nil) + XCTAssertEqual(noName.displayNameOrUsername, "bob") + } +} diff --git a/InterlinedListTests/ModelTests/LinkedIdentityModelTests.swift b/InterlinedListTests/ModelTests/LinkedIdentityModelTests.swift new file mode 100644 index 0000000..045acf3 --- /dev/null +++ b/InterlinedListTests/ModelTests/LinkedIdentityModelTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import InterlinedList + +final class LinkedIdentityModelTests: XCTestCase { + func test_decode_fullObject() throws { + let json = #"{"id":"a1","provider":"github","providerUsername":"octo","createdAt":"2026-01-01T00:00:00Z"}"# + let identity = try JSONDecoder().decode(APIClient.LinkedIdentity.self, from: Data(json.utf8)) + XCTAssertEqual(identity.id, "a1") + XCTAssertEqual(identity.provider, "github") + XCTAssertEqual(identity.providerUsername, "octo") + XCTAssertEqual(identity.createdAt, "2026-01-01T00:00:00Z") + } + + func test_decode_nullProviderUsername() throws { + let json = #"{"id":"a1","provider":"bluesky","providerUsername":null,"createdAt":null}"# + let identity = try JSONDecoder().decode(APIClient.LinkedIdentity.self, from: Data(json.utf8)) + XCTAssertNil(identity.providerUsername) + XCTAssertNil(identity.createdAt) + } + + func test_roundTrip() throws { + let original = APIClient.LinkedIdentity( + id: "a1", + provider: "github", + providerUsername: "octocat", + createdAt: "2026-01-01T00:00:00Z" + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(APIClient.LinkedIdentity.self, from: data) + XCTAssertEqual(decoded.id, original.id) + XCTAssertEqual(decoded.provider, original.provider) + XCTAssertEqual(decoded.providerUsername, original.providerUsername) + XCTAssertEqual(decoded.createdAt, original.createdAt) + } +} diff --git a/InterlinedListTests/ModelTests/OrganizationModelTests.swift b/InterlinedListTests/ModelTests/OrganizationModelTests.swift new file mode 100644 index 0000000..fd47436 --- /dev/null +++ b/InterlinedListTests/ModelTests/OrganizationModelTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import InterlinedList + +final class OrganizationModelTests: XCTestCase { + func test_decode_fullObject() throws { + let json = #"{"id":"o1","name":"Acme","description":"Hello","isPublic":true}"# + let org = try JSONDecoder().decode(Organization.self, from: Data(json.utf8)) + XCTAssertEqual(org.id, "o1") + XCTAssertEqual(org.name, "Acme") + XCTAssertEqual(org.description, "Hello") + XCTAssertEqual(org.isPublic, true) + } + + func test_decode_nullDescription() throws { + let json = #"{"id":"o1","name":"Acme","description":null,"isPublic":false}"# + let org = try JSONDecoder().decode(Organization.self, from: Data(json.utf8)) + XCTAssertNil(org.description) + XCTAssertEqual(org.isPublic, false) + } + + func test_decode_missingOptionalsTreatsAsNil() throws { + let json = #"{"id":"o1","name":"Acme"}"# + let org = try JSONDecoder().decode(Organization.self, from: Data(json.utf8)) + XCTAssertNil(org.description) + XCTAssertNil(org.isPublic) + } + + func test_roundTrip() throws { + let original = Organization(id: "o1", name: "Acme", description: "x", isPublic: true) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Organization.self, from: data) + XCTAssertEqual(decoded.id, original.id) + XCTAssertEqual(decoded.name, original.name) + XCTAssertEqual(decoded.description, original.description) + XCTAssertEqual(decoded.isPublic, original.isPublic) + } +} diff --git a/README.md b/README.md index 8bcd088..7458ca3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # InterlinedList iOS -iPhone app for [InterlinedList](https://interlinedlist.com): sign in with email/password (sync token), view the feed, post and reply to messages, and switch between feed, lists, documents, and profile via a top navigation bar. +Native iOS app for [InterlinedList](https://interlinedlist.com) — a social list-sharing service. Sign in, browse a message feed, post and reply, and manage nested lists, documents, organizations, and your social graph. Built in SwiftUI with no third-party dependencies. ## Requirements @@ -15,36 +15,73 @@ iPhone app for [InterlinedList](https://interlinedlist.com): sign in with email/ ## Navigation -A **top bar** (tab-style) runs across the app with four items, left to right: +A **top bar** runs across the app with four sections plus a notifications bell: -- **Home** – Messages feed. Tap the pencil in the toolbar to compose a new post. -- **Lists** – Placeholder (lists not yet implemented in the app). -- **Documents** – Placeholder (documents not yet implemented in the app). -- **Profile** – User avatar and display name; **Log out** is here. +- **Home** — Messages feed; tap the pencil to compose. +- **Lists** — Your lists and folders. +- **Documents** — Your documents and folders. +- **Profile** — Avatar, account details, social links, settings (gear), and Log out. +- **🔔 Bell** — Notifications, with a badge for unread notifications and pending follow requests. + +An email-verification banner appears beneath the top bar until the account's email is verified. ## Features -- **Login / Register** – Email and password; token is stored in Keychain so you stay logged in. -- **Feed (Home)** – Messages from the site with pull-to-refresh and infinite scroll. Each message shows: - - Author, date, content, and a lock icon when the message is private. - - Optional **previews** (link cards, images, video) with a “Show previews” toggle at the top of the feed. - - **Reply** and **Delete** (Delete only for your own messages). Reply opens a sheet to post a reply. -- **Compose** – Opened from the feed via the pencil button. Text posts with: - - **Public** toggle (default comes from your account setting). - - **Character count** from your account’s max message length. - - **Advanced bar** (gear): toggles a row of icons (image, video, Mastodon, Bluesky, LinkedIn, calendar). Bar visibility default comes from your “Show advanced post settings” setting. Icon actions are not implemented yet. -- **Profile** – Avatar, display name, username, and Log out. +### Accounts & auth +- **Email / password** sign-in (issues a sync token stored in the Keychain) and registration. +- **Forgot / reset password** and **email verification**, reachable via `interlinedlist://` deep links. +- **OAuth sign-in** via the system browser (`ASWebAuthenticationSession`) for Mastodon, Bluesky, LinkedIn, and Twitter. *(GitHub is hidden pending backend support for the native callback.)* +- **Linked accounts** management (subscribers). +- **Change email** and **delete account** from Settings. + +### Feed & messages +- Feed with pull-to-refresh and infinite scroll; cached data renders instantly on launch, then refreshes. +- Each message shows author, date, content, a lock icon when private, and optional **previews** (link cards, images, video) behind a "Show previews" toggle. +- **Reply**, **edit**, and **delete** (your own messages); threaded conversation view. + +### Compose +- **Public** toggle (default from your account setting) and a live **character count** (your account's max length). +- **Subscriber features**: image and video upload, **scheduled posts**, and cross-posting to linked Mastodon / Bluesky / LinkedIn accounts. + +### Lists & documents +- Browse lists and documents as a tree; **search** within each. +- Create lists and documents; edit a list's **schema** (its columns/fields) and rows. +- **Nested folders** organize both (subscriber feature; free accounts see a flat view). +- List **connections** and **watchers**; public list/document detail views. + +### Social & organizations +- **Followers / following**, **follow requests** (accept/decline). +- **Organizations** with member management. +- View other users' profiles. + +### Settings & preferences +- Edit profile (display name, bio, avatar), theme (light/dark/system, applied app-wide), default post visibility, advanced-post-settings toggle, and notification preferences. ## Configuration -- **API base URL** – The app uses `https://interlinedlist.com` by default. To use another instance (e.g. local or staging), set the `ILAPIBaseURL` key in `Info.plist` to the base URL (e.g. `http://localhost:3000`). Leave it empty to use production. +- **API base URL** — defaults to `https://interlinedlist.com`. To target another instance (local/staging), set `ILAPIBaseURL` in `Info.plist` (e.g. `http://localhost:3000`). Leave it empty for production. + +## Testing + +- **Unit tests** stub the network through `MockURLSession` — no connectivity required. +- **End-to-end tests** (`InterlinedListTests/E2E`) run **read-only** checks against the live API. They auto-skip unless credentials are provided via the Xcode scheme's Test action environment, or a gitignored `.env` at the repo root: + ``` + INTERLINEDLIST_EMAIL=you@example.com + INTERLINEDLIST_PASSWORD=... + ``` + +Run from Xcode (⌘U) or via `xcodebuild` — see `CLAUDE.md` for command-line invocations and a note on pinning a simulator UDID. ## Known console messages (safe to ignore) -These come from the system or simulator, not from app logic. They do not indicate bugs in this app. +These come from the system or simulator, not from app logic, and do not indicate bugs: + +- **"Error creating the CFMessagePort needed to communicate with PPT"** — Apple's internal PPT in UIKit; a known simulator/device message. +- **"Failed to send CA Event … FirstFramePresentationMetric"** — system launch metrics occasionally failing in the simulator. +- **"[RTIInputSystemClient …] perform input operation requires a valid sessionID"** — system text-input/emoji (RTI) logging during transitions. The app dismisses the keyboard before presenting sheets to reduce this. +- **"Unable to simultaneously satisfy constraints"** involving **SystemInputAssistantView** / **UIRemoteKeyboardPlaceholderView** / **assistantHeight** — system keyboard/input-assistant UI; iOS recovers by breaking a constraint. +- **"nw_endpoint_flow_failed_with_error"**, **"nw_connection_copy_*"**, **"Socket is not connected"** — low-level Network framework logs from cancelled/failed connections. Safe to ignore unless the app's own API calls are failing in the UI. + +## Project guide -- **"Error creating the CFMessagePort needed to communicate with PPT"** — Apple’s internal PPT in UIKit. Known simulator/device message; doesn’t affect behavior. -- **"Failed to send CA Event for app launch measurements … FirstFramePresentationMetric"** — System launch metrics sometimes fail in simulator. Safe to ignore. -- **"[RTIInputSystemClient …] perform input operation requires a valid sessionID"** — System text input/emoji (RTI) can log this when the keyboard is involved during a transition. The app dismisses the keyboard before presenting reply/delete so this is less likely; if it still appears, it’s system-only and safe to ignore. -- **"Unable to simultaneously satisfy constraints"** involving **SystemInputAssistantView**, **UIRemoteKeyboardPlaceholderView**, **assistantHeight** — These are in the system keyboard/input assistant UI. iOS recovers by breaking a constraint; no app fix. Dismissing the keyboard before opening sheets/alerts can reduce how often it happens. -- **"nw_endpoint_flow_failed_with_error"**, **"nw_connection_copy_*"**, **"Socket is not connected"** — Low-level Network framework logs from failed or cancelled connections (e.g. network unreachable, request cancelled). Can appear when the system or app cancels requests. Safe to ignore unless the app’s own API calls are failing in the UI. +See `CLAUDE.md` for architecture, conventions, and build/test commands. diff --git a/subscription-permissions-update.md b/subscription-permissions-update.md index ff9bc93..d56e685 100644 --- a/subscription-permissions-update.md +++ b/subscription-permissions-update.md @@ -108,24 +108,22 @@ A user could have created folders, scheduled posts, or set up cross-posting while subscribed, then let their subscription lapse. The server still has their data. -**Default behavior:** their existing folders/scheduled posts/etc. -still render (read-only or view-only). They just can't create new ones. - -This needs explicit decisions: - -- **Existing folders:** show them in the tree (they're returned by - `GET /api/folders`). Allow rename/delete? Or just show? - **Recommendation:** show as read-only — they can move lists out and - delete the folder once empty. No "rename" UI for free users. -- **Scheduled posts:** the calendar button is hidden, so they can't - reach the scheduled view from the UI. But scheduled posts will still - fire on the server. **Recommendation:** that's fine — the server - honors what was already scheduled. -- **Existing cross-post connections:** identities are linked at the - account level (`/api/user/identities`). They stay linked but can't - be triggered for new posts. **Recommendation:** Phase 2 identity - management UI gates linking new ones on subscriber status too; the - list itself is informational. +**Resolved direction:** hide everything they don't have access to. +Their data isn't deleted — the server still honors it — but the iOS +UI surfaces nothing. + +- **Existing folders:** filter out from the displayed tree. Lists that + were inside those folders surface at root. Implementation point: in + `ListsView` and `DocumentsView`, gate the folder fetch + render on + `authState.user?.isSubscriber == true`. +- **Scheduled posts:** calendar entry point in `FeedView` toolbar is + hidden. `ScheduledMessagesView` is unreachable. Server still fires + the scheduled posts on time — that's a feature, not a bug. +- **Existing cross-post identities:** Phase 2 identity-management UI + is itself gated on subscriber status. If a free user has linked + identities from when they were a subscriber, the management UI + doesn't render and they see no indication. (Backend continues to + honor whatever credentials exist on the account record.) ### 2. Unauthenticated state @@ -196,27 +194,22 @@ literally nothing subscription-related in the iOS bundle to review. - **Delete.** No `/api/subscriptions/plans` endpoint needed from backend. Lower the backend team's pending-asks pile. -## Open questions for the user - -1. **Degraded-subscriber UX**: confirm the recommendation above - (existing folders show read-only, scheduled posts honor on server, - linked identities stay linked but new cross-posts blocked). Or - different stance? -2. **Empty-state copy when a free user looks at a screen that USED to - show subscriber features**: for example, if we hide the "New Folder" - button entirely, do we want any text explaining "Folders are - available with a subscription, manage at interlinedlist.com" — or - pure silence (the principle of "the feature doesn't exist")? The - strict reading of the new principle says silence; some users may be - confused. -3. **Settings → About / Help links**: still appropriate to link out to - `interlinedlist.com/help/*` etc. via `SFSafariViewController`? - That's not commerce, but it is "web app handles X." Probably yes, - but worth confirming. -4. **Onboarding / first-launch screen**: when a brand-new user opens - the iOS app, do we want any "to upgrade visit interlinedlist.com" - text anywhere — first-launch tooltip, About screen — or zero - mentions anywhere in the bundle? Strict reading: zero. +## Resolved decisions (2026-06-24) + +1. **Degraded subscribers**: hide everything they don't have access to, + including any folders / scheduled posts / cross-post connections + they created when subscribed. Server still honors what's there + (scheduled posts fire on schedule); iOS just doesn't expose any UI + for it. Existing folders that contain lists are not shown — lists + inside them appear at root. +2. **Empty-state copy**: **strict silence**. No "available with + subscription" text anywhere. This is also the Apple-policy-aligned + stance for a free iOS app that doesn't offer IAP. +3. **Help / About links**: opening `interlinedlist.com/help/*` via + `SFSafariViewController` is fine. Help isn't commerce. +4. **Onboarding / About**: **no mention of subscriptions anywhere in + the iOS bundle** at this time. No first-launch tooltip, no About + text, nothing. ## Suggested execution order once approved