From 6eeb0598758209c6ac88f26bcfceb4c487514ac0 Mon Sep 17 00:00:00 2001 From: Ronan Hevenor Date: Wed, 16 Sep 2026 21:34:25 -0400 Subject: [PATCH 1/2] feat(ios): Capacitor iOS shell, iOS build workflow, FCM for iOS, header wave fleet, print rule Co-Authored-By: Claude Opus 5 --- .github/workflows/android-build.yml | 7 +- .github/workflows/ios-build.yml | 92 ++++ CLAUDE.md | 12 +- components/HeaderClient.tsx | 56 +-- docs/architecture.md | 5 +- docs/local-development.md | 12 +- docs/push-notifications.md | 42 +- lib/fcm.ts | 3 + lib/headerWaveFleet.ts | 69 +++ mobile/.gitignore | 1 + mobile/README.md | 161 ++++++- mobile/capacitor.config.ts | 12 +- mobile/ios/.gitignore | 13 + mobile/ios/App/App.xcodeproj/project.pbxproj | 441 ++++++++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 132 ++++++ mobile/ios/App/App/App.entitlements | 12 + mobile/ios/App/App/AppDelegate.swift | 23 + .../AppIcon.appiconset/AppIcon-512@2x.png | Bin 0 -> 22563 bytes .../AppIcon.appiconset/Contents.json | 14 + .../ios/App/App/Assets.xcassets/Contents.json | 6 + .../LaunchBackground.colorset/Contents.json | 38 ++ .../LaunchLogo.imageset/Contents.json | 23 + .../LaunchLogo.imageset/launch-logo.png | Bin 0 -> 2336 bytes .../LaunchLogo.imageset/launch-logo@2x.png | Bin 0 -> 4951 bytes .../LaunchLogo.imageset/launch-logo@3x.png | Bin 0 -> 7544 bytes .../App/Base.lproj/LaunchScreen.storyboard | 41 ++ mobile/ios/App/App/ExternalLinksPlugin.swift | 42 ++ mobile/ios/App/App/Info.plist | 70 +++ mobile/ios/App/App/MainViewController.swift | 411 ++++++++++++++++ mobile/ios/App/App/PushRegistration.swift | 125 +++++ mobile/ios/App/App/SceneDelegate.swift | 47 ++ .../ios/App/App/SiteHostViewController.swift | 42 ++ mobile/ios/App/App/SiteTabBarController.swift | 162 +++++++ mobile/ios/App/CapApp-SPM/.gitignore | 9 + mobile/ios/App/CapApp-SPM/Package.swift | 33 ++ mobile/ios/App/CapApp-SPM/README.md | 8 + .../Sources/CapApp-SPM/CapApp-SPM.swift | 1 + mobile/package.json | 9 +- mobile/pnpm-lock.yaml | 186 ++++---- public/print/header-rule-preview.html | 37 ++ public/print/header-rule.svg | 10 + scripts/generate-print-rule.ts | 79 ++++ 43 files changed, 2324 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/ios-build.yml create mode 100644 lib/headerWaveFleet.ts create mode 100644 mobile/ios/.gitignore create mode 100644 mobile/ios/App/App.xcodeproj/project.pbxproj create mode 100644 mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 mobile/ios/App/App/App.entitlements create mode 100644 mobile/ios/App/App/AppDelegate.swift create mode 100644 mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png create mode 100644 mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 mobile/ios/App/App/Assets.xcassets/Contents.json create mode 100644 mobile/ios/App/App/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/Contents.json create mode 100644 mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo.png create mode 100644 mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo@2x.png create mode 100644 mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo@3x.png create mode 100644 mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard create mode 100644 mobile/ios/App/App/ExternalLinksPlugin.swift create mode 100644 mobile/ios/App/App/Info.plist create mode 100644 mobile/ios/App/App/MainViewController.swift create mode 100644 mobile/ios/App/App/PushRegistration.swift create mode 100644 mobile/ios/App/App/SceneDelegate.swift create mode 100644 mobile/ios/App/App/SiteHostViewController.swift create mode 100644 mobile/ios/App/App/SiteTabBarController.swift create mode 100644 mobile/ios/App/CapApp-SPM/.gitignore create mode 100644 mobile/ios/App/CapApp-SPM/Package.swift create mode 100644 mobile/ios/App/CapApp-SPM/README.md create mode 100644 mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift create mode 100644 public/print/header-rule-preview.html create mode 100644 public/print/header-rule.svg create mode 100644 scripts/generate-print-rule.ts diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 3a73967..ef31072 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -15,6 +15,8 @@ on: branches: [ "main" ] paths: - "mobile/**" + # iOS-only changes shouldn't cut an Android release. + - "!mobile/ios/**" - ".github/workflows/android-build.yml" tags: - "v*.*.*-android" @@ -103,9 +105,12 @@ jobs: - name: Install root dependencies run: pnpm install --frozen-lockfile + # --ignore-workspace: the repo-root pnpm-workspace.yaml otherwise makes + # this install the root project and leaves mobile/node_modules empty, + # which fails the `npx cap sync` step below. - name: Install mobile dependencies working-directory: mobile - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-workspace - name: Write google-services.json env: diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml new file mode 100644 index 0000000..5b97665 --- /dev/null +++ b/.github/workflows/ios-build.yml @@ -0,0 +1,92 @@ +name: iOS Build + +# Compile check for the Capacitor iOS shell: builds the App target for the +# iOS Simulator without code signing, so changes under mobile/ surface Swift, +# Xcode project, and Swift Package resolution breakage without anyone opening +# Xcode. There is no release lane yet; TestFlight uploads need an Apple +# Developer team plus signing secrets. See mobile/README.md. + +on: + pull_request: + paths: + - "mobile/**" + - "!mobile/android/**" + - ".github/workflows/ios-build.yml" + push: + branches: [ "main" ] + paths: + - "mobile/**" + - "!mobile/android/**" + - ".github/workflows/ios-build.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build for iOS Simulator + runs-on: macos-latest + timeout-minutes: 45 + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + # Version comes from the "packageManager" field in package.json. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: mobile/pnpm-lock.yaml + + # --ignore-workspace: the repo-root pnpm-workspace.yaml otherwise makes + # this install the root project instead of mobile/. + - name: Install mobile dependencies + working-directory: mobile + run: pnpm install --frozen-lockfile --ignore-workspace + + - name: Write GoogleService-Info.plist (if provided) + env: + GOOGLE_SERVICE_INFO_PLIST_BASE64: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST_BASE64 }} + run: | + set -euo pipefail + + if [[ -n "${GOOGLE_SERVICE_INFO_PLIST_BASE64:-}" ]]; then + echo "Using GOOGLE_SERVICE_INFO_PLIST_BASE64 secret" + echo "${GOOGLE_SERVICE_INFO_PLIST_BASE64}" | base64 --decode > mobile/ios/App/App/GoogleService-Info.plist + else + echo "Secret not set; building with push registration disabled" + fi + + - name: Sync Capacitor to iOS + working-directory: mobile + run: npx cap sync ios + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: mobile/ios/App/build/SourcePackages + key: spm-${{ runner.os }}-${{ hashFiles('mobile/ios/App/CapApp-SPM/Package.swift', 'mobile/ios/App/App.xcodeproj/project.pbxproj', 'mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: | + spm-${{ runner.os }}- + + - name: Build (iOS Simulator, unsigned) + working-directory: mobile/ios/App + run: | + set -euo pipefail + + xcodebuild -version + xcodebuild \ + -project App.xcodeproj \ + -scheme App \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/CLAUDE.md b/CLAUDE.md index 1704918..4e3f3eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This document is the canonical project + operations reference for Claude Code in ## Project Overview -Polymer is The Polytechnic's web platform (public newspaper site + Payload CMS admin) built on Next.js + Payload + PostgreSQL, with a Capacitor Android shell that wraps the production site and receives FCM breaking-news pushes. +Polymer is The Polytechnic's web platform (public newspaper site + Payload CMS admin) built on Next.js + Payload + PostgreSQL, with Capacitor Android and iOS shells that wrap the production site and receive FCM breaking-news pushes. **This project is live in production with a real production database. Exercise caution with schema changes, migrations, and any destructive operations.** @@ -30,10 +30,10 @@ For deeper architectural context, see [`docs/`](docs/) (architecture, data model - `components/`: UI + article layout + dashboard components - `lib/`: server helpers (PostHog, FCM, theme, archive query, weather, homepage slot resolution) - `migrations/`: Payload-format TypeScript migrations registered via `migrations/index.ts` -- `mobile/`: Capacitor Android shell (separate `package.json`) +- `mobile/`: Capacitor Android + iOS shells (separate `package.json`; install with `pnpm install --ignore-workspace`) - `scripts/`: deploy/runtime scripts (`run_deploy_sql_migrations.sh`, `deploy-smoke.mjs`, `generate-env.js`) - `middleware.ts`: returns `410 Gone` for matching article URLs whose row is unpublished -- `.github/workflows/`: CI, production deploy, and Android release workflows +- `.github/workflows/`: CI, production deploy, Android release, and iOS simulator build workflows ## Core Behavior @@ -60,7 +60,7 @@ Collections: - `submissions`: public op-ed / letter submissions (anonymous create, staff triage) - `event-submissions`: public event submissions for the calendar - `logos`: branded section logos and homepage assets -- `device-tokens`: registered Android FCM tokens (anonymous create via `/api/push/register`, admin-only read/delete) +- `device-tokens`: registered Android and iOS FCM tokens (anonymous create via `/api/push/register`, admin-only read/delete) Globals: @@ -211,9 +211,9 @@ Mixing PM2 users creates split daemons/process lists and inconsistent runtime ow ## Push Notifications (Breaking News) -- registration: `POST /api/push/register` (Android client; in-memory rate limit + token de-dupe) +- registration: `POST /api/push/register` (Android + iOS clients; in-memory rate limit + token de-dupe) - fan-out: `POST /api/push/send` (internal; requires `x-internal-secret` matching `INTERNAL_PUSH_SECRET`) -- transport: FCM HTTP v1 via `lib/fcm.ts` using `FCM_SERVICE_ACCOUNT_JSON` +- transport: FCM HTTP v1 via `lib/fcm.ts` using `FCM_SERVICE_ACCOUNT_JSON`; iOS devices register FCM tokens too (APNs → FCM swap in the app), so there is no separate APNs sender - trigger: `Articles.afterChange` when an article transitions to published with `breakingNews=true` - if `INTERNAL_PUSH_SECRET` or `FCM_SERVICE_ACCOUNT_JSON` is unset, the fan-out becomes a no-op so dev/CI is unaffected diff --git a/components/HeaderClient.tsx b/components/HeaderClient.tsx index cb69d11..f958c16 100644 --- a/components/HeaderClient.tsx +++ b/components/HeaderClient.tsx @@ -17,63 +17,9 @@ import { import { useTheme } from "@/components/ThemeProvider"; import type { ThemeLogoSrcs, HeaderAnimationConfig } from "@/lib/getTheme"; import LiveStrip, { type LiveArticleStripEntry } from "@/components/LiveStrip"; +import { HEADER_WAVE_CONVERGE, HEADER_WAVE_SVG_H, generateWaveFleet } from "@/lib/headerWaveFleet"; const HOME_DARK_MODE_PROMPT_COOKIE = "home-dark-mode-prompt-seen"; -// Header wave fleet: waves fan out from a single start point, converge back at the end. -const HEADER_WAVE_LAMBDA = 320; -const HEADER_WAVE_SVG_H = 16; -const HEADER_WAVE_CONVERGE = 4 * HEADER_WAVE_LAMBDA; // 1280px — where waves fully pinch - -function generateWaveFleet(count: number) { - const half = HEADER_WAVE_LAMBDA / 2; - const cp = Math.round(0.3642 * half); - const baseline = HEADER_WAVE_SVG_H / 2; - const startX = -4; - const rampUp = HEADER_WAVE_LAMBDA * 0.6; - const rampDown = HEADER_WAVE_LAMBDA * 1.2; - const convergeEndX = startX + HEADER_WAVE_CONVERGE; - const maxHalves = Math.ceil(HEADER_WAVE_CONVERGE / half) + 2; - - const envelope = (x: number) => { - const t = x - startX; - if (t <= 0) return 0; - if (t < rampUp) return t / rampUp; - if (t < HEADER_WAVE_CONVERGE - rampDown) return 1; - if (t < HEADER_WAVE_CONVERGE) return (HEADER_WAVE_CONVERGE - t) / rampDown; - return 0; - }; - - const n = Math.max(1, Math.min(8, Math.round(count))); - const margin = 1.5; - const usableH = HEADER_WAVE_SVG_H - 2 * margin; - - const specs = Array.from({ length: n }, (_, i) => { - const t = n === 1 ? 0.5 : i / (n - 1); - const cy = margin + t * usableH; - const dist = Math.abs(t - 0.5) * 2; // 0 at center, 1 at edges - return { cy, A: 4.6 - dist * 1.4, opacity: 1 - dist * 0.6, delay: dist * 0.1 }; - }); - - return specs.map(({ cy, A, opacity, delay }) => { - let d = `M ${startX},${baseline}`; - for (let k = 0; k < maxHalves; k++) { - const x0 = startX + k * half; - const x1 = startX + (k + 1) * half; - if (x0 >= convergeEndX) break; - const e0 = envelope(x0); - const e1 = envelope(x1); - const eMid = envelope((x0 + x1) / 2); - const y0 = baseline + (cy - baseline) * e0; - const y1 = baseline + (cy - baseline) * e1; - const peakA = A * eMid; - const sign = k % 2 === 0 ? -1 : 1; - d += ` C ${x0 + cp},${y0 + sign * peakA} ${x1 - cp},${y1 + sign * peakA} ${x1},${y1}`; - } - d += ` L ${convergeEndX},${baseline}`; - return { d, opacity, delay }; - }); -} - function formatCurrentDate() { return new Date().toLocaleDateString("en-US", { weekday: "long", diff --git a/docs/architecture.md b/docs/architecture.md index f43851b..c1c3e74 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,7 @@ It talks to two PostgreSQL databases: A self-hosted PostHog instance (`t.poly.rpi.edu`) receives privacy-filtered analytics; FCM HTTP v1 is the transport for breaking-news pushes to the -Android app. +Android and iOS apps. ``` ┌─────────────────────────────────────────────────┐ @@ -147,4 +147,5 @@ internal HTTP call back into the app, which CodeQL flagged as SSRF. | PM2 runtime | [`ecosystem.config.cjs`](../ecosystem.config.cjs) | | CI | [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) | | Deploy | [`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) | -| Android | [`mobile/`](../mobile/) | +| Android | [`mobile/android/`](../mobile/android/) | +| iOS | [`mobile/ios/`](../mobile/ios/), [`.github/workflows/ios-build.yml`](../.github/workflows/ios-build.yml) | diff --git a/docs/local-development.md b/docs/local-development.md index 2e4f2f5..767d382 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -9,7 +9,8 @@ How to get a working dev loop, plus the gotchas we keep tripping on. - PostgreSQL 14+ (CI uses 16; either works locally) > Note: `mobile/` is excluded from the root `tsconfig.json` and has its own -> `package.json`. You don't need the Android SDK to work on the web app. +> `package.json`. You don't need the Android SDK or Xcode to work on the web +> app. ## First-time setup @@ -121,12 +122,15 @@ archive side just becomes empty. is sending. Both sides read the same env var; restart the dev server after editing `.env`. -### Android emulator can't reach the dev server +### Android emulator or iOS Simulator can't reach the dev server -The Capacitor shell points at `poly.rpi.edu` by default. To test against a +The Capacitor shells point at `poly.rpi.edu` by default. To test against a local backend, edit `mobile/capacitor.config.ts` server.url to your LAN-accessible host (e.g. `http://10.0.2.2:3000` for the standard Android -emulator). +emulator; the iOS Simulator shares the Mac's network, so +`http://localhost:3000` works there), then re-run `npx cap sync`. Plain +`http://` also needs `cleartext: true` on Android and an App Transport +Security exception on iOS. ### "duplicate key value" during migration diff --git a/docs/push-notifications.md b/docs/push-notifications.md index edc9c98..7bffffb 100644 --- a/docs/push-notifications.md +++ b/docs/push-notifications.md @@ -1,16 +1,17 @@ # Push Notifications -Breaking-news pushes from Payload to the Android app, end to end. +Breaking-news pushes from Payload to the Android and iOS apps, end to end. ## Components ``` -Android app ──► POST /api/push/register (saves token in `device-tokens`) +Android / iOS app ──► POST /api/push/register (saves token in `device-tokens`) Article publish (breakingNews=true) - ──► Articles.afterChange (article hook) - ──► POST /api/push/send (with x-internal-secret) - ──► lib/fcm.ts → FCM HTTP v1 (fan-out to all device-tokens) - └──► Android device shows notification + ──► Articles.afterChange (article hook) + ──► POST /api/push/send (with x-internal-secret) + ──► lib/fcm.ts → FCM HTTP v1 (fan-out to all device-tokens) + ├──► Android device shows notification + └──► APNs ──► iPhone shows notification ``` | Piece | File | @@ -20,7 +21,8 @@ Article publish (breakingNews=true) | Internal fan-out endpoint | [`app/api/push/send/route.ts`](../app/api/push/send/route.ts) | | FCM v1 client | [`lib/fcm.ts`](../lib/fcm.ts) | | Article publish hook | [`collections/Articles.ts`](../collections/Articles.ts) (`hooks.afterChange`) | -| Android registration code | [`mobile/`](../mobile/) (Capacitor + a small Java/Kotlin plugin) | +| Android registration code | [`PushRegistration.java`](../mobile/android/app/src/main/java/edu/rpi/poly/PushRegistration.java) (JS bootstrap for `@capacitor/push-notifications`) | +| iOS registration code | [`PushRegistration.swift`](../mobile/ios/App/App/PushRegistration.swift) (same bootstrap, plus the APNs → FCM token swap) | ## Required secrets @@ -41,9 +43,14 @@ credentials. ## Registration flow -1. The Android app obtains its FCM registration token via the Capacitor - FCM plugin. -2. It POSTs to `/api/push/register` with `{ token, platform: 'android' }`. +1. The app obtains its FCM registration token via + `@capacitor/push-notifications`. On iOS the plugin natively yields an + APNs device token, so the app hands that to Firebase Messaging and + reports the resulting FCM token instead; both platforms therefore store + FCM tokens and share one send path. (Delivering to iOS requires an APNs + auth key uploaded to the Firebase project; see `mobile/README.md`.) +2. It POSTs to `/api/push/register` with + `{ token, platform: 'android' | 'ios' }`. 3. The endpoint validates length (`MAX_TOKEN_LENGTH = 4096`), throttles re-registrations (in-memory `recentTokens` map, `RATE_LIMIT_WINDOW_MS = 60_000`), and upserts into `device-tokens`. @@ -67,6 +74,9 @@ credentials. `device-tokens` rows, and calls `sendFcmToTokens` from `lib/fcm.ts`. 5. `lib/fcm.ts` mints an OAuth bearer from the service account, posts the FCM v1 message in chunks, and reports back per-token failures. + Each message carries `android.priority: high` and an + `apns.payload.aps.sound` so iOS alerts aren't silent; each platform + ignores the other's block. Tokens that come back as `UNREGISTERED` should be removed from `device-tokens` (TODO if not yet wired). @@ -105,9 +115,10 @@ but the path is exercised. caller in `Articles.afterChange` and the endpoint together. There's only one caller in the codebase; CI typecheck will catch most mismatches but JSON body shape is checked at runtime. -- For multi-platform support, add an iOS branch to `device-tokens.platform` - and a APNs-or-FCM dispatch in `lib/fcm.ts`. The platform column is - already enum-bounded to `android | ios`. +- iOS goes through FCM too, so `lib/fcm.ts` doesn't branch on + `device-tokens.platform`. The column records which app registered the + token (`android | ios`), which is useful for debugging and per-platform + cleanup. ## Incident playbook @@ -117,6 +128,11 @@ but the path is exercised. - **All sends fail with `401` from FCM.** The service account JSON is invalid or the Firebase project ID doesn't match the package name (`edu.rpi.poly`). Regenerate the service account and rotate the env var. +- **Android gets pushes but iPhones don't.** Check that the Firebase + project has an APNs auth key under Project settings → Cloud Messaging → + Apple app configuration, and that the iOS build bundled + `GoogleService-Info.plist` (Xcode prints a build warning when it's + missing, and the app then registers no token). - **Tokens accumulate forever.** Until token cleanup is wired, `device-tokens` grows monotonically. Manually prune rows older than ~6 months (`DELETE FROM device_tokens WHERE last_seen_at < NOW() - diff --git a/lib/fcm.ts b/lib/fcm.ts index dfb676b..29f1b2e 100644 --- a/lib/fcm.ts +++ b/lib/fcm.ts @@ -144,6 +144,9 @@ async function sendOne( }, data: notification.data, android: { priority: 'high' as const }, + // FCM relays `notification` to iOS as an APNs alert, but it arrives + // silently unless the aps payload asks for a sound. + apns: { payload: { aps: { sound: 'default' } } }, }, } try { diff --git a/lib/headerWaveFleet.ts b/lib/headerWaveFleet.ts new file mode 100644 index 0000000..ee80e24 --- /dev/null +++ b/lib/headerWaveFleet.ts @@ -0,0 +1,69 @@ +// Header wave fleet: waves fan out from a single start point, converge back at the end. +// Shared by the animated site header and the static print rule so both stay in sync. + +export const HEADER_WAVE_LAMBDA = 320; +export const HEADER_WAVE_SVG_H = 16; +export const HEADER_WAVE_CONVERGE = 4 * HEADER_WAVE_LAMBDA; // 1280px — where waves fully pinch +export const HEADER_WAVE_START_X = -4; + +export type HeaderWave = { d: string; opacity: number; delay: number }; + +export type WaveFleetOptions = { + /** + * Distance from the start point to where the fleet pinches back to the + * baseline. Defaults to the header's 1280px. The ramp-in/ramp-out envelope + * scales with it, so a shorter fleet is the same motif stretched, not cropped. + */ + converge?: number; +}; + +export function generateWaveFleet(count: number, options: WaveFleetOptions = {}): HeaderWave[] { + const converge = options.converge ?? HEADER_WAVE_CONVERGE; + const half = HEADER_WAVE_LAMBDA / 2; + const cp = Math.round(0.3642 * half); + const baseline = HEADER_WAVE_SVG_H / 2; + const startX = HEADER_WAVE_START_X; + const rampUp = converge * 0.15; + const rampDown = converge * 0.3; + const convergeEndX = startX + converge; + const maxHalves = Math.ceil(converge / half) + 2; + + const envelope = (x: number) => { + const t = x - startX; + if (t <= 0) return 0; + if (t < rampUp) return t / rampUp; + if (t < converge - rampDown) return 1; + if (t < converge) return (converge - t) / rampDown; + return 0; + }; + + const n = Math.max(1, Math.min(8, Math.round(count))); + const margin = 1.5; + const usableH = HEADER_WAVE_SVG_H - 2 * margin; + + const specs = Array.from({ length: n }, (_, i) => { + const t = n === 1 ? 0.5 : i / (n - 1); + const cy = margin + t * usableH; + const dist = Math.abs(t - 0.5) * 2; // 0 at center, 1 at edges + return { cy, A: 4.6 - dist * 1.4, opacity: 1 - dist * 0.6, delay: dist * 0.1 }; + }); + + return specs.map(({ cy, A, opacity, delay }) => { + let d = `M ${startX},${baseline}`; + for (let k = 0; k < maxHalves; k++) { + const x0 = startX + k * half; + const x1 = startX + (k + 1) * half; + if (x0 >= convergeEndX) break; + const e0 = envelope(x0); + const e1 = envelope(x1); + const eMid = envelope((x0 + x1) / 2); + const y0 = baseline + (cy - baseline) * e0; + const y1 = baseline + (cy - baseline) * e1; + const peakA = A * eMid; + const sign = k % 2 === 0 ? -1 : 1; + d += ` C ${x0 + cp},${y0 + sign * peakA} ${x1 - cp},${y1 + sign * peakA} ${x1},${y1}`; + } + d += ` L ${convergeEndX},${baseline}`; + return { d, opacity, delay }; + }); +} diff --git a/mobile/.gitignore b/mobile/.gitignore index c8278f4..3857931 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -17,6 +17,7 @@ android/gradle/wrapper/dists/ # Firebase config (provide via CI secret or copy from placeholder) android/app/google-services.json +ios/App/App/GoogleService-Info.plist # Signing material — never commit android/app/release.keystore diff --git a/mobile/README.md b/mobile/README.md index b2d41ea..45dd44f 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,11 +1,13 @@ -# mobile — "The Poly" Android app +# mobile — "The Poly" Android and iOS apps -Capacitor-based Android WebView shell for [poly.rpi.edu](https://poly.rpi.edu). -The app is a thin native wrapper around the production website, plus a push -notifications plugin for breaking-news alerts. +Capacitor-based WebView shells for [poly.rpi.edu](https://poly.rpi.edu), one +per platform. Each app is a thin native wrapper around the production +website, plus a push notifications plugin for breaking-news alerts. The authoritative design doc lives at [`docs/superpowers/specs/2026-04-24-capacitor-android-app-design.md`](../docs/superpowers/specs/2026-04-24-capacitor-android-app-design.md). +The iOS app follows the same design; its platform notes are in the +[iOS](#ios) section below. ## Layout @@ -18,6 +20,7 @@ mobile/ │ ├── generate-icons.sh # re-renders resources/*.png from resources/*.svg │ └── sync-version.mjs # writes versionName/versionCode into android/app/build.gradle ├── android/ # generated Capacitor Android project +├── ios/ # Capacitor iOS project (Swift Package Manager, no CocoaPods) ├── capacitor.config.ts # appId, appName, server.url └── package.json # Capacitor 6 deps ``` @@ -29,6 +32,8 @@ mobile/ - Android SDK (Platform 34 + Build Tools 34+) - ImageMagick and `rsvg-convert` if you plan to regenerate the icon/splash source art +iOS prerequisites are listed under [iOS](#ios). + ## Local development Install everything (repo root and `mobile/`), then sync and run: @@ -38,7 +43,9 @@ Install everything (repo root and `mobile/`), then sync and run: pnpm install cd mobile -pnpm install +# --ignore-workspace: the repo-root pnpm-workspace.yaml otherwise makes this +# install the root project and leave mobile/node_modules empty. +pnpm install --ignore-workspace npx cap sync android # open Android Studio @@ -164,9 +171,151 @@ Set `ANDROID_KEYSTORE_PATH` if the keystore lives somewhere other than hosted on production with the release keystore's SHA-256 fingerprint. Do that after the first signed release. +## iOS + +The iOS app lives in `ios/`. It shares the Android app's plumbing (same +site, theme bridge, push flow) but uses native iOS chrome and gestures +where Android relies on the website's own UI: + +| Android (`android/app/src/main/`) | iOS (`ios/App/App/`) | +| --- | --- | +| `MainActivity.java`: red splash until the page is ready; system bars follow `window.PolyTheme.setDark` | `MainViewController.swift`: logo launch view with the same readiness timings; status bar and tab bar follow the same `PolyTheme` bridge | +| The site's web bottom nav | `SiteTabBarController.swift`: native tab bar (Liquid Glass on iOS 26+) | +| Off-site links open in the browser | `ExternalLinksPlugin.swift`: in-app Safari sheet | +| System back button | Edge swipe back/forward through history, plus pull to refresh | +| `PushRegistration.java`: JS bootstrap for `@capacitor/push-notifications` | `PushRegistration.swift`: same bootstrap (`platform: 'ios'`) plus the APNs → FCM token swap | +| App Links intent filter for `poly.rpi.edu` | Associated Domains entitlement (`applinks:poly.rpi.edu`), routed by `SceneDelegate.swift` | +| `google-services.json` (gitignored) | `GoogleService-Info.plist` (gitignored) | + +Native dependencies come from Swift Package Manager. `npx cap sync ios` +regenerates `ios/App/CapApp-SPM/Package.swift` from the installed Capacitor +plugins, and the Xcode project pulls `FirebaseCore` + `FirebaseMessaging` +from `firebase-ios-sdk`. There is no Podfile. + +### Prerequisites + +- macOS with Xcode 16.3 or newer (Firebase's Swift package needs Swift 6.1). + App Store and TestFlight uploads need whichever Xcode Apple currently + requires. +- An iOS Simulator runtime (Xcode → Settings → Components). +- Node 20+ and pnpm 10. + +### Local development + +```bash +cd mobile +pnpm install --ignore-workspace +npx cap sync ios # writes capacitor.config.json into the app, regenerates CapApp-SPM +npx cap open ios # opens App.xcodeproj; pick a simulator and press Run +``` + +Or build from the command line (this is what `.github/workflows/ios-build.yml` +runs, also available as `pnpm build:ios`): + +```bash +cd mobile/ios/App +xcodebuild -project App.xcodeproj -scheme App -configuration Debug \ + -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build CODE_SIGNING_ALLOWED=NO build +``` + +Simulator builds need no Apple Developer account. Physical devices do: the +app uses the Push Notifications and Associated Domains capabilities, which +free personal teams can't sign. Pick the team under the App target → +Signing & Capabilities. + +### Firebase setup (iOS) + +Use the same Firebase project as Android: + +1. **Add an iOS app** in Firebase → Project settings with bundle ID + `edu.rpi.poly`. +2. **Download `GoogleService-Info.plist`** to + `mobile/ios/App/App/GoogleService-Info.plist`. A build phase copies it into + the app bundle when it exists. Without it, the app still runs, push + registration is disabled, and Xcode prints a build warning. + - For CI: `base64 -i GoogleService-Info.plist | pbcopy`, then paste into + the GitHub secret `GOOGLE_SERVICE_INFO_PLIST_BASE64`. +3. **Upload an APNs auth key.** Create a key with the Apple Push + Notifications service enabled (Apple Developer → Certificates, IDs & + Profiles → Keys), then upload the `.p8` under Firebase → Project + settings → Cloud Messaging → Apple app configuration with its key ID and + your team ID. The same key covers development and production builds. +4. **No backend changes.** The app registers an FCM token (not the raw APNs + token) with `platform: 'ios'`, so `/api/push/send` → `lib/fcm.ts` reaches + iPhones through the existing `FCM_SERVICE_ACCOUNT_JSON`. + +### How it works + +- **Launch.** `LaunchScreen.storyboard` shows the red "p" on the site's + background color (white, or #0A0A0A in dark mode), the way iOS apps + launch, rather than Android's full-bleed red. The SplashScreen plugin skips + its iOS launch splash when `launchShowDuration` is 0 (the value the Android + flow relies on), so `MainViewController` keeps an identical view over the + web view until `document.readyState === 'complete'` plus 350 ms, with an + 8 s backstop and a 250 ms fade, matching `MainActivity`. The tab bar is + already live underneath. +- **Tab bar (iPhone).** Home, News, Features, Opinion, and Sports are native + tabs over the one web view. Tapping a tab navigates client-side by + clicking the site's own Next.js link for that section (falling back to a + page load). Articles opened from a tab stay in that tab, each tab + remembers its last page, and re-tapping the selected tab returns to the + section front, then scrolls to the top. On iOS 26+ the bar minimizes while + scrolling down. The app hides the site's web bottom nav with an injected + stylesheet keyed to `nav[aria-label="Primary"]`, so keep that selector in + mind when changing `components/BottomNav.tsx`. iPad keeps the site's own + desktop navigation. +- **Safe areas and theme.** `SiteHostViewController` pins the web view below + the status bar, the same place Android's WebView starts, because the + site's fixed article header (`ArticleScrollBar`) has no top safe-area + padding. An injected rule also drops the extra 0.75rem the mobile header + (`header.safe-area-top`) adds above the logo. The web view still runs under + the home indicator and tab bar, whose height reaches the page as + `env(safe-area-inset-bottom)` (`ios.contentInset: 'never'`). The status bar + strip, status bar glyphs, and tab bar follow `window.PolyTheme.setDark`, + which `ThemeProvider` already calls for Android, falling back to the + `theme` cookie and then the system appearance. +- **Gestures.** Rubber-band scrolling and pull to refresh are back on + (Capacitor disables bouncing). Edge swipes move back and forward through + history; an injected capture-phase touch listener keeps touches that start + at the left edge away from the site's swipe-to-open menu drawer. +- **Links.** Off-site links open in an in-app Safari sheet + (`ExternalLinksPlugin`, via Capacitor's `shouldOverrideLoad` hook). + Same-site links that request a new window load in place. +- **Push.** The bootstrap script runs at document end on every page load: + it requests permission, POSTs the token to `/api/push/register`, and sends + notification taps to `data.articleUrl`. `FirebaseAppDelegateProxyEnabled` + is off, so `AppDelegate` hands the APNs token to Firebase Messaging + explicitly. +- **Lifecycle.** Scene-based (`SceneDelegate` builds the window), which + recent iOS SDKs require for apps to launch. + +### Known limitations + +- **Universal links** need + `https://poly.rpi.edu/.well-known/apple-app-site-association` to list + `.edu.rpi.poly` before iOS opens site links in the app. The + entitlement and the in-app routing are already in place. +- **No release pipeline yet.** `ios-build.yml` only compiles for the + simulator. TestFlight and App Store uploads need an Apple Developer + Program team, an App Store Connect app record, and signing secrets, + analogous to the Android keystore secrets. +- **Versioning.** `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` are set + by hand in the Xcode project until there's a release lane. +- **Deprecated FCM token API.** Firebase 12.18 deprecated + `Messaging.token(completion:)` in favor of installation-ID registration. + The app still uses the token API (one build warning) because + `lib/fcm.ts` and the Android app address devices by registration token. + Moving to installation IDs means changing the backend send path for both + platforms. +- **Offline mode:** none, same as Android. If poly.rpi.edu is unreachable, + the web view stays blank after the splash backstop. + ## References - Design spec: [`docs/superpowers/specs/2026-04-24-capacitor-android-app-design.md`](../docs/superpowers/specs/2026-04-24-capacitor-android-app-design.md) -- Capacitor: https://capacitorjs.com/docs/android +- Capacitor: https://capacitorjs.com/docs/android, https://capacitorjs.com/docs/ios +- Capacitor + Swift Package Manager: https://capacitorjs.com/docs/ios/spm +- Firebase Cloud Messaging on Apple platforms: https://firebase.google.com/docs/cloud-messaging/ios/client - Adaptive icons: https://developer.android.com/develop/ui/views/launch/icon_design_adaptive - Themed icons (monochrome): https://developer.android.com/develop/ui/views/launch/icon_design_adaptive#monochromatic_app_icons diff --git a/mobile/capacitor.config.ts b/mobile/capacitor.config.ts index c856923..5c4f67f 100644 --- a/mobile/capacitor.config.ts +++ b/mobile/capacitor.config.ts @@ -1,7 +1,7 @@ import type { CapacitorConfig } from '@capacitor/cli'; /** - * Capacitor configuration for "The Poly" Android shell. + * Capacitor configuration for "The Poly" Android and iOS shells. * * The app loads the production site directly via `server.url`, so `webDir` * is only a formal requirement and points at a tiny placeholder bundle. @@ -19,6 +19,12 @@ const config: CapacitorConfig = { android: { allowMixedContent: false, }, + ios: { + // The native container already places the web view below the status + // bar, and the page handles the bottom safe area itself + // (env(safe-area-inset-bottom)), so UIKit shouldn't inset it again. + contentInset: 'never', + }, plugins: { SplashScreen: { // The splash is dismissed from MainActivity once the WebView reports @@ -27,6 +33,10 @@ const config: CapacitorConfig = { // on a fixed timer and flashing unstyled / half-hydrated content. // A hard backstop in MainActivity guarantees dismissal if the page // never reports ready (e.g. broken network). + // + // On iOS the plugin skips its launch splash when launchShowDuration is + // 0, so MainViewController.swift holds an equivalent red overlay with + // the same readiness poll, settle delay, backstop, and fade. launchShowDuration: 0, launchAutoHide: false, launchFadeOutDuration: 250, diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore new file mode 100644 index 0000000..f470299 --- /dev/null +++ b/mobile/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/mobile/ios/App/App.xcodeproj/project.pbxproj b/mobile/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a0c5736 --- /dev/null +++ b/mobile/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,441 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 7A3D1C2E2E8F4B1000A1B002 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3D1C2E2E8F4B1000A1B001 /* MainViewController.swift */; }; + 7A3D1C2E2E8F4B1000A1B004 /* PushRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3D1C2E2E8F4B1000A1B003 /* PushRegistration.swift */; }; + 7A3D1C2E2E8F4B1000A1B006 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3D1C2E2E8F4B1000A1B005 /* SceneDelegate.swift */; }; + 7A3D1C2E2E8F4B1000A1B00A /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 7A3D1C2E2E8F4B1000A1B009 /* FirebaseCore */; }; + 7A3D1C2E2E8F4B1000A1B00C /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 7A3D1C2E2E8F4B1000A1B00B /* FirebaseMessaging */; }; + 7A3D1C2E2E8F4B1000A1B00F /* SiteTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3D1C2E2E8F4B1000A1B00E /* SiteTabBarController.swift */; }; + 7A3D1C2E2E8F4B1000A1B011 /* ExternalLinksPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3D1C2E2E8F4B1000A1B010 /* ExternalLinksPlugin.swift */; }; + 7A3D1C2E2E8F4B1000A1B013 /* SiteHostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3D1C2E2E8F4B1000A1B012 /* SiteHostViewController.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 7A3D1C2E2E8F4B1000A1B001 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; + 7A3D1C2E2E8F4B1000A1B003 /* PushRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushRegistration.swift; sourceTree = ""; }; + 7A3D1C2E2E8F4B1000A1B005 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7A3D1C2E2E8F4B1000A1B007 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; + 7A3D1C2E2E8F4B1000A1B00E /* SiteTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteTabBarController.swift; sourceTree = ""; }; + 7A3D1C2E2E8F4B1000A1B010 /* ExternalLinksPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalLinksPlugin.swift; sourceTree = ""; }; + 7A3D1C2E2E8F4B1000A1B012 /* SiteHostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteHostViewController.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + 7A3D1C2E2E8F4B1000A1B00A /* FirebaseCore in Frameworks */, + 7A3D1C2E2E8F4B1000A1B00C /* FirebaseMessaging in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 7A3D1C2E2E8F4B1000A1B007 /* App.entitlements */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 7A3D1C2E2E8F4B1000A1B005 /* SceneDelegate.swift */, + 7A3D1C2E2E8F4B1000A1B00E /* SiteTabBarController.swift */, + 7A3D1C2E2E8F4B1000A1B012 /* SiteHostViewController.swift */, + 7A3D1C2E2E8F4B1000A1B001 /* MainViewController.swift */, + 7A3D1C2E2E8F4B1000A1B003 /* PushRegistration.swift */, + 7A3D1C2E2E8F4B1000A1B010 /* ExternalLinksPlugin.swift */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + 7A3D1C2E2E8F4B1000A1B00D /* Copy GoogleService-Info.plist */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + 7A3D1C2E2E8F4B1000A1B009 /* FirebaseCore */, + 7A3D1C2E2E8F4B1000A1B00B /* FirebaseMessaging */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + 7A3D1C2E2E8F4B1000A1B008 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 7A3D1C2E2E8F4B1000A1B00D /* Copy GoogleService-Info.plist */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy GoogleService-Info.plist"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# GoogleService-Info.plist is gitignored, like Android's google-services.json.\n# Bundle it when present; without it the app runs with push registration disabled.\nPLIST=\"${SRCROOT}/App/GoogleService-Info.plist\"\nif [ -f \"$PLIST\" ]; then\n cp \"$PLIST\" \"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleService-Info.plist\"\nelse\n echo \"warning: App/GoogleService-Info.plist not found; push notifications are disabled in this build\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + 7A3D1C2E2E8F4B1000A1B006 /* SceneDelegate.swift in Sources */, + 7A3D1C2E2E8F4B1000A1B00F /* SiteTabBarController.swift in Sources */, + 7A3D1C2E2E8F4B1000A1B013 /* SiteHostViewController.swift in Sources */, + 7A3D1C2E2E8F4B1000A1B002 /* MainViewController.swift in Sources */, + 7A3D1C2E2E8F4B1000A1B004 /* PushRegistration.swift in Sources */, + 7A3D1C2E2E8F4B1000A1B011 /* ExternalLinksPlugin.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = edu.rpi.poly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = edu.rpi.poly; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 7A3D1C2E2E8F4B1000A1B008 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.19.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; + 7A3D1C2E2E8F4B1000A1B009 /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = 7A3D1C2E2E8F4B1000A1B008 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCore; + }; + 7A3D1C2E2E8F4B1000A1B00B /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 7A3D1C2E2E8F4B1000A1B008 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..96d5ca1 --- /dev/null +++ b/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,132 @@ +{ + "originHash" : "27bee8d6bed56d729e31468dace4508c078dd30f5f288a18cfec49da2c887427", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", + "version" : "11.3.2" + } + }, + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "3fe94bbbe43e63ada75aa6788fc10e3764622a97", + "version" : "6.2.1" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "cf44bf2fa90b1dbba999ee2bb4dce4eaf7bceae8", + "version" : "12.19.1" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee", + "version" : "3.7.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "8fe40b69bd53241847814422101188465f7ff728", + "version" : "12.19.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", + "version" : "10.1.1" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", + "version" : "8.1.3" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + } + ], + "version" : 3 +} diff --git a/mobile/ios/App/App/App.entitlements b/mobile/ios/App/App/App.entitlements new file mode 100644 index 0000000..db15fe0 --- /dev/null +++ b/mobile/ios/App/App/App.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.developer.associated-domains + + applinks:poly.rpi.edu + + + diff --git a/mobile/ios/App/App/AppDelegate.swift b/mobile/ios/App/App/AppDelegate.swift new file mode 100644 index 0000000..b93ad62 --- /dev/null +++ b/mobile/ios/App/App/AppDelegate.swift @@ -0,0 +1,23 @@ +import UIKit +import Capacitor + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + PushRegistration.configureFirebase() + return true + } + + // The window, deep links, and universal links are handled per scene; see + // SceneDelegate.swift. Remote notification registration stays app-wide. + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + PushRegistration.didRegisterForRemoteNotifications(deviceToken: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) + } + +} diff --git a/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..90e8accfda04bc3f4ad1ae242f2e2f773d44b0de GIT binary patch literal 22563 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 literal 0 HcmV?d00001 diff --git a/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9b7d382 --- /dev/null +++ b/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/ios/App/App/Assets.xcassets/Contents.json b/mobile/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/mobile/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/mobile/ios/App/App/Assets.xcassets/LaunchBackground.colorset/Contents.json b/mobile/ios/App/App/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..19d96af --- /dev/null +++ b/mobile/ios/App/App/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.039", + "green" : "0.039", + "red" : "0.039" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/Contents.json b/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/Contents.json new file mode 100644 index 0000000..04292db --- /dev/null +++ b/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "launch-logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "launch-logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "launch-logo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo.png b/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..92e83ce1018d8fc9caeb0d0ee4a56d311a9e0000 GIT binary patch literal 2336 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 bdM;~E5jT~_koWn&4=yz&ZN+MNo5=qIoT3cM literal 0 HcmV?d00001 diff --git a/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo@3x.png b/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b57fcbf14f25dec364a6e772b92f06b0a5f84910 GIT binary patch literal 7544 zcmeHsqTk z$Rmms$|ItYIl2t%(SL;8cAh=4J_X(VjERBLD4RTM+5|)xEMRWVrZ)oC>9~G+TR4rj z|0?jhxVX4%|EyU5!pqyB?V{tt;4j+~kDv&+1UD>v&>c0_G5e1<>!U@|BUdeAijq;% zFn^@Tz39Kk_c!NRPKQpC3wd{zN5%;|_70t2dLHL+=rPeJrIJ54i_)>-`JqM;6fwY) z^yi>^B@K=_D8>AdfLiBGB^h1p zzFb?gzCVHQ&l~Y!d!YQInFI<|%7OhPhu1HUPWbsx7!~n=uGnn%&(HFw?4FoQm<4)g zCmY9@{xlCE%D1HW5EeSS=69#H#hCB;zB*|v?s1vundrc!pU`)@w5TGc!lb^ShPLDq zdQMv!{=t>$T!&i9pU$kkI#Dzt09m{GEXnlgFNep&YAefuqBRj45^6f)rm;PdeOOE9 zj+0UY!2lyS-RX2=Jz6z8ku*YoQaZZSO)`@}lBr{jJs)2Is^zcx;eMM>LH1DH=8xEo zSK5z-(_?!CV&zW+%fbGj1feSF^#Dq^0b}xFK@v14#oNHLtI@eXOZJ6uX7d`NEgcOvyjz-(Uq#dqi!*Uhxy! z;i9XGB9cpPN<-HUCy&R9MW!JYG?Y-7hAb(IEI*Ij0ZKh`o+50n#jhqN}$N~SvDx6;xqVS{hf%OZH1?MmBZ@m4j}O?k_I@-z+=z!`@WlP zOu!52K>b^0z)wmC_&+8(gtYXPcTK> zuvb=G<*t*G3kcY`T_b)BpFGh%Zce*BhR8Rd`JP)BPA0-j&mHzB;Cfc|l#4POk9FJ; zRPx%VD#q;zuo8mHrOXTk>(v7~^hx%8iY`^&8L9#TE;POFoWjkOf$W*P2byX6c^HR2 zG1WO)YbIbS24h8L$~8)dOFUdDfAd!-9xV#0fZtee-3Wza77lM#99Ktdg?8HtQ}rFm zfS(zV!r1Nl8TTeL;e%x5vV0=tr^}ZaO~geSxy*<#%d-2_u4Tn-ArHA)=+eg0Gq6&? zx-C6YHr2;a8X(X$bhq9I86W5FjBn^tL-x1d#dzE=QdPx$ME)(p_yTIJKs@y zGp&kGybQEa`$T0`H7cHO5|nw({}@i7e>w>W^{ldaBKflKBJA$g_OpcAfCrh}nL!p)`M^kiC-e^WS^eoE%T~_{gHt!htC6ahR z)@kC@D~4<1G*ugX6fYiOmUWXsl<$$y&iLeVX*idB;+Sp?NfJA?I(ZSnfuqc0Pug61C#G3tyNjHB5)7NajP}g_5h;WjGW*|EihSYE|DZVHGa-hDb0> z53^`k!RPePzU8o(cG<+PNCt3OzqrdJb_feJKAdYFL# zD?0z0)LOy&#naV|S|QDgk7Pop zRM40kMuwO`Xz&5=2&?ABGov@ejHnJsIVuw$>E|EE-cAgZ5~G&ybRG##jbFMl8)}Gj zco2SmTV2b)y5pgsN;^Zp#WO!tCB(i%^XQmhjxI5g7Pc`e$xN**r=gGjrro|IVu=uoe z)jK*+Od3WCOgcuuc!by{DZAdv1usxBZqMIwXd?-#ts4dYLl7Rh;*6G_j<$(IE{$u>_N>J1f;~vrbjA!e?_-{#db*G=-iG_UU zXw;Sx4C!+T9c*sycaZ=ZnpQ6Z?kP$!+m?S9v$Zk##+L+~$6xEnqg!w?(<2)ZB&Z*k zt@s1obCDAXk##3(NXY5xyf>p=bdBO%Rp4~am(7wJq+lrrGb(!na(KXM?N%BQ5=^AC+6;`-Cy0Kzt2#&jKoC^?T!Ca6U1g}bps0*UhxpGMs!)>iQ(LbeQ@TJ z)Ghl=jP_4n%IxSbNDab{g!55xG2_0%75w%4$B!S}Y@4QGWF!EL6NI(kXA%*5PwKMVQM?*ybLXL~TQUKMy7T1ynT~!-;qJ)a%m9R3V zknmST(zR7hM+R@;-TI<78^~iKExKGjk`JZ-Sg})N9^WZ{pRJ%rT_w3wjhE^w z{0$92tptzqrBS-~%*F&3qTNj`KPs93G?oQyiTdyDM(?N+G?O!fD)1^T0{>lcPqMRf zxXxPiAjb`8enC;F`#dh}jaLrP9r(Vut%`XP^BJ;metQaH0;ws$3E#ddY;4sd1;6bj z_d=Hcz&SUMh-dH(e2ojdDfh+a>`;vbY+IO(G{QZ1nFct43qUfgnrWi8Z2l}jS?Z>7 zX2&7Nw;N6g(4H!<%VVAKF1OP z9KU%^=}Pwp3F>8<(QT%q=&W{YR>)Yhw+OiexPB8^CnFa`A_W6 zP8g_CUSJ`LwqCxud=$6~p;8A3nhMrjS&g7nMQb?CW?qjt?p-TVdu^2**^2E_r~{}0 zWR$*83xWR6X9~r?tp4pJEd>Es88=L!Ak(;2ZA2jZKab%^CLXVqq&gWCDX|A9)nfv(?zLGct15vYv|ZK|!f;P;tA?V*2mwy8>{{boX$|H7!Q|lr0#rWip_H+ZM|K z^=p8lK`)KiEW1F>!GNa}11dy`M;tJel!^-A<KkPFoJ?^~#}r@orlNfT^@D3{pmeKt3EY{~MXew2#lWl2#7 zV(~rxAbE^03y@Ly99%QgIh@@3Y$3|-?00(f&TulRQz3yxjrRdrrQiDebMk}DEvt0#q`3$tAb{UT2lK@-B{l){|w3jc`$g}vQNd(}b zNq{1gLEcM5p32!>lz^FYj9y2zbrLb!>miMFp7*zJplJ{H$(cLp4KJ^q#4JwQoQ6^5 z#)ATmlM(&83|zmok_Gt9Yn>On9Ev91u9F_F4Lg(-dGim7vy}d}mIySvYV^6k-?6v$ zZP;FunslD@fTJi-p*`GvVF6gSpfkF%qq@u^n1kjVzgHb_5C*o^dFW-N5Ff2Mg*qk6)mO=cT+K3V?!0u9>ap=)JLlE#>JZxeTn+5zfyK?|aEn_S@j> z<;QaRcj|Cdy;PIs*RF)Cj~&*xh5VzxmfM8eFbx_XJDBTEV7M6h6p) z7dmZ!#bN_{3CTgzhSmA2<8s*5Lyy)nZ9dC=a4^(qi+kpX4NO8acBZNFAX~iFHou=j zrWlZb{rl;{6h7yurjBr&AW4Y;FsgVC2-sucMgSX11EzwSOh_3_+Pw7>mxHY#*g_f{GDAJDh^-1TENYr z0tzr_d8?Zb4z#U8wbIlcKU*-%@|H{lyUaX)oyq%=RfaeN5nIN0g_r2t3F8JGuZ&;5 z!((pLvU2!HwR=btY++DKSIEMN8VeS|~A=_O=WsG6k=Fli!n zLl_rZwwBZFk0i@$Q=t`^4Dk$T=G6<@@UqbCRZ>Ti$FK7r^J#mFWZo-L+ZgcHZ(Qvc z+qX)k-)J+TeqLWM(BG}f#E@BINp}e;!p}--8QUx{Ex&HRf4~_}R^MYGfq%s%Tpyn3 z6ESukxG0+&*1vH>u6egUx)1qXtT#pa%YEZw%TyJA+sRSdlZN4o0j#JFUA*qk=!S1(YRZhZz zM-?m*?>bb|tj6e}hL}tcvpLTkT(?7ws*bw)YXbMe0@U^Z4;|AAHyoe#s_Ucxs`1cWH3+ zLS51F&EBgjyZ;Wv?NIn)E#kW`F)vi{kq7zr^YkdqS5v`uEv~}An={i7*Sf}Tq0SMcd>L{+ zb<|5KZAhIrogQLq`FoozwW zzvQe7Nl4Nyc{+`IY36zuO9M3Qo(-M(BPPc4@$`lNUEF@HD3Xwu5uXe>x4WJs&x};V z7x55#>RvUpPS{?_0gcB|lDT?s?!#1ORjTk}-^!VYR&S#VEmyGmFrCGdC~EF_r>H|a|P3$Y3Z`{j{(6q6DGu-0CU$stm-v$na^_Lpwi=-{(`+lMZo$}fe02FxqU>PI@opIV?h~Sj1Ps4*5 zKfOuk6W%J9F5mZz(Y$b9j46`XNv&i+fmSb+OuN3riVymUK7wx;`(AAoxVq35j3S6F zB;yuQh#35Tlm@qW#4O?E){x!oz07ze4k630W}6(axx&o+jSkj z!xfnp_7$v^Q{qjRxRWKH(>Qxwnwq1$*sBQFcc010-C1!lQ3u%I=!<)(bPZGx#y|3q z0|Kc|2u4rDkqQu9wTc%gUg7OOD?>E0=Y%n+i^)raru_Y*8fI#R0Knj18d&q^RWm9e zof`ss{_x|u>p=&#11D)yT=%`)JoHdw0r}l*!#RY>p02@{=JLYZ^Dxn-O{TjgLV6BLnp7hT*(-=Hs z$UWdWzYp&xflKpIMmt+GO+V}6I;UfM&j$d{3)lVuBQ%>~)s&$mU@a|8S*syDej0RM z%2ENZBTf7Dr7H$AzUw&7d(&{nD*8ahCu0dU??jK%WLc$vaU7G=qZXpPz3ZEpixzKt zL)$MuG>Bl>NLs3CJVITE)NS314H|J!PgAmldvVzn(omQBWV-7i;<%{*75?E*?6}`@ zlJ51}(F2$?B8%eg{fa|PE6v3}Y1!;=O6tM0;Fht=Eg`R*mKa8#s)nL4wY@hPW1iX) zROe-~lO{z@o1#F$c{>BYlUz1}RI6L9d7R*H75BpL7E4PN*2!+oDx2e`t}#_m7=ggK z*5c2IU&`zRRhC3qlQIa0YB9gvj*;(o+U)AFsZqA0I}O`V&}m)r)l@=ZC$=dnCxr2z zY_-K@S_ptI!uInQI{^mmq3?GL4${9&@qqkAWu3)`?jO!3B)=D3K&5{W>S#?I&m0{F z5FEX;D1C26VeV4+lkm!U`e&H*%WO@*Gm5eJ>yvDEW!Wml!%`-KOtDm=);__X`sVcR zeXr0VEC3j|e=;InFjqJepE|3{VEui&%i20JA?99>_}dzgF)MY9^BI2fcmz9mTqaS( z0!ZGtgncY)d8A=idZ$m zH-|1G=tw*=&s&#T&&4=QAI+4I%`M2F=+ocwC`3~PNoPdRE~KmG9CrT9{i2L`Dl*5B zS6uIT%$f^7S{?2VFQ~pU_GZNXecWT0twHX41;lq0&hyseJT(QaL?{fM69xw>T z&#doctY7+yPtuGyoiJs0AO4-w*7eq=?-Xj*jlLkd_^IQ4ssG3RL+rwEKi-En7#Zjk z)Dx5)`6&V~idsLWPCD}lr*~W+?oXujBE4fyLTsUu0eM2E&5;lzk;zK%ud|ll_+J&+ zKAOk)LBkBkw4u`A;B+DyufMft1{KTuE2PCgx7j-0pg6wnS0pw69=$nPjgmxqf z3dfHIe?y_5XzZ7fF~;B^fU!Lz8v6k`4UN2}=wT13WB_u)U|_e-!MyyZ52l||;r@RY u{vT#xQ}QNVO@9ijT~J>z{v{RUMymJmXgJnS;y?fWfGW#t$W_T$1pE&etk*UG literal 0 HcmV?d00001 diff --git a/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard b/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..7364805 --- /dev/null +++ b/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/App/App/ExternalLinksPlugin.swift b/mobile/ios/App/App/ExternalLinksPlugin.swift new file mode 100644 index 0000000..378a075 --- /dev/null +++ b/mobile/ios/App/App/ExternalLinksPlugin.swift @@ -0,0 +1,42 @@ +import UIKit +import WebKit +import SafariServices +import Capacitor + +/// Opens off-site links in an in-app Safari sheet instead of sending the +/// reader out to Safari, and keeps same-site links that ask for a new window +/// in the web view. Capacitor's default for both is UIApplication.open. +/// Registered from MainViewController.capacitorDidLoad; it has no JS API. +@objc(ExternalLinksPlugin) +final class ExternalLinksPlugin: CAPPlugin, CAPBridgedPlugin { + let identifier = "ExternalLinksPlugin" + let jsName = "ExternalLinks" + let pluginMethods: [CAPPluginMethod] = [] + + override func shouldOverrideLoad(_ navigationAction: WKNavigationAction) -> NSNumber? { + guard let bridge = bridge, + let url = navigationAction.request.url, + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + + // Frame loads (video and social embeds) go through untouched. + let opensNewWindow = navigationAction.targetFrame == nil + guard opensNewWindow || navigationAction.targetFrame?.isMainFrame == true else { + return nil + } + + if url.host == bridge.config.serverURL.host { + guard opensNewWindow else { return nil } + _ = bridge.webView?.load(URLRequest(url: url)) + return true + } + + let safari = SFSafariViewController(url: url) + safari.preferredControlTintColor = MainViewController.brandRed + safari.dismissButtonStyle = .close + bridge.viewController?.present(safari, animated: true) + return true + } +} diff --git a/mobile/ios/App/App/Info.plist b/mobile/ios/App/App/Info.plist new file mode 100644 index 0000000..1e5d3ab --- /dev/null +++ b/mobile/ios/App/App/Info.plist @@ -0,0 +1,70 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + The Poly + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + FirebaseAppDelegateProxyEnabled + + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/mobile/ios/App/App/MainViewController.swift b/mobile/ios/App/App/MainViewController.swift new file mode 100644 index 0000000..6210db3 --- /dev/null +++ b/mobile/ios/App/App/MainViewController.swift @@ -0,0 +1,411 @@ +import UIKit +import WebKit +import Capacitor + +/// Bridge controller hosting the site; counterpart of the Android shell's +/// MainActivity, adapted to feel native on iOS. +/// +/// - Launch: a logo-on-background view matching LaunchScreen.storyboard +/// covers the web view until the site reports document.readyState === +/// 'complete' plus a settle delay, with a hard backstop so a dead network +/// never strands the user on it. (The SplashScreen plugin can't do this on +/// iOS: it skips its launch splash entirely when launchShowDuration is 0.) +/// - Chrome: on iPhone the site's sections live in a native tab bar +/// (SiteTabBarController), so the site's own bottom nav is hidden. Off-site +/// links open in an in-app Safari sheet (ExternalLinksPlugin). +/// - Gestures: rubber-band scrolling, pull to refresh, and edge swipes that +/// walk back and forward through the site's history. +/// - Theme: status bar glyphs and native chrome follow the site's theme. The +/// site pushes it through window.PolyTheme.setDark, the same bridge the +/// Android shell exposes; until it does, the `theme` cookie and then the +/// system appearance decide. +/// - Injects the push registration bootstrap (PushRegistration.swift) and +/// loads universal links for the site in the web view. +/// +/// Like the Android shell's WebView, this view sits below the status bar +/// (SiteHostViewController); the site's fixed article header has no +/// top safe-area padding of its own. It still runs under the home indicator +/// and tab bar, whose height reaches the page as env(safe-area-inset-bottom). +class MainViewController: CAPBridgeViewController { + + static let brandRed = UIColor(red: 214 / 255, green: 0, blue: 28 / 255, alpha: 1) + private static let lightBackground = UIColor.white + private static let darkBackground = UIColor(red: 10 / 255, green: 10 / 255, blue: 10 / 255, alpha: 1) + + // Same splash timings as MainActivity. + private static let splashReadyPollInterval: TimeInterval = 0.12 + private static let splashSettleDelay: TimeInterval = 0.35 + private static let splashHideBackstop: TimeInterval = 8 + private static let splashFadeOutDuration: TimeInterval = 0.25 + + private static let themeMessageName = "polyTheme" + + // The initial about:blank document is already 'complete', so require a + // real http(s) page before treating the site as ready. + private static let pageReadyScript = "location.protocol.indexOf('http') === 0 && document.readyState === 'complete'" + + /// Set before the view loads when a native tab bar replaces the site's + /// bottom nav. + var usesNativeTabBar = false + + /// Called whenever the web view's URL changes, including client-side + /// (pushState) navigations. + var onURLChange: ((URL) -> Void)? + + /// Called with the resolved theme (true = dark) whenever it's applied. + var onThemeChange: ((Bool) -> Void)? + + private var splashView: UIView? + private var splashLogoCenterY: NSLayoutConstraint? + private var urlObservation: NSKeyValueObservation? + private var loadingObservation: NSKeyValueObservation? + + // nil = the site hasn't pushed a theme yet; defer to the cookie / system. + private var siteThemeOverride: Bool? + private var cookieTheme: Bool? + + static func normalizedPath(_ path: String) -> String { + if path.isEmpty { return "/" } + return path.count > 1 && path.hasSuffix("/") ? String(path.dropLast()) : path + } + + override func capacitorDidLoad() { + super.capacitorDidLoad() + guard let webView = webView else { return } + + bridge?.registerPluginInstance(ExternalLinksPlugin()) + + let contentController = webView.configuration.userContentController + contentController.add(WeakScriptMessageHandler(self), name: Self.themeMessageName) + contentController.addUserScript(WKUserScript(source: shellScript(), + injectionTime: .atDocumentStart, + forMainFrameOnly: true)) + contentController.addUserScript(WKUserScript(source: PushRegistration.bootstrapScript, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true)) + + // Capacitor turns rubber-banding off; iOS readers expect it, along + // with pull to refresh and edge swipes through history. + let scrollView = webView.scrollView + scrollView.bounces = true + scrollView.alwaysBounceVertical = true + let refreshControl = UIRefreshControl() + refreshControl.addTarget(self, action: #selector(refreshPage), for: .valueChanged) + scrollView.refreshControl = refreshControl + webView.allowsBackForwardNavigationGestures = true + + urlObservation = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in + guard let url = webView.url else { return } + DispatchQueue.main.async { + self?.onURLChange?(url) + } + } + loadingObservation = webView.observe(\.isLoading, options: [.new]) { [weak self] webView, _ in + guard !webView.isLoading else { return } + DispatchQueue.main.async { + self?.webView?.scrollView.refreshControl?.endRefreshing() + } + } + + NotificationCenter.default.addObserver(self, + selector: #selector(handleUniversalLink(_:)), + name: .capacitorOpenUniversalLink, + object: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + + showSplash() + applyTheme(animated: false) + readThemeCookie() + + // A universal link that cold-launched the app is posted before this + // controller starts observing, but Capacitor keeps it as lastURL. + if let url = ApplicationDelegateProxy.shared.lastURL { + loadSiteURL(url) + } + + scheduleSplashHideWhenReady() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // This view starts below the status bar (SiteHostViewController), so + // shift the logo up to where LaunchScreen.storyboard centers it on + // the full screen. + if let window = view.window { + splashLogoCenterY?.constant = -view.convert(CGPoint.zero, to: window).y / 2 + } + // The spinner normally sits behind the page, where the site's fixed + // article header covers it. Draw it in front instead. + if let scrollView = webView?.scrollView, let refreshControl = scrollView.refreshControl { + scrollView.bringSubviewToFront(refreshControl) + } + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + applyTheme(animated: false) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + applyTheme(animated: true) + } + } + + // MARK: - Navigation + + /// Navigates within the site: client-side through one of the page's own + /// links when it has one, otherwise with a normal page load. + func navigate(to path: String) { + guard let webView = webView else { return } + if let current = webView.url, + Self.normalizedPath(current.path) + (current.query.map { "?" + $0 } ?? "") == Self.normalizedPath(path) { + return + } + guard let data = try? JSONSerialization.data(withJSONObject: path, options: .fragmentsAllowed), + let argument = String(data: data, encoding: .utf8) else { return } + webView.evaluateJavaScript("!!(window.PolyShell && window.PolyShell.navigate(\(argument)))") { [weak self] result, _ in + guard (result as? Bool) != true, + let base = self?.bridge?.config.serverURL, + let url = URL(string: path, relativeTo: base) else { return } + _ = self?.webView?.load(URLRequest(url: url.absoluteURL)) + } + } + + /// Re-selecting the current tab: back to the section front, or up to the + /// top when already there. + func showFrontPage(path: String) { + guard let webView = webView else { return } + if let current = webView.url, Self.normalizedPath(current.path) == path { + webView.scrollView.setContentOffset(.zero, animated: true) + } else { + navigate(to: path) + } + } + + @objc private func refreshPage() { + webView?.reload() + } + + // MARK: - Splash + + private func showSplash() { + // Same layout as LaunchScreen.storyboard, so the hand-off from the + // system launch screen is invisible. + let splash = UIView() + splash.backgroundColor = UIColor(named: "LaunchBackground") ?? .systemBackground + splash.translatesAutoresizingMaskIntoConstraints = false + let logo = UIImageView(image: UIImage(named: "LaunchLogo")) + logo.translatesAutoresizingMaskIntoConstraints = false + splash.addSubview(logo) + view.addSubview(splash) + let logoCenterY = logo.centerYAnchor.constraint(equalTo: splash.centerYAnchor) + NSLayoutConstraint.activate([ + splash.topAnchor.constraint(equalTo: view.topAnchor), + splash.bottomAnchor.constraint(equalTo: view.bottomAnchor), + splash.leadingAnchor.constraint(equalTo: view.leadingAnchor), + splash.trailingAnchor.constraint(equalTo: view.trailingAnchor), + logo.centerXAnchor.constraint(equalTo: splash.centerXAnchor), + logoCenterY + ]) + splashView = splash + splashLogoCenterY = logoCenterY + } + + private func scheduleSplashHideWhenReady() { + DispatchQueue.main.asyncAfter(deadline: .now() + Self.splashHideBackstop) { [weak self] in + self?.hideSplash() + } + pollPageReady() + } + + private func pollPageReady() { + guard splashView != nil, let webView = webView else { return } + webView.evaluateJavaScript(Self.pageReadyScript) { [weak self] result, _ in + guard let self = self, self.splashView != nil else { return } + if (result as? Bool) == true { + DispatchQueue.main.asyncAfter(deadline: .now() + Self.splashSettleDelay) { [weak self] in + self?.hideSplash() + } + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + Self.splashReadyPollInterval) { [weak self] in + self?.pollPageReady() + } + } + } + } + + private func hideSplash() { + guard let splash = splashView else { return } + splashView = nil + UIView.animate(withDuration: Self.splashFadeOutDuration, animations: { + splash.alpha = 0 + }, completion: { _ in + splash.removeFromSuperview() + }) + applyTheme(animated: true) + } + + // MARK: - Theme + + private var isDarkTheme: Bool { + return siteThemeOverride ?? cookieTheme ?? (traitCollection.userInterfaceStyle == .dark) + } + + private func applyTheme(animated: Bool) { + let dark = isDarkTheme + + // Painted before a page's first frame, e.g. during full reloads. + let background = dark ? Self.darkBackground : Self.lightBackground + webView?.backgroundColor = background + webView?.scrollView.backgroundColor = background + // The container's background fills the strip behind the status bar; + // while the launch view is up it matches that instead. + parent?.view.backgroundColor = splashView != nil + ? (UIColor(named: "LaunchBackground") ?? .systemBackground) + : background + onThemeChange?(dark) + + // The launch view follows the system appearance, like the storyboard. + let style: UIStatusBarStyle = splashView != nil ? .default : (dark ? .lightContent : .darkContent) + guard style != statusBarStyle else { return } + if animated { + setStatusBarStyle(style) + } else { + statusBarStyle = style + setNeedsStatusBarAppearanceUpdate() + } + } + + // Reads the site's `theme` cookie so cold launches match the in-app theme + // before the site's own PolyTheme.setDark call lands. + private func readThemeCookie() { + guard let webView = webView, let host = bridge?.config.serverURL.host else { return } + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] cookies in + guard let self = self else { return } + let cookie = cookies.first { cookie in + cookie.name == "theme" && host.hasSuffix(cookie.domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))) + } + switch cookie?.value.lowercased() { + case "dark": + self.cookieTheme = true + case "light": + self.cookieTheme = false + default: + return + } + self.applyTheme(animated: false) + } + } + + // MARK: - Universal links + + @objc private func handleUniversalLink(_ notification: Notification) { + guard let url = (notification.object as? [String: Any])?["url"] as? URL else { return } + DispatchQueue.main.async { [weak self] in + self?.loadSiteURL(url) + } + } + + private func loadSiteURL(_ url: URL) { + guard let host = bridge?.config.serverURL.host, url.host == host else { return } + _ = webView?.load(URLRequest(url: url)) + } + + // MARK: - Page scripts + + /// Injected at document start on every page. + private func shellScript() -> String { + var script = #""" + window.PolyTheme = { + setDark: function (dark) { + try { window.webkit.messageHandlers.polyTheme.postMessage(!!dark); } catch (e) {} + } + }; + + window.PolyShell = { + platform: 'ios', + // Client-side navigation through one of the site's own Next.js links + // when the page has one. Returns false so the caller can do a full load. + navigate: function (path) { + var selector = 'a[href="' + path + '"]'; + var link = document.querySelector('nav[aria-label="Primary"] ' + selector) || document.querySelector(selector); + if (!link) return false; + link.click(); + return true; + } + }; + + // A touch that starts at the left edge belongs to the native back + // swipe; keep it away from the site's swipe-to-open menu drawer. + (function () { + var edgeTouch = false; + var swallow = function (e) { if (edgeTouch) e.stopImmediatePropagation(); }; + var options = { capture: true, passive: true }; + window.addEventListener('touchstart', function (e) { + edgeTouch = e.touches.length === 1 && e.touches[0].clientX <= 24; + swallow(e); + }, options); + window.addEventListener('touchmove', swallow, options); + window.addEventListener('touchend', function (e) { swallow(e); edgeTouch = false; }, options); + window.addEventListener('touchcancel', function (e) { swallow(e); edgeTouch = false; }, options); + })(); + + // The web view starts below the status bar, so the mobile header + // doesn't need the extra 0.75rem it adds on top of the safe area. + (function () { + var style = document.createElement('style'); + style.textContent = 'header.safe-area-top { padding-top: var(--safe-area-top) !important; }'; + (document.head || document.documentElement).appendChild(style); + })(); + """# + + if usesNativeTabBar { + script += #""" + + // The native tab bar replaces the site's bottom nav. The tab bar's + // height reaches the page as env(safe-area-inset-bottom), so drop the + // nav's body padding and the solid safe-area strip, letting content + // scroll under the tab bar. + (function () { + var style = document.createElement('style'); + style.textContent = + 'nav[aria-label="Primary"].fixed { display: none !important; }' + + 'body.has-bottom-nav { padding-bottom: 0 !important; }' + + 'html:not(.standalone-pwa) body::after { display: none !important; }'; + (document.head || document.documentElement).appendChild(style); + })(); + """# + } + return script + } +} + +extension MainViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == Self.themeMessageName, + message.frameInfo.isMainFrame, + let dark = message.body as? Bool else { return } + siteThemeOverride = dark + applyTheme(animated: true) + } +} + +/// WKUserContentController retains its handlers, and the web view (which owns +/// the controller) is retained by MainViewController, so register through a +/// weak hop to avoid a cycle. +private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + private weak var target: WKScriptMessageHandler? + + init(_ target: WKScriptMessageHandler) { + self.target = target + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + target?.userContentController(userContentController, didReceive: message) + } +} diff --git a/mobile/ios/App/App/PushRegistration.swift b/mobile/ios/App/App/PushRegistration.swift new file mode 100644 index 0000000..4e0c28d --- /dev/null +++ b/mobile/ios/App/App/PushRegistration.swift @@ -0,0 +1,125 @@ +import Foundation +import Capacitor +import FirebaseCore +import FirebaseMessaging + +/// Bootstraps FCM push registration and forwards the token to the Polymer +/// backend. iOS counterpart of the Android shell's PushRegistration.java. +/// +/// Design notes: +/// - The page-side half is the same JS bootstrap the Android shell injects: +/// it drives window.Capacitor.Plugins.PushNotifications, POSTs the token, +/// and routes notification taps to data.articleUrl. +/// - On iOS the plugin's `registration` event would carry the raw APNs +/// device token, which FCM can't address. The app delegate hands that +/// token to Firebase Messaging here and emits the FCM registration token +/// instead, so /api/push/send keeps fanning out through FCM for both +/// platforms. +/// - Endpoint: POST /api/push/register (same origin as the loaded site) +/// Body: { "token": "...", "platform": "ios" } +/// - GoogleService-Info.plist is gitignored. Builds without it skip Firebase +/// entirely and report a registrationError to JS rather than posting an +/// APNs token the backend can't use. +enum PushRegistration { + + enum RegistrationError: LocalizedError { + case firebaseNotConfigured + case missingToken + + var errorDescription: String? { + switch self { + case .firebaseNotConfigured: + return "Firebase is not configured (GoogleService-Info.plist missing from the app bundle)" + case .missingToken: + return "Firebase Messaging returned no registration token" + } + } + } + + static func configureFirebase() { + guard Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil else { + CAPLog.print("⚡️ GoogleService-Info.plist not bundled; push registration is disabled") + return + } + FirebaseApp.configure() + } + + /// Swaps the APNs device token for an FCM registration token and hands it + /// to @capacitor/push-notifications, which emits it as `registration`. + static func didRegisterForRemoteNotifications(deviceToken: Data) { + guard FirebaseApp.app() != nil else { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, + object: RegistrationError.firebaseNotConfigured) + return + } + let messaging = Messaging.messaging() + messaging.apnsToken = deviceToken + // token(completion:) is deprecated since Firebase 12.18 in favor of + // installation-ID registration (register(completion:)), but lib/fcm.ts + // and the Android app both address devices by FCM registration token. + // Keep using it until the backend moves to installation IDs; it keeps + // working while FirebaseMessagingInstallationIdEnabled is unset. + messaging.token { token, error in + if let token = token { + NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token) + } else { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, + object: error ?? RegistrationError.missingToken) + } + } + } + + /// Injected at document end on every page load. Guarded per document, so + /// a full reload re-attaches listeners; the backend de-dupes re-registers. + /// The permission prompt only appears once; later calls resolve silently. + static let bootstrapScript = #""" + (function () { + try { + if (window.__polyPushBootstrapped) return; + window.__polyPushBootstrapped = true; + var tryInit = function () { + var Cap = window.Capacitor; + if (!Cap || !Cap.Plugins || !Cap.Plugins.PushNotifications) { + return false; + } + var PN = Cap.Plugins.PushNotifications; + PN.addListener('registration', function (token) { + try { + fetch('/api/push/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: token && token.value, + platform: 'ios' + }) + }).catch(function (err) { console.warn('push register POST failed', err); }); + } catch (e) { console.warn('push register threw', e); } + }); + PN.addListener('registrationError', function (err) { + console.warn('push registration error', err); + }); + PN.addListener('pushNotificationActionPerformed', function (action) { + try { + var url = action && action.notification && action.notification.data && action.notification.data.articleUrl; + if (url) window.location.href = url; + } catch (e) { console.warn('push nav failed', e); } + }); + PN.requestPermissions().then(function (res) { + if (res && res.receive === 'granted') { + PN.register(); + } + }).catch(function (err) { console.warn('push perm failed', err); }); + return true; + }; + if (!tryInit()) { + // Wait for Capacitor to finish wiring up. + var attempts = 0; + var iv = setInterval(function () { + attempts += 1; + if (tryInit() || attempts > 40) clearInterval(iv); + }, 250); + } + } catch (e) { console.warn('push init failed', e); } + })(); + """# +} diff --git a/mobile/ios/App/App/SceneDelegate.swift b/mobile/ios/App/App/SceneDelegate.swift new file mode 100644 index 0000000..1bde870 --- /dev/null +++ b/mobile/ios/App/App/SceneDelegate.swift @@ -0,0 +1,47 @@ +import UIKit +import Capacitor + +/// Scene lifecycle for the single app window. iPhone gets the native tab bar +/// (SiteTabBarController); iPad shows the site's own desktop navigation. URL +/// opens arrive here instead of the app delegate, so forward them to +/// Capacitor's proxy the way the stock AppDelegate template does. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + + let site = MainViewController() + let window = UIWindow(windowScene: windowScene) + if windowScene.traitCollection.userInterfaceIdiom == .phone { + window.rootViewController = SiteTabBarController(site: site) + } else { + let host = SiteHostViewController() + host.embed(site) + window.rootViewController = host + } + self.window = window + window.makeKeyAndVisible() + + // Cold launches deliver their link in the connection options rather + // than through the continue / openURLContexts callbacks below. + if let userActivity = connectionOptions.userActivities.first { + self.scene(scene, continue: userActivity) + } + if !connectionOptions.urlContexts.isEmpty { + self.scene(scene, openURLContexts: connectionOptions.urlContexts) + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, open: context.url) + } + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, continue: userActivity) { _ in } + } + +} diff --git a/mobile/ios/App/App/SiteHostViewController.swift b/mobile/ios/App/App/SiteHostViewController.swift new file mode 100644 index 0000000..4aa938e --- /dev/null +++ b/mobile/ios/App/App/SiteHostViewController.swift @@ -0,0 +1,42 @@ +import UIKit + +/// Container for the Capacitor web view. Pins it below the status bar, the +/// way the Android shell lays out its WebView, so the site's headers never +/// sit behind the clock or the camera cutout. The web view still runs under +/// the home indicator and tab bar, which reach the page as +/// env(safe-area-inset-bottom). The status bar strip shows this view's +/// background, which MainViewController keeps in step with the site theme. +class SiteHostViewController: UIViewController { + + func embed(_ site: MainViewController) { + guard site.parent !== self else { return } + if site.parent != nil { + site.willMove(toParent: nil) + site.view.removeFromSuperview() + site.removeFromParent() + } + addChild(site) + site.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(site.view) + NSLayoutConstraint.activate([ + site.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + site.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + site.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + site.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + site.didMove(toParent: self) + // Lets a tab bar track the page's scrolling (minimize on scroll). + if let scrollView = site.webView?.scrollView { + setContentScrollView(scrollView) + } + setNeedsStatusBarAppearanceUpdate() + } + + override var childForStatusBarStyle: UIViewController? { + return children.first + } + + override var childForStatusBarHidden: UIViewController? { + return children.first + } +} diff --git a/mobile/ios/App/App/SiteTabBarController.swift b/mobile/ios/App/App/SiteTabBarController.swift new file mode 100644 index 0000000..919009a --- /dev/null +++ b/mobile/ios/App/App/SiteTabBarController.swift @@ -0,0 +1,162 @@ +import UIKit + +/// The site's primary sections, shown as native tabs on iPhone. +enum SiteSection: Int, CaseIterable { + case home, news, features, opinion, sports + + var path: String { + switch self { + case .home: return "/" + case .news: return "/news" + case .features: return "/features" + case .opinion: return "/opinion" + case .sports: return "/sports" + } + } + + var title: String { + switch self { + case .home: return "Home" + case .news: return "News" + case .features: return "Features" + case .opinion: return "Opinion" + case .sports: return "Sports" + } + } + + var image: UIImage? { + switch self { + case .home: return Self.symbol("house") + case .news: return Self.symbol("newspaper") + case .features: return Self.symbol("building.columns") + case .opinion: return Self.symbol("quote.bubble") + case .sports: return Self.symbol("figure.hockey") ?? Self.symbol("sportscourt") + } + } + + var selectedImage: UIImage? { + switch self { + case .home: return Self.symbol("house.fill") + case .news: return Self.symbol("newspaper.fill") + case .features: return Self.symbol("building.columns.fill") + case .opinion: return Self.symbol("quote.bubble.fill") + case .sports: return Self.symbol("figure.hockey") ?? Self.symbol("sportscourt.fill") + } + } + + // Smaller than the tab bar's default symbol size, which reads heavy with + // these glyphs. The tab bar re-applies its own symbol configuration to + // symbol images, so flatten each one into a plain template image to keep + // the size. + private static func symbol(_ name: String) -> UIImage? { + guard let symbol = UIImage(systemName: name, withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)) else { + return nil + } + return UIGraphicsImageRenderer(size: symbol.size).image { _ in + symbol.draw(at: .zero) + }.withRenderingMode(.alwaysTemplate) + } + + /// The section whose front page is `path`. Deeper pages such as articles + /// return nil: they stay in whichever tab opened them, like a navigation + /// stack would. + init?(frontPagePath path: String) { + guard let section = SiteSection.allCases.first(where: { $0.path == MainViewController.normalizedPath(path) }) else { + return nil + } + self = section + } +} + +/// One per tab. The app has a single web view, which moves into whichever +/// host is selected. +final class TabHostViewController: SiteHostViewController { + let section: SiteSection + + init(section: SiteSection) { + self.section = section + super.init(nibName: nil, bundle: nil) + tabBarItem = UITabBarItem(title: section.title, image: section.image, selectedImage: section.selectedImage) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// iPhone root: a native tab bar over the Capacitor web view, replacing the +/// site's own bottom nav (which the Android shell keeps). Each tab remembers +/// the last page it showed; re-tapping the selected tab returns to its +/// section front, then scrolls to the top. +final class SiteTabBarController: UITabBarController, UITabBarControllerDelegate { + + private let site: MainViewController + private var lastURLs: [SiteSection: URL] = [:] + + init(site: MainViewController) { + self.site = site + site.usesNativeTabBar = true + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var hosts: [TabHostViewController] { + return viewControllers?.compactMap { $0 as? TabHostViewController } ?? [] + } + + private var selectedSection: SiteSection { + return SiteSection(rawValue: selectedIndex) ?? .home + } + + override func viewDidLoad() { + super.viewDidLoad() + delegate = self + tabBar.tintColor = MainViewController.brandRed + viewControllers = SiteSection.allCases.map { TabHostViewController(section: $0) } + if #available(iOS 26.0, *) { + tabBarMinimizeBehavior = .onScrollDown + } + + site.onURLChange = { [weak self] url in + self?.siteDidNavigate(to: url) + } + site.onThemeChange = { [weak self] dark in + self?.tabBar.overrideUserInterfaceStyle = dark ? .dark : .light + } + hosts.first?.embed(site) + } + + private func siteDidNavigate(to url: URL) { + // In-page links to a section front (e.g. from the site's menu) move + // the tab selection along with them. + if let section = SiteSection(frontPagePath: url.path), section != selectedSection, section.rawValue < hosts.count { + hosts[section.rawValue].embed(site) + selectedIndex = section.rawValue + } + lastURLs[selectedSection] = url + } + + // MARK: - UITabBarControllerDelegate + + func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { + guard let host = viewController as? TabHostViewController else { return false } + if host === selectedViewController { + site.showFrontPage(path: host.section.path) + return false + } + host.embed(site) + return true + } + + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + guard let host = viewController as? TabHostViewController else { return } + if let url = lastURLs[host.section] { + site.navigate(to: url.path + (url.query.map { "?" + $0 } ?? "")) + } else { + site.navigate(to: host.section.path) + } + } +} diff --git a/mobile/ios/App/CapApp-SPM/.gitignore b/mobile/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/mobile/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/mobile/ios/App/CapApp-SPM/Package.swift b/mobile/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 0000000..be49f0f --- /dev/null +++ b/mobile/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "6.2.1"), + .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@6.0.3_@capacitor+core@6.2.1/node_modules/@capacitor/app"), + .package(name: "CapacitorPushNotifications", path: "../../../node_modules/.pnpm/@capacitor+push-notifications@6.0.5_@capacitor+core@6.2.1/node_modules/@capacitor/push-notifications"), + .package(name: "CapacitorSplashScreen", path: "../../../node_modules/.pnpm/@capacitor+splash-screen@6.0.4_@capacitor+core@6.2.1/node_modules/@capacitor/splash-screen"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/.pnpm/@capacitor+status-bar@6.0.3_@capacitor+core@6.2.1/node_modules/@capacitor/status-bar") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"), + .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") + ] + ) + ] +) diff --git a/mobile/ios/App/CapApp-SPM/README.md b/mobile/ios/App/CapApp-SPM/README.md new file mode 100644 index 0000000..9b9fd37 --- /dev/null +++ b/mobile/ios/App/CapApp-SPM/README.md @@ -0,0 +1,8 @@ +# CapApp-SPM + +> [!WARNING] +> SPM Support is currently experimental. + +This SPM is used to host SPM dependancies for you Capacitor project + +Do not modifiy the contents of it or there may be unintended concquences. diff --git a/mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 0000000..945afec --- /dev/null +++ b/mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/mobile/package.json b/mobile/package.json index df3e864..e84d116 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -2,19 +2,24 @@ "name": "the-poly-mobile", "version": "0.0.0", "private": true, - "description": "Capacitor Android shell for The Polytechnic (poly.rpi.edu).", + "description": "Capacitor Android and iOS shells for The Polytechnic (poly.rpi.edu).", "scripts": { "sync": "cap sync android", "open": "cap open android", "run:android": "cap run android", + "sync:ios": "cap sync ios", + "open:ios": "cap open ios", + "run:ios": "cap run ios", "assets": "capacitor-assets generate --android", "build:debug": "cd android && ./gradlew assembleDebug", - "build:release": "cd android && ./gradlew assembleRelease" + "build:release": "cd android && ./gradlew assembleRelease", + "build:ios": "cd ios/App && xcodebuild -project App.xcodeproj -scheme App -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath build CODE_SIGNING_ALLOWED=NO build" }, "dependencies": { "@capacitor/android": "^6.2.0", "@capacitor/app": "^6.0.2", "@capacitor/core": "^6.2.0", + "@capacitor/ios": "^6.2.0", "@capacitor/push-notifications": "^6.0.4", "@capacitor/splash-screen": "^6.0.3", "@capacitor/status-bar": "^6.0.2" diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 0443fc0..8189aea 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@capacitor/core': specifier: ^6.2.0 version: 6.2.1 + '@capacitor/ios': + specifier: ^6.2.0 + version: 6.2.1(@capacitor/core@6.2.1) '@capacitor/push-notifications': specifier: ^6.0.4 version: 6.0.5(@capacitor/core@6.2.1) @@ -29,10 +32,10 @@ importers: devDependencies: '@capacitor/assets': specifier: ^3.0.5 - version: 3.0.5(@types/node@25.6.0)(typescript@5.9.3) + version: 3.0.5(@types/node@25.6.0)(supports-color@5.5.0)(typescript@5.9.3) '@capacitor/cli': specifier: ^6.2.0 - version: 6.2.1 + version: 6.2.1(supports-color@5.5.0) typescript: specifier: ^5.6.3 version: 5.9.3 @@ -75,6 +78,11 @@ packages: '@capacitor/core@6.2.1': resolution: {integrity: sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==} + '@capacitor/ios@6.2.1': + resolution: {integrity: sha512-tbMlQdQjxe1wyaBvYVU1yTojKJjgluZQsJkALuJxv/6F8QTw5b6vd7X785O/O7cMpIAZfUWo/vtAHzFkRV+kXw==} + peerDependencies: + '@capacitor/core': ^6.2.0 + '@capacitor/push-notifications@6.0.5': resolution: {integrity: sha512-CsRmb0cnZd9Uwx24ym4My5fNKrQvwI4D51aMEph5pUZy+LAjp6q0y4NJe8DEgwaVdAqVLbb4rqn75AZ4WSiFYg==} peerDependencies: @@ -238,6 +246,7 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} @@ -465,10 +474,12 @@ packages: conventional-changelog-atom@2.0.8: resolution: {integrity: sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-codemirror@2.0.8: resolution: {integrity: sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-conventionalcommits@4.6.3: resolution: {integrity: sha512-LTTQV4fwOM4oLPad317V/QNQ1FY4Hju5qeBIM1uTHbrnCE+Eg4CdRZ3gO2pUeR+tzWdp80M2j3qFFEDWVqOV4g==} @@ -477,26 +488,32 @@ packages: conventional-changelog-core@4.2.4: resolution: {integrity: sha512-gDVS+zVJHE2v4SLc6B0sLsPiloR0ygU7HaDW14aNJE1v4SlqJPILPl/aJC7YdtRE4CybBf8gDwObBvKha8Xlyg==} engines: {node: '>=10'} + deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. conventional-changelog-ember@2.0.9: resolution: {integrity: sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-eslint@3.0.9: resolution: {integrity: sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-express@2.0.6: resolution: {integrity: sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jquery@3.0.11: resolution: {integrity: sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jshint@2.0.9: resolution: {integrity: sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-preset-loader@2.3.4: resolution: {integrity: sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g==} @@ -731,7 +748,7 @@ packages: git-raw-commits@2.0.11: resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-remote-origin-url@2.0.0: @@ -741,7 +758,7 @@ packages: git-semver-tags@4.1.1: resolution: {integrity: sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true gitconfiglocal@1.0.0: @@ -1554,6 +1571,7 @@ packages: uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -1681,14 +1699,14 @@ snapshots: dependencies: '@capacitor/core': 6.2.1 - '@capacitor/assets@3.0.5(@types/node@25.6.0)(typescript@5.9.3)': + '@capacitor/assets@3.0.5(@types/node@25.6.0)(supports-color@5.5.0)(typescript@5.9.3)': dependencies: - '@capacitor/cli': 5.7.8 - '@ionic/utils-array': 2.1.6 - '@ionic/utils-fs': 3.1.7 - '@trapezedev/project': 7.1.3(@types/node@25.6.0)(typescript@5.9.3) + '@capacitor/cli': 5.7.8(supports-color@5.5.0) + '@ionic/utils-array': 2.1.6(supports-color@5.5.0) + '@ionic/utils-fs': 3.1.7(supports-color@5.5.0) + '@trapezedev/project': 7.1.3(@types/node@25.6.0)(supports-color@5.5.0)(typescript@5.9.3) commander: 8.3.0 - debug: 4.3.4 + debug: 4.3.4(supports-color@5.5.0) fs-extra: 10.1.0 node-fetch: 2.7.0 node-html-parser: 5.4.2 @@ -1706,17 +1724,17 @@ snapshots: - supports-color - typescript - '@capacitor/cli@5.7.8': + '@capacitor/cli@5.7.8(supports-color@5.5.0)': dependencies: - '@ionic/cli-framework-output': 2.2.8 - '@ionic/utils-fs': 3.1.7 - '@ionic/utils-subprocess': 2.1.14 - '@ionic/utils-terminal': 2.3.5 + '@ionic/cli-framework-output': 2.2.8(supports-color@5.5.0) + '@ionic/utils-fs': 3.1.7(supports-color@5.5.0) + '@ionic/utils-subprocess': 2.1.14(supports-color@5.5.0) + '@ionic/utils-terminal': 2.3.5(supports-color@5.5.0) commander: 9.5.0 - debug: 4.3.4 + debug: 4.3.4(supports-color@5.5.0) env-paths: 2.2.1 kleur: 4.1.5 - native-run: 2.0.3 + native-run: 2.0.3(supports-color@5.5.0) open: 8.4.2 plist: 3.1.0 prompts: 2.4.2 @@ -1728,17 +1746,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@capacitor/cli@6.2.1': + '@capacitor/cli@6.2.1(supports-color@5.5.0)': dependencies: - '@ionic/cli-framework-output': 2.2.8 - '@ionic/utils-fs': 3.1.7 - '@ionic/utils-subprocess': 2.1.11 - '@ionic/utils-terminal': 2.3.5 + '@ionic/cli-framework-output': 2.2.8(supports-color@5.5.0) + '@ionic/utils-fs': 3.1.7(supports-color@5.5.0) + '@ionic/utils-subprocess': 2.1.11(supports-color@5.5.0) + '@ionic/utils-terminal': 2.3.5(supports-color@5.5.0) commander: 9.5.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) env-paths: 2.2.1 kleur: 4.1.5 - native-run: 2.0.3 + native-run: 2.0.3(supports-color@5.5.0) open: 8.4.2 plist: 3.1.0 prompts: 2.4.2 @@ -1754,6 +1772,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@capacitor/ios@6.2.1(@capacitor/core@6.2.1)': + dependencies: + '@capacitor/core': 6.2.1 + '@capacitor/push-notifications@6.0.5(@capacitor/core@6.2.1)': dependencies: '@capacitor/core': 6.2.1 @@ -1772,126 +1794,126 @@ snapshots: '@hutson/parse-repository-url@3.0.2': {} - '@ionic/cli-framework-output@2.2.8': + '@ionic/cli-framework-output@2.2.8(supports-color@5.5.0)': dependencies: - '@ionic/utils-terminal': 2.3.5 - debug: 4.4.3 + '@ionic/utils-terminal': 2.3.5(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-array@2.1.5': + '@ionic/utils-array@2.1.5(supports-color@5.5.0)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-array@2.1.6': + '@ionic/utils-array@2.1.6(supports-color@5.5.0)': dependencies: - debug: 4.3.4 + debug: 4.3.4(supports-color@5.5.0) tslib: 2.6.2 transitivePeerDependencies: - supports-color - '@ionic/utils-fs@3.1.6': + '@ionic/utils-fs@3.1.6(supports-color@5.5.0)': dependencies: '@types/fs-extra': 8.1.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) fs-extra: 9.1.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-fs@3.1.7': + '@ionic/utils-fs@3.1.7(supports-color@5.5.0)': dependencies: '@types/fs-extra': 8.1.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) fs-extra: 9.1.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-object@2.1.5': + '@ionic/utils-object@2.1.5(supports-color@5.5.0)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-object@2.1.6': + '@ionic/utils-object@2.1.6(supports-color@5.5.0)': dependencies: - debug: 4.3.4 + debug: 4.3.4(supports-color@5.5.0) tslib: 2.6.2 transitivePeerDependencies: - supports-color - '@ionic/utils-process@2.1.10': + '@ionic/utils-process@2.1.10(supports-color@5.5.0)': dependencies: - '@ionic/utils-object': 2.1.5 - '@ionic/utils-terminal': 2.3.3 - debug: 4.4.3 + '@ionic/utils-object': 2.1.5(supports-color@5.5.0) + '@ionic/utils-terminal': 2.3.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@5.5.0) signal-exit: 3.0.7 tree-kill: 1.2.2 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-process@2.1.11': + '@ionic/utils-process@2.1.11(supports-color@5.5.0)': dependencies: - '@ionic/utils-object': 2.1.6 - '@ionic/utils-terminal': 2.3.4 - debug: 4.3.4 + '@ionic/utils-object': 2.1.6(supports-color@5.5.0) + '@ionic/utils-terminal': 2.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@5.5.0) signal-exit: 3.0.7 tree-kill: 1.2.2 tslib: 2.6.2 transitivePeerDependencies: - supports-color - '@ionic/utils-stream@3.1.5': + '@ionic/utils-stream@3.1.5(supports-color@5.5.0)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-stream@3.1.6': + '@ionic/utils-stream@3.1.6(supports-color@5.5.0)': dependencies: - debug: 4.3.4 + debug: 4.3.4(supports-color@5.5.0) tslib: 2.6.2 transitivePeerDependencies: - supports-color - '@ionic/utils-subprocess@2.1.11': + '@ionic/utils-subprocess@2.1.11(supports-color@5.5.0)': dependencies: - '@ionic/utils-array': 2.1.5 - '@ionic/utils-fs': 3.1.6 - '@ionic/utils-process': 2.1.10 - '@ionic/utils-stream': 3.1.5 - '@ionic/utils-terminal': 2.3.3 + '@ionic/utils-array': 2.1.5(supports-color@5.5.0) + '@ionic/utils-fs': 3.1.6(supports-color@5.5.0) + '@ionic/utils-process': 2.1.10(supports-color@5.5.0) + '@ionic/utils-stream': 3.1.5(supports-color@5.5.0) + '@ionic/utils-terminal': 2.3.3(supports-color@5.5.0) cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@ionic/utils-subprocess@2.1.14': + '@ionic/utils-subprocess@2.1.14(supports-color@5.5.0)': dependencies: - '@ionic/utils-array': 2.1.6 - '@ionic/utils-fs': 3.1.7 - '@ionic/utils-process': 2.1.11 - '@ionic/utils-stream': 3.1.6 - '@ionic/utils-terminal': 2.3.4 + '@ionic/utils-array': 2.1.6(supports-color@5.5.0) + '@ionic/utils-fs': 3.1.7(supports-color@5.5.0) + '@ionic/utils-process': 2.1.11(supports-color@5.5.0) + '@ionic/utils-stream': 3.1.6(supports-color@5.5.0) + '@ionic/utils-terminal': 2.3.4(supports-color@5.5.0) cross-spawn: 7.0.6 - debug: 4.3.4 + debug: 4.3.4(supports-color@5.5.0) tslib: 2.6.2 transitivePeerDependencies: - supports-color - '@ionic/utils-terminal@2.3.3': + '@ionic/utils-terminal@2.3.3(supports-color@5.5.0)': dependencies: '@types/slice-ansi': 4.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) signal-exit: 3.0.7 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -1902,10 +1924,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@ionic/utils-terminal@2.3.4': + '@ionic/utils-terminal@2.3.4(supports-color@5.5.0)': dependencies: '@types/slice-ansi': 4.0.0 - debug: 4.3.4 + debug: 4.3.4(supports-color@5.5.0) signal-exit: 3.0.7 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -1916,10 +1938,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@ionic/utils-terminal@2.3.5': + '@ionic/utils-terminal@2.3.5(supports-color@5.5.0)': dependencies: '@types/slice-ansi': 4.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) signal-exit: 3.0.7 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -1964,10 +1986,10 @@ snapshots: '@trapezedev/gradle-parse@7.1.3': {} - '@trapezedev/project@7.1.3(@types/node@25.6.0)(typescript@5.9.3)': + '@trapezedev/project@7.1.3(@types/node@25.6.0)(supports-color@5.5.0)(typescript@5.9.3)': dependencies: - '@ionic/utils-fs': 3.1.7 - '@ionic/utils-subprocess': 2.1.14 + '@ionic/utils-fs': 3.1.7(supports-color@5.5.0) + '@ionic/utils-subprocess': 2.1.14(supports-color@5.5.0) '@prettier/plugin-xml': 2.2.0 '@trapezedev/gradle-parse': 7.1.3 '@xmldom/xmldom': 0.7.13 @@ -2350,13 +2372,17 @@ snapshots: dateformat@3.0.3: {} - debug@4.3.4: + debug@4.3.4(supports-color@5.5.0): dependencies: ms: 2.1.2 + optionalDependencies: + supports-color: 5.5.0 - debug@4.4.3: + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 decamelize-keys@1.1.1: dependencies: @@ -2805,12 +2831,12 @@ snapshots: napi-build-utils@2.0.0: {} - native-run@2.0.3: + native-run@2.0.3(supports-color@5.5.0): dependencies: - '@ionic/utils-fs': 3.1.7 - '@ionic/utils-terminal': 2.3.5 + '@ionic/utils-fs': 3.1.7(supports-color@5.5.0) + '@ionic/utils-terminal': 2.3.5(supports-color@5.5.0) bplist-parser: 0.3.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) elementtree: 0.1.7 ini: 4.1.3 plist: 3.1.0 diff --git a/public/print/header-rule-preview.html b/public/print/header-rule-preview.html new file mode 100644 index 0000000..f94d217 --- /dev/null +++ b/public/print/header-rule-preview.html @@ -0,0 +1,37 @@ +
Weights at 3× — left third, where the fan opens
hairline — stroke 0.5
+ Polytechnic header rule (print) + + + + + + + +
light — stroke 0.75
+ Polytechnic header rule (print) + + + + + + + +
regular — stroke 1
+ Polytechnic header rule (print) + + + + + + + +
bold — stroke 1.5
+ Polytechnic header rule (print) + + + + + + + +
\ No newline at end of file diff --git a/public/print/header-rule.svg b/public/print/header-rule.svg new file mode 100644 index 0000000..7700e44 --- /dev/null +++ b/public/print/header-rule.svg @@ -0,0 +1,10 @@ + + Polytechnic header rule (print) + + + + + + + + diff --git a/scripts/generate-print-rule.ts b/scripts/generate-print-rule.ts new file mode 100644 index 0000000..f7abdac --- /dev/null +++ b/scripts/generate-print-rule.ts @@ -0,0 +1,79 @@ +/** + * Emits the print translation of the header's logo rule + wave fleet. + * + * The site header draws the fleet with a left-to-right gradient and animates it + * in (stroke-dashoffset draw, then a "crystallize" fade). Print gets the same + * geometry frozen at full draw: every stroke solid black at full opacity, no + * gradient, no animation, no tints — solid hairlines reproduce cleanly on + * newsprint where a 40% tint goes muddy. The fan still reads because the waves + * differ in baseline offset and amplitude, not in color. + * + * Usage: pnpm exec tsx scripts/generate-print-rule.ts [outDir] + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { + HEADER_WAVE_CONVERGE, + HEADER_WAVE_START_X, + HEADER_WAVE_SVG_H, + generateWaveFleet, +} from '../lib/headerWaveFleet' + +const WAVE_COUNT = 4 + +/** Fleet lengths, in wavelengths of the 320px base. */ +const LENGTHS = [ + { name: 'short', converge: 640 }, + { name: 'medium', converge: 960 }, + { name: 'full', converge: HEADER_WAVE_CONVERGE }, + { name: 'extended', converge: 1920 }, +] + +/** Stroke weights in viewBox units, at the asset's native 1:1 scale. */ +const WEIGHTS = [ + { name: 'hairline', stroke: 0.5 }, + { name: 'light', stroke: 0.75 }, + { name: 'regular', stroke: 1 }, + { name: 'bold', stroke: 1.5 }, +] + +export function buildPrintRule({ + waveCount = WAVE_COUNT, + stroke = 1, + converge = HEADER_WAVE_CONVERGE, +} = {}) { + const baseline = HEADER_WAVE_SVG_H / 2 + const endX = HEADER_WAVE_START_X + converge + + const waves = generateWaveFleet(waveCount, { converge }) + .map( + ({ d }) => + ` `, + ) + .join('\n') + + return ` + Polytechnic header rule (print) + + +${waves} + + +` +} + +const outDir = process.argv[2] ?? resolve(process.cwd(), 'public/print') +mkdirSync(outDir, { recursive: true }) + +for (const { name: lengthName, converge } of LENGTHS) { + for (const { name: weightName, stroke } of WEIGHTS) { + const file = resolve(outDir, `header-rule-${lengthName}-${weightName}.svg`) + writeFileSync(file, buildPrintRule({ converge, stroke })) + console.log(`wrote ${file}`) + } +} + +// Canonical asset: full length, regular weight. +const canonical = resolve(outDir, 'header-rule.svg') +writeFileSync(canonical, buildPrintRule()) +console.log(`wrote ${canonical}`) From 27452a36116be80f850c4ea21792ead27fd38921 Mon Sep 17 00:00:00 2001 From: Ronan Hevenor Date: Wed, 16 Sep 2026 23:05:37 -0400 Subject: [PATCH 2/2] feat(search): progressive results, filters, and /search history over the page - Result rows are fully clickable; pointer cursor on close and paging buttons - One shared search overlay (header buttons + Ctrl/Cmd+Space) pushes /search?q= over the current page, so back from an article returns to the blurred results at the same scroll position - Filters (section, date range, sort) written into the status sentence and kept in the /search URL - API returns headline matches first (fast headline-only request shown while the body scan finishes); search rate limit raised to 80/10s for the two requests per search - Status sentence types out once, results slide in top-down with divider lines drawing in, filter words go rainbow while loading - Link to the online archives next to the Folsom Library archive Co-Authored-By: Claude Opus 5 --- app/(frontend)/layout.tsx | 6 +- app/(frontend)/search/page.tsx | 23 +- app/api/search/route.ts | 130 +++-- .../Article/Photofeature/ArticleHeader.tsx | 9 +- components/ArticleScrollBar.tsx | 6 +- components/HeaderClient.tsx | 13 +- components/SearchInput.tsx | 35 +- components/SearchOverlay.tsx | 521 +++++++++++++++--- components/SearchOverlayHost.tsx | 50 ++ utils/search.ts | 38 ++ 10 files changed, 647 insertions(+), 184 deletions(-) create mode 100644 components/SearchOverlayHost.tsx diff --git a/app/(frontend)/layout.tsx b/app/(frontend)/layout.tsx index 8fe0933..33683ba 100644 --- a/app/(frontend)/layout.tsx +++ b/app/(frontend)/layout.tsx @@ -12,6 +12,7 @@ import configPromise from "@/payload.config"; import { User } from "@/payload-types"; import ThemeStyle from "@/components/ThemeStyle"; import BottomNav from "@/components/BottomNav"; +import SearchOverlayHost from "@/components/SearchOverlayHost"; import { getTheme } from "@/lib/getTheme"; import { getSeo } from "@/lib/getSeo"; @@ -179,7 +180,10 @@ export default async function RootLayout({ - {children} + + {children} + + diff --git a/app/(frontend)/search/page.tsx b/app/(frontend)/search/page.tsx index ec40877..20eaed8 100644 --- a/app/(frontend)/search/page.tsx +++ b/app/(frontend)/search/page.tsx @@ -1,8 +1,5 @@ -import React from 'react'; import type { Metadata } from 'next'; -import Header from '@/components/Header'; -import SearchInput from '@/components/SearchInput'; -import { sanitizeSearchQuery } from '@/utils/search'; +import SearchOverlay from '@/components/SearchOverlay'; import { getSeo } from '@/lib/getSeo'; export async function generateMetadata(): Promise { @@ -16,20 +13,12 @@ export async function generateMetadata(): Promise { } } -type Args = { - searchParams: Promise<{ q?: string }>; -}; - -export default async function SearchPage({ searchParams }: Args) { - const { q } = await searchParams; - const query = sanitizeSearchQuery(q); - +// The search overlay on a solid background. Reads ?q= itself so it can pick up +// the overlay's results and scroll position (see SearchOverlay's handoff). +export default function SearchPage() { return ( -
-
-
- -
+
+
); } diff --git a/app/api/search/route.ts b/app/api/search/route.ts index c54baa2..0e86722 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -6,13 +6,16 @@ import { formatArticle } from "@/utils/formatArticle"; import { Article } from "@/components/FrontPage/types"; import { DEFAULT_SEARCH_PAGE_SIZE, + parseSearchFilters, parseSearchPage, parseSearchPageSize, sanitizeSearchQuery, + searchRangeStart, + type SearchFilters, } from "@/utils/search"; import { checkRateLimit } from "@/utils/rateLimit"; -const SEARCH_RATE_LIMIT = 40; +const SEARCH_RATE_LIMIT = 80; // each overlay search sends two requests (headline + full) const SEARCH_RATE_LIMIT_WINDOW_MS = 10_000; // Generate alternate separator forms of the query so "anti-discrimination", @@ -42,58 +45,92 @@ type PayloadSearchArticle = { _status?: string | null; }; -async function searchPayload(queryFormsLower: string[], page: number, pageSize: number) { - const payload = await getPayload({ config }); - const articleSearchSelect = { - title: true, - plainTitle: true, - slug: true, - subdeck: true, - featuredImage: true, - section: true, - kicker: true, - publishedDate: true, - createdAt: true, - authors: true, - writeInAuthors: true, - isFollytechnic: true, - } as const; - - // Build OR conditions: match any query form in plainTitle, subdeck, or kicker - // (title is a richText field; plainTitle is the auto-derived plain-text version used for search) - const orConditions: Where[] = []; - for (const form of queryFormsLower) { - orConditions.push({ plainTitle: { like: form } }); - orConditions.push({ subdeck: { like: form } }); - orConditions.push({ kicker: { like: form } }); - orConditions.push({ 'writeInAuthors.name': { like: form } }); - orConditions.push({ plainContent: { like: form } }); - } +const articleSearchSelect = { + title: true, + plainTitle: true, + slug: true, + subdeck: true, + featuredImage: true, + section: true, + kicker: true, + publishedDate: true, + createdAt: true, + authors: true, + writeInAuthors: true, + isFollytechnic: true, +} as const; + +// Short fields scan fast, so these matches come back first (title is a richText +// field; plainTitle is the auto-derived plain-text version used for search). +const HEADLINE_FIELDS = ["plainTitle", "subdeck", "kicker", "writeInAuthors.name"]; +const BODY_FIELDS = ["plainContent"]; + +function matchAny(fields: string[], queryFormsLower: string[]): Where { + return { or: queryFormsLower.flatMap((form) => fields.map((field) => ({ [field]: { like: form } }))) }; +} + +type Payload = Awaited>; +async function matchingIds(payload: Payload, where: Where, sort: string): Promise { const result = await payload.find({ collection: "articles", - where: { - and: [ - { _status: { equals: "published" } }, - { or: orConditions }, - ], - }, - sort: "-publishedDate", - limit: pageSize, - page, + where, + sort, + pagination: false, + depth: 0, + select: { publishedDate: true }, + }); + return result.docs.map((doc) => doc.id); +} + +async function articlesByIds(payload: Payload, ids: number[]): Promise { + if (ids.length === 0) return []; + const result = await payload.find({ + collection: "articles", + where: { id: { in: ids } }, + pagination: false, depth: 1, select: articleSearchSelect, }); + const docsById = new Map(result.docs.map((doc) => [doc.id, doc])); + return ids + .map((id) => docsById.get(id)) + .map((doc) => doc && formatArticle(doc as unknown as Parameters[0], { absoluteDate: true })) + .filter((a): a is Article => !!a); +} + +// Headline matches come first, then articles that only mention the query in the +// body, each newest (or oldest) first. `headlineOnly` skips the slow body scan so +// the client can show the first results while the full search finishes. +async function searchPayload( + queryFormsLower: string[], + page: number, + pageSize: number, + filters: SearchFilters, + headlineOnly: boolean, +) { + const payload = await getPayload({ config }); + const base: Where[] = [{ _status: { equals: "published" } }]; + if (filters.section) base.push({ section: { equals: filters.section } }); + const since = searchRangeStart(filters.range); + if (since) base.push({ publishedDate: { greater_than_equal: since.toISOString() } }); + const sort = filters.sort === "oldest" ? "publishedDate" : "-publishedDate"; - const articles = result.docs - .map((doc) => formatArticle(doc as unknown as Parameters[0], { absoluteDate: true })) - .filter((a): a is Article => a !== null); + const headlineWhere: Where = { and: [...base, matchAny(HEADLINE_FIELDS, queryFormsLower)] }; + const bodyWhere: Where = { and: [...base, matchAny(BODY_FIELDS, queryFormsLower)] }; + const [headlineIds, bodyIds] = await Promise.all([ + matchingIds(payload, headlineWhere, sort), + headlineOnly ? Promise.resolve([]) : matchingIds(payload, bodyWhere, sort), + ]); + const headlineSet = new Set(headlineIds); + const ids = [...headlineIds, ...bodyIds.filter((id) => !headlineSet.has(id))]; + const offset = (page - 1) * pageSize; return { - articles, - totalDocs: result.totalDocs, - totalPages: result.totalPages, - page: result.page ?? page, + articles: await articlesByIds(payload, ids.slice(offset, offset + pageSize)), + totalDocs: ids.length, + totalPages: Math.ceil(ids.length / pageSize), + page, }; } @@ -120,6 +157,8 @@ export async function GET(request: NextRequest) { const q = sanitizeSearchQuery(request.nextUrl.searchParams.get("q")); const page = parseSearchPage(request.nextUrl.searchParams.get("page")); const pageSize = parseSearchPageSize(request.nextUrl.searchParams.get("pageSize")); + const filters = parseSearchFilters(request.nextUrl.searchParams); + const headlineOnly = request.nextUrl.searchParams.get("part") === "headline"; if (!q) { return Response.json({ @@ -136,7 +175,7 @@ export async function GET(request: NextRequest) { const queryFormsLower = forms.map((form) => form.toLowerCase()).filter((form) => form.length > 0); try { - const result = await searchPayload(queryFormsLower, page, pageSize); + const result = await searchPayload(queryFormsLower, page, pageSize, filters, headlineOnly); return Response.json({ articles: result.articles, @@ -145,6 +184,7 @@ export async function GET(request: NextRequest) { query: q, totalPages: result.totalPages, totalResults: result.totalDocs, + partial: headlineOnly, }); } catch { return Response.json({ diff --git a/components/Article/Photofeature/ArticleHeader.tsx b/components/Article/Photofeature/ArticleHeader.tsx index 606df43..6e3cc5f 100644 --- a/components/Article/Photofeature/ArticleHeader.tsx +++ b/components/Article/Photofeature/ArticleHeader.tsx @@ -7,7 +7,7 @@ import Link from 'next/link'; import { Menu, Search } from 'lucide-react'; import { Article, Media, User } from '@/payload-types'; import { MobileMenuDrawer } from '@/components/MobileMenuDrawer'; -import SearchOverlay from '@/components/SearchOverlay'; +import { openSearchOverlay } from '@/components/SearchOverlayHost'; import { useTheme } from '@/components/ThemeProvider'; import { focalObjectPosition } from '@/utils/focalPoint'; import { resolveCredit } from '@/components/Article/PhotoCaption'; @@ -18,7 +18,6 @@ type Props = { export const ArticleHeader: React.FC = ({ article }) => { const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isSearchOpen, setIsSearchOpen] = useState(false); const { isDarkMode, toggleDarkMode, logoSrcs } = useTheme(); const featuredImage = article.featuredImage as Media | null; // Same precedence galleries use: explicit credit, then the media record's @@ -98,7 +97,7 @@ export const ArticleHeader: React.FC = ({ article }) => { {/* Right: Search */}
- {isHome && searchOpen && setSearchOpen(false)} />} ); } diff --git a/components/HeaderClient.tsx b/components/HeaderClient.tsx index f958c16..d75aef5 100644 --- a/components/HeaderClient.tsx +++ b/components/HeaderClient.tsx @@ -6,7 +6,8 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudRain, CloudSnow, Cloudy, Menu, Moon, Search, Sun, Wind, X } from "lucide-react"; -import SearchOverlay, { SearchOverlayTrigger } from "@/components/SearchOverlay"; +import { SearchOverlayTrigger } from "@/components/SearchOverlay"; +import { openSearchOverlay } from "@/components/SearchOverlayHost"; import { MobileMenuDrawer, primaryNavItems, secondaryNavItems, isExternalHref } from "@/components/MobileMenuDrawer"; import { useHeaderTransition } from "@/components/HeaderTransitionProvider"; import { @@ -92,7 +93,6 @@ function pickWeatherIcon(forecast: string): React.ComponentType<{ className?: st export default function Header({ compact = false, mobileTight = false, logoSrcs, headerAnimation = DEFAULT_HEADER_ANIMATION, volume, edition, liveEntries, weather }: { compact?: boolean; mobileTight?: boolean; logoSrcs?: HeaderLogoSrcs; headerAnimation?: HeaderAnimationConfig; volume?: number | null; edition?: number | null; liveEntries?: LiveArticleStripEntry[]; weather?: HeaderWeather }) { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const [isSearchOverlayOpen, setIsSearchOverlayOpen] = useState(false); const [showDarkModePrompt, setShowDarkModePrompt] = useState(false); const currentDate = useCurrentDate(); const { animationKey, phase, isAnimating, navigateImmediately, triggerTransition, suckDurationMs, shootDurationMs } = useHeaderTransition(); @@ -114,10 +114,6 @@ export default function Header({ compact = false, mobileTight = false, logoSrcs, const shootWrapPathLength = wrapAround ? (logoOutlineRightX - logoOutlineLeftX) + (logoBaselineY - logoOutlineTopY) * 2 : 0; const shootWrapPathD = wrapAround ? `M ${logoOutlineRightX} ${logoBaselineY} V ${logoOutlineTopY} H ${logoOutlineLeftX} V ${logoBaselineY}` : ''; - const openSearchOverlay = () => { - setIsSearchOverlayOpen(true); - }; - const prefetchLink = (href: string) => { if (!href.startsWith("/")) return; @@ -226,7 +222,7 @@ export default function Header({ compact = false, mobileTight = false, logoSrcs, />
-
- + openSearchOverlay()} /> @@ -586,7 +582,6 @@ export default function Header({ compact = false, mobileTight = false, logoSrcs,
{/* */} - {isSearchOverlayOpen && setIsSearchOverlayOpen(false)} />} ); } diff --git a/components/SearchInput.tsx b/components/SearchInput.tsx index 8d78d0d..e5c942d 100644 --- a/components/SearchInput.tsx +++ b/components/SearchInput.tsx @@ -654,22 +654,21 @@ export default function SearchInput({
{articles.map((article) => ( -
- posthog.capture("search_result_clicked", { query, article_title: article.title, article_section: article.section })} - > -

- {article.title} -

- -

- {article.excerpt} -

-
-
+ posthog.capture("search_result_clicked", { query, article_title: article.title, article_section: article.section })} + > +

+ {article.title} +

+ +

+ {article.excerpt} +

+
))}
{totalPages > 1 && ( @@ -677,7 +676,7 @@ export default function SearchInput({ @@ -685,7 +684,7 @@ export default function SearchInput({ diff --git a/components/SearchOverlay.tsx b/components/SearchOverlay.tsx index d233d9c..6a921fe 100644 --- a/components/SearchOverlay.tsx +++ b/components/SearchOverlay.tsx @@ -1,8 +1,8 @@ "use client"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import Image from "next/image"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Search, X } from "lucide-react"; import { Article } from "@/components/FrontPage/types"; import { Byline } from "@/components/FrontPage/Byline"; @@ -10,9 +10,13 @@ import TransitionLink from "@/components/TransitionLink"; import { getArticleUrl } from "@/utils/getArticleUrl"; import { useTheme } from "@/components/ThemeProvider"; import { + DEFAULT_SEARCH_FILTERS, DEFAULT_SEARCH_PAGE_SIZE, MAX_SEARCH_QUERY_LENGTH, + appendSearchFilters, + parseSearchFilters, sanitizeSearchQuery, + type SearchFilters, } from "@/utils/search"; import { parseArchiveDateQuery } from "@/lib/archiveDateQuery"; import posthog from "posthog-js"; @@ -56,6 +60,139 @@ type SpellCorrectionState = { suggestedResults: number; }; +type SearchSnapshot = { + query: string; + articles: Article[]; + displayCount: number; + searched: boolean; + hasSearchedOnce: boolean; + isLoading: boolean; + archiveSubtitle: string | null; + page: number; + totalResults: number; + totalPages: number; + spellCorrection: SpellCorrectionState | null; + filters: SearchFilters; + resultsKey: string | null; + scrollTop: number; + forceDark: boolean; +}; + +// Search state saved when an overlay unmounts (keyed by its /search URL), so coming +// back to that URL restores the same results and scroll position. +const searchSnapshots = new Map(); + +const SECTION_FILTERS = [ + { value: "", label: "every section" }, + { value: "news", label: "News" }, + { value: "features", label: "Features" }, + { value: "opinion", label: "Opinion" }, + { value: "sports", label: "Sports" }, +]; +const RANGE_FILTERS = [ + { value: "any", label: "all time" }, + { value: "week", label: "the past week" }, + { value: "month", label: "the past month" }, + { value: "year", label: "the past year" }, +]; +const SORT_FILTERS = [ + { value: "newest", label: "newest first" }, + { value: "oldest", label: "oldest first" }, +]; + +function searchPageHref(query: string, filters: SearchFilters) { + const params = new URLSearchParams(); + const q = sanitizeSearchQuery(query); + if (q) params.set("q", q); + const search = appendSearchFilters(params, filters).toString(); + return search ? `/search?${search}` : "/search"; +} + +const resultsKeyFor = (query: string, page: number, filters: SearchFilters) => + `${searchPageHref(query, filters)}|${page}`; + +const TYPE_MS_PER_CHAR = 2; +const TYPE_OUT_MS = 1200; +const RESULT_STAGGER_MS = 30; + +// Types the sentence out a character at a time using staggered CSS delays, so the +// layout never reflows. Components (like InlineSelect) appear as one unit. Only the +// first appearance types; after that it's plain text, so later edits don't flicker. +function TypeOut({ children, enabled }: { children: React.ReactNode; enabled: boolean }) { + const [typing, setTyping] = useState(enabled); + useEffect(() => { + if (!typing) return; + const timer = setTimeout(() => setTyping(false), TYPE_OUT_MS); + return () => clearTimeout(timer); + }, [typing]); + + let index = 0; + const walk = (node: React.ReactNode): React.ReactNode => { + if (typeof node === "string" || typeof node === "number") { + if (!typing) return node; + return Array.from(String(node), (char, i) => ( + + {char} + + )); + } + if (Array.isArray(node)) { + return node.map((child, i) => {walk(child)}); + } + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + if (typeof node.type === "string" || node.type === React.Fragment) { + return React.cloneElement(node, undefined, walk(node.props.children)); + } + const delay = index * TYPE_MS_PER_CHAR; + index += 8; + return typing ? {node} : {node}; + } + return node; + }; + return <>{walk(children)}; +} + +// A word in the status sentence that opens the native picker (the select is +// invisible and stretched over the word, so the text sets the width). +function InlineSelect({ + label, + value, + options, + rainbow, + onChange, +}: { + label: string; + value: string; + options: { value: string; label: string }[]; + // Rainbow while searching, lingering a second before fading back to normal. + rainbow: boolean; + onChange: (value: string) => void; +}) { + const text = (options.find((option) => option.value === value) ?? options[0]).label; + return ( + + {text} + + + + ); +} + export function SearchBarTrigger({ onClick, className = "", @@ -142,13 +279,16 @@ function formatRetryCountdown(totalSeconds: number): string { async function fetchSearchResults( q: string, page: number, + filters: SearchFilters, signal: AbortSignal, + headlineOnly = false, ): Promise { - const params = new URLSearchParams({ + const params = appendSearchFilters(new URLSearchParams({ q, page: String(page), pageSize: String(DEFAULT_SEARCH_PAGE_SIZE), - }); + }), filters); + if (headlineOnly) params.set("part", "headline"); const res = await fetch(`/api/search?${params.toString()}`, { signal }); if (!res.ok) { let errorMessage = "Search request failed"; @@ -174,25 +314,51 @@ async function fetchSearchResults( return res.json() as Promise; } -export default function SearchOverlay({ onClose, forceDark = false }: { onClose: () => void; forceDark?: boolean }) { +export default function SearchOverlay({ + onClose, + forceDark = false, + variant = "overlay", +}: { + onClose?: () => void; + forceDark?: boolean; + // "page" is the /search route itself (direct visits and refreshes). + variant?: "overlay" | "page"; +}) { const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isPage = variant === "page"; + // The overlay sits at /search?q= (over the page it was opened on) after Enter or + // a result click, and when back/forward returns there. + const atSearchUrl = isPage || pathname === "/search"; + const [initialQuery] = useState(() => (atSearchUrl ? sanitizeSearchQuery(searchParams.get("q")) : "")); + const [initialFilters] = useState(() => (atSearchUrl ? parseSearchFilters(searchParams) : DEFAULT_SEARCH_FILTERS)); + const [restored] = useState(() => + atSearchUrl ? searchSnapshots.get(searchPageHref(initialQuery, initialFilters)) ?? null : null, + ); const { isDarkMode: themeDarkMode, logoSrcs } = useTheme(); - const isDarkMode = forceDark || themeDarkMode; + const forceDarkMode = forceDark || !!restored?.forceDark; + const isDarkMode = forceDarkMode || themeDarkMode; const logoSrc = isDarkMode ? logoSrcs.mobileDark : logoSrcs.mobileLight; - const [query, setQuery] = useState(""); - const [articles, setArticles] = useState([]); - const [displayCount, setDisplayCount] = useState(0); - const displayCountRef = useRef(0); - const [searched, setSearched] = useState(false); - const hasSearchedOnceRef = useRef(false); - const [hasSearchedOnce, setHasSearchedOnce] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [archiveSubtitle, setArchiveSubtitle] = useState(null); - const [page, setPage] = useState(0); - const [totalResults, setTotalResults] = useState(0); - const [totalPages, setTotalPages] = useState(0); - const [isVisible, setIsVisible] = useState(false); + const [query, setQuery] = useState(restored?.query ?? initialQuery); + const [articles, setArticles] = useState(restored?.articles ?? []); + const [displayCount, setDisplayCount] = useState(restored?.displayCount ?? 0); + const displayCountRef = useRef(restored?.displayCount ?? 0); + const [searched, setSearched] = useState(restored?.searched ?? false); + const hasSearchedOnceRef = useRef(restored?.hasSearchedOnce ?? false); + const [hasSearchedOnce, setHasSearchedOnce] = useState(restored?.hasSearchedOnce ?? false); + const [isLoading, setIsLoading] = useState(restored?.isLoading ?? false); + const [hasFinishedSearch, setHasFinishedSearch] = useState(!!restored); + const [archiveSubtitle, setArchiveSubtitle] = useState(restored?.archiveSubtitle ?? null); + const [page, setPage] = useState(restored?.page ?? 0); + const [animateResults, setAnimateResults] = useState(!restored); + const [staggerFrom, setStaggerFrom] = useState(0); + const [filters, setFilters] = useState(restored?.filters ?? initialFilters); + const [totalResults, setTotalResults] = useState(restored?.totalResults ?? 0); + const [totalPages, setTotalPages] = useState(restored?.totalPages ?? 0); + const [isVisible, setIsVisible] = useState(isPage || !!restored); const [isClosing, setIsClosing] = useState(false); + const containerRef = useRef(null); const inputRef = useRef(null); const abortRef = useRef(null); const cursorRef = useRef(null); @@ -200,9 +366,13 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: const closeTimerRef = useRef | null>(null); const animFrameRef = useRef(null); const waveTypingTimerRef = useRef | null>(null); + // Query|page the displayed results fully belong to (null while stale or loading). + const resultsKeyRef = useRef(restored?.resultsKey ?? null); + // Restored results are already current, so the first fetch for them is skipped. + const restoredKeyRef = useRef(restored && !restored.isLoading ? restored.resultsKey : null); // Spell check - const [spellCorrection, setSpellCorrection] = useState(null); + const [spellCorrection, setSpellCorrection] = useState(restored?.spellCorrection ?? null); const [rateLimitError, setRateLimitError] = useState(null); const [rateLimitUntil, setRateLimitUntil] = useState(null); const [rateLimitSecondsRemaining, setRateLimitSecondsRemaining] = useState(0); @@ -211,7 +381,7 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: const characterLimitHideTimerRef = useRef | null>(null); const characterLimitResetTimerRef = useRef | null>(null); - const [stage, setStage] = useState(0); + const [stage, setStage] = useState(restored ? 3 : 0); // 0: blank (overlay fading in) // 1: "Search..." typing out // 2: line extends + logo fades in + X drops in @@ -272,6 +442,13 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: setPage(0); }; + const updateFilters = (patch: Partial) => { + const next = { ...filters, ...patch }; + if (next.section === filters.section && next.range === filters.range && next.sort === filters.sort) return; + setFilters(next); + setPage(0); + }; + const updateCursor = useCallback(() => { const input = inputRef.current; const cursor = cursorRef.current; @@ -297,20 +474,79 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: const closingRef = useRef(false); const handleClose = useCallback(() => { + if (isPage) { + if (window.history.length > 1) router.back(); + else router.push("/"); + return; + } if (closingRef.current) return; closingRef.current = true; setIsClosing(true); setIsVisible(false); - closeTimerRef.current = setTimeout(onClose, OVERLAY_TRANSITION_MS); - }, [onClose]); + closeTimerRef.current = setTimeout(() => { + onClose?.(); + // Closing search at /search?q= returns to the page underneath. + if (window.location.pathname === "/search") window.history.back(); + }, OVERLAY_TRANSITION_MS); + }, [isPage, onClose, router]); useEffect(() => { + if (isPage || restored) return; const frame = window.requestAnimationFrame(() => { setIsVisible(true); inputRef.current?.focus(); }); return () => window.cancelAnimationFrame(frame); - }, []); + }, [isPage, restored]); + + const saveSnapshot = () => { + searchSnapshots.set(searchPageHref(query, filters), { + query, + articles, + displayCount, + searched, + hasSearchedOnce, + isLoading, + archiveSubtitle, + page, + totalResults, + totalPages, + spellCorrection, + filters, + resultsKey: resultsKeyRef.current, + scrollTop: containerRef.current?.scrollTop ?? 0, + forceDark: forceDarkMode, + }); + }; + const saveSnapshotRef = useRef(saveSnapshot); + useLayoutEffect(() => { + saveSnapshotRef.current = saveSnapshot; + }); + + // Put /search?q= in history without leaving the page underneath, so back from a + // clicked article returns to this search. + const pushSearchUrl = () => { + if (window.location.pathname !== "/search") window.history.pushState(null, "", searchPageHref(query, filters)); + }; + + // Land at the saved scroll position when restored, and save state on the way out. + useLayoutEffect(() => { + if (restored && containerRef.current) containerRef.current.scrollTop = restored.scrollTop; + return () => saveSnapshotRef.current(); + }, [restored]); + + // At /search, keep ?q= in step with the input so history lands on this query. + useEffect(() => { + if (!atSearchUrl) return; + const timer = setTimeout(() => { + // Compare parsed URLs: the browser re-encodes some characters (e.g. '). + const next = new URL(searchPageHref(query, filters), window.location.origin); + if (next.pathname + next.search !== window.location.pathname + window.location.search) { + window.history.replaceState(null, "", next.pathname + next.search); + } + }, 250); + return () => clearTimeout(timer); + }, [atSearchUrl, filters, query]); // Smooth count-up: accumulate the real total, then animate toward it const targetCountRef = useRef(0); @@ -335,12 +571,19 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: const animateCount = useCallback((target: number) => { targetCountRef.current = target; + if (target < displayCountRef.current) { + displayCountRef.current = target; + setDisplayCount(target); + return; + } startCountAnimation(); }, [startCountAnimation]); - const fetchResults = useCallback(async (rawQuery: string, pageIndex: number) => { + const fetchResults = useCallback(async (rawQuery: string, pageIndex: number, activeFilters: SearchFilters) => { abortRef.current?.abort(); + resultsKeyRef.current = null; const q = sanitizeSearchQuery(rawQuery); + const resultsKey = resultsKeyFor(q, pageIndex, activeFilters); if (!q) { setArticles([]); @@ -358,21 +601,43 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: } if (parseArchiveDateQuery(q)) { - router.push(`/archive?date=${encodeURIComponent(q)}&source=search-overlay`); + const archiveHref = `/archive?date=${encodeURIComponent(q)}&source=search-overlay`; + // Replace on /search so back doesn't bounce into the redirect again. + if (isPage || window.location.pathname === "/search") router.replace(archiveHref); + else router.push(archiveHref); return; } const controller = new AbortController(); abortRef.current = controller; + setAnimateResults(true); + setStaggerFrom(0); setIsLoading(true); setSearched(false); + if (!hasSearchedOnceRef.current) { + hasSearchedOnceRef.current = true; + setHasSearchedOnce(true); + } setSpellCorrection(null); - setDisplayCount(0); - displayCountRef.current = 0; try { - const primaryData = await fetchSearchResults(q, pageIndex + 1, controller.signal); - + // Headline matches skip the slow body scan, so show them while the full + // search (headline matches first, then body mentions) finishes. + const fullRequest = fetchSearchResults(q, pageIndex + 1, activeFilters, controller.signal); + let fullArrived = false; + let shownCount = 0; + fullRequest.then(() => { fullArrived = true; }, () => {}); + fetchSearchResults(q, pageIndex + 1, activeFilters, controller.signal, true).then((headlineData) => { + if (fullArrived || controller.signal.aborted || headlineData.articles.length === 0) return; + shownCount = headlineData.articles.length; + setArticles(headlineData.articles); + setSearched(true); + animateCount(headlineData.totalResults); + }, () => {}); + + const primaryData = await fullRequest; + + setStaggerFrom(shownCount); setArticles(primaryData.articles); setTotalResults(primaryData.totalResults); setTotalPages(primaryData.totalPages); @@ -382,20 +647,22 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: query: q, total_results: primaryData.totalResults, page: pageIndex + 1, - source: "overlay", + section: activeFilters.section ?? "all", + range: activeFilters.range, + sort: activeFilters.sort, + source: isPage ? "page" : "overlay", }); + const needsSpellcheck = pageIndex === 0 && primaryData.totalResults === 0; + if (!needsSpellcheck) resultsKeyRef.current = resultsKey; setRateLimitError(null); setRateLimitUntil(null); setRateLimitSecondsRemaining(0); setSearched(true); - if (!hasSearchedOnceRef.current) { - hasSearchedOnceRef.current = true; - setHasSearchedOnce(true); - } + setHasFinishedSearch(true); setIsLoading(false); // Spellcheck fallback only applies when the original query has zero results. - if (pageIndex === 0 && primaryData.totalResults === 0) { + if (needsSpellcheck) { try { const spellRes = await fetch( `/api/search/spellcheck?q=${encodeURIComponent(q)}`, @@ -406,7 +673,7 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: if (!spellcheckData.suggestion || spellcheckData.suggestion.toLowerCase() === q.toLowerCase()) return; // Re-search with corrected query - const suggestedData = await fetchSearchResults(spellcheckData.suggestion, 1, controller.signal); + const suggestedData = await fetchSearchResults(spellcheckData.suggestion, 1, activeFilters, controller.signal); setSpellCorrection({ originalQuery: q, @@ -422,7 +689,9 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: animateCount(suggestedData.totalResults); setPage(0); } - } catch {} + } catch {} finally { + if (!controller.signal.aborted) resultsKeyRef.current = resultsKey; + } } } catch (e) { if (e instanceof DOMException && e.name === "AbortError") return; @@ -440,13 +709,15 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: } setIsLoading(false); } - }, [animateCount, router]); + }, [animateCount, isPage, router]); useEffect(() => { if (rateLimitUntil && rateLimitUntil > Date.now()) return; - const timer = setTimeout(() => fetchResults(query, page), 250); + if (restoredKeyRef.current === resultsKeyFor(query, page, filters)) return; + restoredKeyRef.current = null; + const timer = setTimeout(() => fetchResults(query, page, filters), 250); return () => clearTimeout(timer); - }, [query, page, fetchResults, rateLimitUntil]); + }, [query, page, filters, fetchResults, rateLimitUntil]); useEffect(() => { if (!rateLimitUntil) return; @@ -493,6 +764,7 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: // Animation sequence — starts immediately, no dead time useEffect(() => { + if (restored) return; const timers: ReturnType[] = []; timers.push(setTimeout(() => { if (!closingRef.current) setStage(1); }, 0)); timers.push(setTimeout(() => { if (!closingRef.current) setStage(2); }, 550)); @@ -501,15 +773,16 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: setStage(3); }, 900)); return () => timers.forEach(clearTimeout); - }, []); + }, [restored]); // Fetch archive subtitle on mount useEffect(() => { + if (restored?.archiveSubtitle) return; fetch("/api/search/archive-date") .then((r) => r.ok ? r.json() : Promise.reject()) .then((data: { subtitle: string }) => setArchiveSubtitle(data.subtitle)) .catch(() => setArchiveSubtitle(null)); - }, []); + }, [restored]); // Lock body scroll and handle Esc useEffect(() => { @@ -541,13 +814,15 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: return (
{ const target = e.target as HTMLElement; - if (!target.closest("input, a, button, [data-search-area]")) handleClose(); + if (!target.closest("input, select, a, button, [data-search-area]")) handleClose(); }} style={{ - ...(forceDark ? { + ...(forceDarkMode ? { '--background': '#0a0a0a', '--foreground': '#e8e8e8', '--foreground-muted': '#c8ced6', @@ -591,6 +866,33 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: from { filter: hue-rotate(0deg); } to { filter: hue-rotate(360deg); } } + @keyframes searchTypeChar { + from { opacity: 0; } + to { opacity: 1; } + } + .search-type-char { + animation: searchTypeChar 60ms ease-out both; + } + @keyframes searchResultIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes searchRuleExtend { + from { transform: scaleX(0); } + to { transform: scaleX(1); } + } + @keyframes rainbowTextShift { + to { background-position: 200% 0; } + } + .search-rainbow-text { + background-image: linear-gradient(90deg, #ff4040, #ff9900, #ffee00, #44dd44, #4488ff, #cc44ff, #ff4040); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + animation: rainbowTextShift 1.5s linear infinite; + } @keyframes rainbowLetterFlash { 0% { color: #f4a6a6; } 16% { color: #f6c7a1; } @@ -620,7 +922,7 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: {/* X button */}
@@ -814,14 +1151,23 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: {searched && displayArticles.length > 0 && (() => { return (
-
- {displayArticles.map((article) => ( -
+
+ {displayArticles.map((article, index) => { + const delay = Math.min(Math.max(0, index - staggerFrom), 10) * RESULT_STAGGER_MS; + return ( { posthog.capture("search_result_clicked", { query, article_title: article.title, article_section: article.section, source: "overlay" }); handleClose(); }} - className="flex flex-col group cursor-pointer" + data-analytics-context={isPage ? "search-page" : "search-overlay"} + onClick={(e) => { + posthog.capture("search_result_clicked", { query, article_title: article.title, article_section: article.section, source: isPage ? "page" : "overlay" }); + if (isPage) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) handleClose(); + // Leave /search?q= in history so back from the article returns here. + else pushSearchUrl(); + }} + className="relative flex flex-col group cursor-pointer py-4 first:pt-0" + style={animateResults ? { animation: `searchResultIn 320ms cubic-bezier(0.22, 1, 0.36, 1) ${delay}ms both` } : undefined} >

{article.title} @@ -830,16 +1176,23 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose:

{article.excerpt}

+ {index < displayArticles.length - 1 && ( +

- ))} + ); + })}
{totalPages > 1 && (
@@ -847,7 +1200,7 @@ export default function SearchOverlay({ onClose, forceDark = false }: { onClose: diff --git a/components/SearchOverlayHost.tsx b/components/SearchOverlayHost.tsx new file mode 100644 index 0000000..85e9972 --- /dev/null +++ b/components/SearchOverlayHost.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { usePathname, useSelectedLayoutSegment } from "next/navigation"; +import SearchOverlay from "@/components/SearchOverlay"; + +const OPEN_EVENT = "polymer:open-search-overlay"; + +export function openSearchOverlay(options: { forceDark?: boolean } = {}) { + window.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: options })); +} + +// The one search overlay for the public site. It opens from the header buttons or +// Ctrl/Cmd+Space, stays up when it pushes /search?q= over the current page, and +// reappears over that page when back/forward lands on that /search entry. +export default function SearchOverlayHost() { + const pathname = usePathname(); + const segment = useSelectedLayoutSegment(); + const [open, setOpen] = useState<{ on: string; forceDark: boolean } | null>(null); + // /search?q= showing another page underneath (not the /search route itself). + const overSearchUrl = pathname === "/search" && segment !== "search"; + + // Close once the route moves anywhere other than the page it opened on or /search. + if (open && pathname !== open.on && pathname !== "/search") setOpen(null); + + const close = useCallback(() => setOpen(null), []); + + useEffect(() => { + const handleOpen = (e: Event) => { + const { forceDark = false } = (e as CustomEvent<{ forceDark?: boolean }>).detail ?? {}; + setOpen({ on: window.location.pathname, forceDark }); + }; + const handleKey = (e: KeyboardEvent) => { + if (e.code !== "Space" || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; + e.preventDefault(); + const input = document.querySelector("[data-search-overlay] input"); + if (input) input.focus(); + else setOpen({ on: window.location.pathname, forceDark: false }); + }; + window.addEventListener(OPEN_EVENT, handleOpen); + window.addEventListener("keydown", handleKey); + return () => { + window.removeEventListener(OPEN_EVENT, handleOpen); + window.removeEventListener("keydown", handleKey); + }; + }, []); + + if (!open && !overSearchUrl) return null; + return ; +} diff --git a/utils/search.ts b/utils/search.ts index e384def..bde6bc1 100644 --- a/utils/search.ts +++ b/utils/search.ts @@ -24,3 +24,41 @@ export function parseSearchPageSize(value: string | null | undefined): number { if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_SEARCH_PAGE_SIZE; return Math.min(parsed, MAX_SEARCH_PAGE_SIZE); } + +export const SEARCH_SECTIONS = ["news", "features", "opinion", "sports"] as const; +export const SEARCH_RANGES = ["any", "week", "month", "year"] as const; +export const SEARCH_SORTS = ["newest", "oldest"] as const; + +export type SearchFilters = { + section: (typeof SEARCH_SECTIONS)[number] | null; + range: (typeof SEARCH_RANGES)[number]; + sort: (typeof SEARCH_SORTS)[number]; +}; + +export const DEFAULT_SEARCH_FILTERS: SearchFilters = { section: null, range: "any", sort: "newest" }; + +export function parseSearchFilters(params: { get(name: string): string | null }): SearchFilters { + const section = params.get("section"); + const range = params.get("range"); + const sort = params.get("sort"); + return { + section: SEARCH_SECTIONS.find((value) => value === section) ?? null, + range: SEARCH_RANGES.find((value) => value === range) ?? "any", + sort: SEARCH_SORTS.find((value) => value === sort) ?? "newest", + }; +} + +// Only non-default filters go into the URL, so plain searches keep plain URLs. +export function appendSearchFilters(params: URLSearchParams, filters: SearchFilters): URLSearchParams { + if (filters.section) params.set("section", filters.section); + if (filters.range !== "any") params.set("range", filters.range); + if (filters.sort !== "newest") params.set("sort", filters.sort); + return params; +} + +const SEARCH_RANGE_DAYS = { week: 7, month: 30, year: 365 } as const; + +export function searchRangeStart(range: SearchFilters["range"], now = new Date()): Date | null { + if (range === "any") return null; + return new Date(now.getTime() - SEARCH_RANGE_DAYS[range] * 24 * 60 * 60 * 1000); +}