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/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 cb69d11..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 { @@ -17,63 +18,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", @@ -146,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(); @@ -168,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; @@ -280,7 +222,7 @@ export default function Header({ compact = false, mobileTight = false, logoSrcs, />
-
- + openSearchOverlay()} /> @@ -640,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/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 0000000..90e8acc Binary files /dev/null and b/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ 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 0000000..92e83ce Binary files /dev/null and b/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo.png differ diff --git a/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo@2x.png b/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo@2x.png new file mode 100644 index 0000000..e315673 Binary files /dev/null and b/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo@2x.png differ 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 0000000..b57fcbf Binary files /dev/null and b/mobile/ios/App/App/Assets.xcassets/LaunchLogo.imageset/launch-logo@3x.png differ 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}`) 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); +}