Compare commits
28 Commits
207b1827c7
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd632a1592 | ||
|
|
3a6f1bbdd8 | ||
|
|
bb067df482 | ||
|
|
326d5fe983 | ||
|
|
676119b328 | ||
|
|
b44e708fbb | ||
|
|
d93ffb1580 | ||
|
|
afec870919 | ||
|
|
fcd4e2cce5 | ||
|
|
0b10009d97 | ||
|
|
3c8d9b5736 | ||
|
|
1476906756 | ||
|
|
d62c3511f6 | ||
|
|
5419540648 | ||
|
|
f7164e2978 | ||
|
|
457866c7ac | ||
|
|
217a51b013 | ||
|
|
a86284cb8a | ||
|
|
044f67cf63 | ||
|
|
a476e426f3 | ||
|
|
595f21fb6c | ||
|
|
6677033faa | ||
|
|
a1d7979048 | ||
|
|
7a97118d12 | ||
|
|
ff304d1fda | ||
|
|
694880eb4c | ||
|
|
03a1e45531 | ||
|
|
b53a4b8f5d |
40
.github/workflows/ci.yml
vendored
40
.github/workflows/ci.yml
vendored
@@ -1,14 +1,15 @@
|
|||||||
# Gitea Actions reads this path (.github/workflows) as well as .gitea/workflows.
|
# Gitea Actions reads .github/workflows/ (as well as .gitea/workflows/).
|
||||||
# iOS builds require a macOS runner: register a Gitea act_runner on a Mac with
|
# Keeping everything here avoids running two duplicate pipelines.
|
||||||
# labels matching `runs-on` below (macos-14 / macos-15), or change them to your
|
#
|
||||||
# runner's label. On GitHub-hosted runners these labels work as-is.
|
# Runner requirement: a Mac with Xcode 16+ registered as a Gitea act_runner
|
||||||
|
# with labels "self-hosted" and "macos" (see CONTRIBUTING.md → Runner Setup).
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [main, develop]
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
@@ -16,12 +17,11 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build · ${{ matrix.os }} · ${{ matrix.configuration }}
|
name: Build · ${{ matrix.configuration }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: [self-hosted, macos]
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ macos-14, macos-15 ]
|
|
||||||
configuration: [Debug, Release]
|
configuration: [Debug, Release]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -31,21 +31,29 @@ jobs:
|
|||||||
run: xcodebuild -version
|
run: xcodebuild -version
|
||||||
|
|
||||||
- name: Install XcodeGen
|
- name: Install XcodeGen
|
||||||
run: brew install xcodegen
|
run: brew list xcodegen &>/dev/null || brew install xcodegen
|
||||||
|
|
||||||
- name: Generate Xcode project
|
- name: Generate Xcode project
|
||||||
run: xcodegen generate
|
run: xcodegen generate
|
||||||
|
|
||||||
- name: Build (${{ matrix.configuration }}, iOS Simulator)
|
# Device SDK, signing disabled: CoreSimulator on the runner Mac is unreliable
|
||||||
|
# (Xcode on an external volume can't discover simulator runtimes and hangs).
|
||||||
|
# Compilation coverage is identical; no tests run on simulator yet anyway.
|
||||||
|
- name: Build (${{ matrix.configuration }}, iOS device SDK)
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
xcodebuild \
|
xcodebuild \
|
||||||
-project Jarvis.xcodeproj \
|
-project Jervis.xcodeproj \
|
||||||
-scheme Jarvis \
|
-scheme Jervis \
|
||||||
-configuration ${{ matrix.configuration }} \
|
-configuration ${{ matrix.configuration }} \
|
||||||
-sdk iphonesimulator \
|
-sdk iphoneos \
|
||||||
-destination 'generic/platform=iOS Simulator' \
|
-destination 'generic/platform=iOS' \
|
||||||
-derivedDataPath build \
|
-derivedDataPath build \
|
||||||
|
COMPILATION_CACHE_ENABLE_CACHING=NO \
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
build
|
build
|
||||||
|
|
||||||
|
# No test targets exist yet (see docs/BACKLOG.md #13). When a JervisTests
|
||||||
|
# target is added, switch this to `xcodebuild test` on a simulator — after
|
||||||
|
# fixing CoreSimulator on the runner (Xcode must live on the boot volume).
|
||||||
|
|||||||
65
.github/workflows/release.yml
vendored
Normal file
65
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Triggers on merge to main. Archives the app and exports an IPA.
|
||||||
|
# TestFlight upload requires three secrets set in:
|
||||||
|
# Gitea → Repository Settings → Actions → Secrets
|
||||||
|
#
|
||||||
|
# ASC_KEY_ID — App Store Connect API key ID (e.g. ABC123DEFG)
|
||||||
|
# ASC_ISSUER_ID — Issuer ID from App Store Connect → Users → API Keys
|
||||||
|
# ASC_PRIVATE_KEY — Full contents of the downloaded .p8 file
|
||||||
|
#
|
||||||
|
# The runner Mac must have the distribution certificate and team K8BLMMR883
|
||||||
|
# provisioning profile in its keychain (set up once in Xcode on that machine).
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
archive:
|
||||||
|
name: Archive & Export IPA
|
||||||
|
runs-on: [self-hosted, macos]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Xcode version
|
||||||
|
run: xcodebuild -version
|
||||||
|
|
||||||
|
- name: Install XcodeGen
|
||||||
|
run: brew list xcodegen &>/dev/null || brew install xcodegen
|
||||||
|
|
||||||
|
- name: Generate Xcode project
|
||||||
|
run: xcodegen generate
|
||||||
|
|
||||||
|
- name: Archive (Release, device)
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
xcodebuild archive \
|
||||||
|
-project Jervis.xcodeproj \
|
||||||
|
-scheme Jervis \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-archivePath build/Jervis.xcarchive \
|
||||||
|
DEVELOPMENT_TEAM=K8BLMMR883
|
||||||
|
|
||||||
|
- name: Export IPA
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
xcodebuild -exportArchive \
|
||||||
|
-archivePath build/Jervis.xcarchive \
|
||||||
|
-exportPath build/ipa \
|
||||||
|
-exportOptionsPlist ExportOptions.plist
|
||||||
|
|
||||||
|
- name: Upload to TestFlight
|
||||||
|
env:
|
||||||
|
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||||
|
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||||
|
ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "$ASC_PRIVATE_KEY" > /tmp/AuthKey_${ASC_KEY_ID}.p8
|
||||||
|
xcrun altool --upload-app \
|
||||||
|
--type ios \
|
||||||
|
--file build/ipa/Jervis.ipa \
|
||||||
|
--apiKey "$ASC_KEY_ID" \
|
||||||
|
--apiIssuer "$ASC_ISSUER_ID"
|
||||||
|
rm -f /tmp/AuthKey_${ASC_KEY_ID}.p8
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,7 +7,7 @@ xcuserdata/
|
|||||||
*.moved-aside
|
*.moved-aside
|
||||||
|
|
||||||
# Generated by XcodeGen — project.yml is the source of truth
|
# Generated by XcodeGen — project.yml is the source of truth
|
||||||
Jarvis.xcodeproj/
|
Jervis.xcodeproj/
|
||||||
|
|
||||||
# SwiftPM
|
# SwiftPM
|
||||||
.build/
|
.build/
|
||||||
|
|||||||
200
CLAUDE.md
200
CLAUDE.md
@@ -1,53 +1,165 @@
|
|||||||
# Jarvis — Claude Code Handoff
|
# Jarvis iOS — Claude Code
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
Jarvis is an iOS app (SwiftUI + SwiftData) that connects to a self-hosted RSS
|
Jarvis is an iOS app (SwiftUI, SwiftData, iOS 17+) that displays ranked news stories
|
||||||
correlation platform. It displays news stories ranked by signal score, caches
|
served from a self-hosted backend. The backend ingests RSS, clusters articles into
|
||||||
full article content for offline reading, and receives live updates over WebSocket.
|
stories, ranks them by signal score, and serves cached results via REST + WebSocket.
|
||||||
|
|
||||||
## Architecture
|
The app is a **cached news intelligence client**. It is not an RSS reader.
|
||||||
- REST on launch → WebSocket for live updates
|
|
||||||
- Server computes signal scores — app only renders them
|
|
||||||
- Full offline support via SwiftData cache
|
|
||||||
- No auth — local network / self-hosted only
|
|
||||||
|
|
||||||
## Files produced so far
|
---
|
||||||
- `API/contract.md` — full REST + WebSocket API spec
|
|
||||||
- `Jarvis/Models/Models.swift` — all Codable structs + SwiftData models
|
|
||||||
- `Jarvis/Networking/APIClient.swift` — REST layer (actor-based)
|
|
||||||
- `Jarvis/Networking/WebSocketManager.swift` — WS connection + reconnect backoff
|
|
||||||
- `Jarvis/Store/StoryStore.swift` — central ObservableObject, drives all views
|
|
||||||
- `Jarvis/Store/ServerSettings.swift` — persists server host to UserDefaults
|
|
||||||
- `Jarvis/JarvisApp.swift` — app entry point, SwiftData container
|
|
||||||
- `Jarvis/Views/RootTabView.swift` — tab navigation
|
|
||||||
- `Jarvis/Views/Onboarding/OnboardingView.swift` — server setup screen
|
|
||||||
|
|
||||||
## Views still needed
|
## Architecture rule
|
||||||
- `SignalFeedView` — home screen, story list sorted by signal score
|
|
||||||
- `StoryDetailView` — cluster detail, timeline, consensus/conflict
|
```
|
||||||
- `ArticleReaderView` — full article, cached badge, more from cluster
|
RSS feeds → backend ingestion → cache/database → API → frontend
|
||||||
- `FeedManagerView` — feed list, health states, add/delete
|
```
|
||||||
- `LatestView`, `SavedView`, `SearchView` — secondary tabs
|
|
||||||
|
**The frontend must never fetch RSS feeds directly.**
|
||||||
|
**The frontend must never parse, deduplicate, or rank articles.**
|
||||||
|
**The frontend consumes cached backend data only.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend responsibilities (not the app's job)
|
||||||
|
|
||||||
|
- RSS fetching, validation, timeout handling
|
||||||
|
- Feed health checks and dead-feed detection
|
||||||
|
- Article normalization and deduplication
|
||||||
|
- Story clustering and ranking
|
||||||
|
- Freshness, interestingness, and signal scoring
|
||||||
|
- API response caching and last-known-good fallback
|
||||||
|
|
||||||
|
The app trusts these to be done. It does not replicate them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend responsibilities
|
||||||
|
|
||||||
|
- Load cached stories immediately on open (SwiftData)
|
||||||
|
- Display stories with freshness indicators and source attribution
|
||||||
|
- Show connection/refresh state without blocking the UI
|
||||||
|
- Show stale-cache label when backend data is old
|
||||||
|
- Paginate via cursor (`nextCursor`)
|
||||||
|
- Never show an empty state when the cache has data
|
||||||
|
|
||||||
|
**Performance targets:**
|
||||||
|
- App opens with cached news: instant
|
||||||
|
- Home feed API response: < 1 second
|
||||||
|
- Refresh response: < 2 seconds
|
||||||
|
- Story open: < 500ms
|
||||||
|
- Background refresh does not block UI
|
||||||
|
|
||||||
|
If any of these fail, treat it as a bug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend validation checklist
|
||||||
|
|
||||||
|
Before marking any feed/story feature complete, verify:
|
||||||
|
|
||||||
|
- [ ] Cached news loads immediately without waiting for RSS
|
||||||
|
- [ ] Refresh does not freeze the UI
|
||||||
|
- [ ] Empty state does not appear when cache has data
|
||||||
|
- [ ] Stale cache is labeled (age visible)
|
||||||
|
- [ ] Fresh stories rise above old ones
|
||||||
|
- [ ] Categories are balanced across the feed
|
||||||
|
- [ ] Source attribution is correct and links work
|
||||||
|
- [ ] Offline state shows last-known-good content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Jarvis/
|
||||||
|
Models/Models.swift — Codable structs + SwiftData models
|
||||||
|
Networking/APIClient.swift — REST layer (actor-based)
|
||||||
|
Networking/WebSocketManager.swift — WS + reconnect backoff
|
||||||
|
Store/StoryStore.swift — central ObservableObject, drives all views
|
||||||
|
Store/StoryStore+Supplement.swift — background section supplement fetches
|
||||||
|
Store/ServerSettings.swift — persists host to UserDefaults
|
||||||
|
Store/CacheMaintenance.swift — SwiftData eviction
|
||||||
|
Views/
|
||||||
|
Home/SignalFeedView.swift — main feed (All digest, pill pills, sub-sections)
|
||||||
|
Home/StoryRowView.swift — story card
|
||||||
|
Reader/ArticleReaderView.swift — full article, cached badge
|
||||||
|
Story/StoryDetailView.swift — cluster detail + timeline
|
||||||
|
Feeds/FeedManagerView.swift — feed list + health states
|
||||||
|
Feeds/AddFeedSheet.swift
|
||||||
|
Settings/SettingsView.swift
|
||||||
|
Settings/NotificationsView.swift
|
||||||
|
Shared/Theme.swift — StoryPill enum, NewsSection, design tokens
|
||||||
|
Shared/SignalStripe.swift
|
||||||
|
Shared/SourceChips.swift
|
||||||
|
Shared/CachedBadge.swift
|
||||||
|
Shared/ConnectionBanner.swift
|
||||||
|
Onboarding/OnboardingView.swift
|
||||||
|
JarvisApp.swift
|
||||||
|
API/contract.md — REST + WebSocket API spec
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Design tokens
|
## Design tokens
|
||||||
- Primary accent: `KisaniOrange` = #FF5C00 (add to Assets.xcassets)
|
|
||||||
- Background: pure black #000000
|
|
||||||
- Surface: #111111, #1A1A1A
|
|
||||||
- All views: `.preferredColorScheme(.dark)`
|
|
||||||
|
|
||||||
## Key decisions already made
|
- Accent: `KisaniOrange` = `#FF5C00`
|
||||||
- Story = user-facing product object (never expose Cluster to UI)
|
- Background: `#000000` (pure black)
|
||||||
- Pipeline: Article → Cluster → Story
|
- Surface: `#111111` / `#1A1A1A`
|
||||||
- Signal score is server-computed, breakdown exposed as `scoreBreakdown`
|
- All views: `.preferredColorScheme(.dark)` unless user overrides in settings
|
||||||
- WebSocket pushes lightweight events (storyId + score only), app refetches full object via REST
|
|
||||||
- WS reconnect: exponential backoff 1s → 2s → 4s → max 60s
|
|
||||||
- Pagination: cursor-based via `nextCursor`
|
|
||||||
|
|
||||||
## To start in Claude Code
|
---
|
||||||
```bash
|
|
||||||
cd jarvis
|
## Key decisions
|
||||||
# Create new Xcode project named Jarvis, iOS target
|
|
||||||
# Drag all .swift files into the project
|
- `Story` is the user-facing object. Never expose `Cluster` to UI.
|
||||||
# Add KisaniOrange (#FF5C00) to Assets.xcassets as a Color Set
|
- Pipeline: `Article → Cluster → Story`
|
||||||
# Set minimum deployment target to iOS 17 (SwiftData requirement)
|
- Signal score is server-computed; `scoreBreakdown` is exposed for display.
|
||||||
```
|
- WebSocket pushes `{storyId, score}` only; app re-fetches the full object via REST.
|
||||||
|
- WS reconnect: exponential backoff 1s → 2s → 4s → max 60s.
|
||||||
|
- Pagination: cursor-based via `nextCursor`.
|
||||||
|
- `StoryStore.setPill()` resets `lastSyncedAt` to bypass the 30s rate-limit on filter switch.
|
||||||
|
- `sectionSupplement` fetches populate sparse regional sections in the All digest independently.
|
||||||
|
- **Age must be read from `firstSeenAt`, never `updatedAt`.** The backend bumps `updatedAt`
|
||||||
|
every time it attaches another article to a cluster, however loosely related — a story
|
||||||
|
can read "1 hr ago" while being weeks old (see `docs/BACKLOG.md` #15). `firstSeenAt` is
|
||||||
|
the honest age and is already decoded on `StorySummary`.
|
||||||
|
- **Stale-news control (client-side, `Views/Shared/Theme.swift`):** `clientRank` applies a
|
||||||
|
soft time-decay to `signalScore` so fresh stories outrank stale high-scorers in
|
||||||
|
`feedOrder` — it computes age from `firstSeenAt`, not `updatedAt` (was silently broken
|
||||||
|
by the same bug above until fixed). `StorySummary.isFossil` (`firstSeenAt` > 5 days) is a
|
||||||
|
hard backstop on top of that: `SignalFeedView.mainStories` and `.makeDigest()` sink
|
||||||
|
fossils below current stories so one can never take the hero/lead slot, regardless of
|
||||||
|
score. Fossils still appear in the feed — just not in the most prominent position. This
|
||||||
|
is a display/ordering patch; the server's own ranking is still wrong at the source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pill → tag mapping
|
||||||
|
|
||||||
|
| Pill | Backend tags queried |
|
||||||
|
|---|---|
|
||||||
|
| All | — (no filter, full ranked feed) |
|
||||||
|
| F1 | `formula-1` |
|
||||||
|
| Sport | `sports`, `esports` |
|
||||||
|
| Tech | `technology`, `artificial-intelligence`, `machine-learning`, `cloud-computing`, `homelab`, `cybersecurity`, `security`, `programming-and-software-development`, `privacy-and-data-protection`, `web-design-and-ui-ux`, `wordpress-and-web-development`, `robotics` |
|
||||||
|
| East Africa | `east-africa`, `uganda` (Uganda sub-section pinned first) |
|
||||||
|
| Southern Africa | `south-africa`, `namibia`, `botswana`, `zimbabwe`, `zambia`, `mozambique`, `southern-africa` |
|
||||||
|
| Canada | `canada` |
|
||||||
|
| US | `united-states` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting requirement
|
||||||
|
|
||||||
|
At the end of any significant work session, generate a report covering:
|
||||||
|
|
||||||
|
1. **Executive summary** — is the app usable, fast, serving cached news?
|
||||||
|
2. **Issues found** — backend, frontend, cache, feed, ranking, data quality
|
||||||
|
3. **Fixes made** — file, function, reason, result
|
||||||
|
4. **Measurements** — before/after for response times, article counts, dead feeds
|
||||||
|
5. **Remaining risks** — nothing hidden
|
||||||
|
6. **Next actions** — highest-value improvements
|
||||||
|
7. **Status** — one of: `NOT READY` / `PARTIALLY READY` / `READY FOR TESTING` / `READY FOR RELEASE`
|
||||||
|
|
||||||
|
Never claim READY FOR RELEASE without evidence from logs, API responses, or timing measurements.
|
||||||
|
|||||||
140
CONTRIBUTING.md
Normal file
140
CONTRIBUTING.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Contributing to Jervis
|
||||||
|
|
||||||
|
## Branch workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/* ──→ develop ──→ main
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`main`** — protected. No direct pushes. Every change arrives via PR from `develop`.
|
||||||
|
- **`develop`** — integration branch. All feature work merges here first. CI runs on every push.
|
||||||
|
- **`feature/*`** — short-lived branches cut from `develop`.
|
||||||
|
|
||||||
|
## Starting work
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
git pull origin develop
|
||||||
|
git checkout -b feature/your-description
|
||||||
|
```
|
||||||
|
|
||||||
|
## Opening a PR
|
||||||
|
|
||||||
|
1. Push your feature branch and open a PR targeting **`develop`**.
|
||||||
|
2. CI must pass (Debug + Release builds succeed).
|
||||||
|
3. At least 1 approval required before merge.
|
||||||
|
|
||||||
|
## Releasing to TestFlight
|
||||||
|
|
||||||
|
A maintainer opens a PR from `develop → main`. Merging triggers the release workflow:
|
||||||
|
archive → export IPA → upload to TestFlight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runner setup (one-time, on your Mac)
|
||||||
|
|
||||||
|
Gitea Actions requires a self-hosted runner. The CI workflows target `[self-hosted, macos]`.
|
||||||
|
|
||||||
|
### 1. Enable Gitea Actions (admin, once per instance)
|
||||||
|
|
||||||
|
In `app.ini` on the Gitea server, add:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[actions]
|
||||||
|
ENABLED = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart Gitea.
|
||||||
|
|
||||||
|
### 2. Install act_runner on the Mac
|
||||||
|
|
||||||
|
There is no Homebrew formula — download the official binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sL -o /usr/local/bin/act_runner \
|
||||||
|
https://dl.gitea.com/act_runner/0.6.1/act_runner-0.6.1-darwin-arm64
|
||||||
|
chmod +x /usr/local/bin/act_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Register the runner
|
||||||
|
|
||||||
|
Get a registration token from:
|
||||||
|
**Repository Settings → Actions → Runners → Create Runner**
|
||||||
|
(or via API: `GET /api/v1/repos/kutesir/jarvis/actions/runners/registration-token`)
|
||||||
|
|
||||||
|
The `:host` label suffix is required — it makes jobs run directly on the Mac
|
||||||
|
instead of in Docker (which xcodebuild needs, and the Mac has no Docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.act_runner && cd ~/.act_runner
|
||||||
|
act_runner register --no-interactive \
|
||||||
|
--instance http://10.10.1.21:3002 \
|
||||||
|
--token <RUNNER_TOKEN> \
|
||||||
|
--name "mac-mini" \
|
||||||
|
--labels "self-hosted:host,macos:host"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configure and start the runner
|
||||||
|
|
||||||
|
Generate the config, then fix two defaults that break Docker-less Macs
|
||||||
|
(the generated config ships Docker-based ubuntu labels that make the daemon
|
||||||
|
refuse to start without a Docker socket):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.act_runner
|
||||||
|
act_runner generate-config > config.yaml
|
||||||
|
# 1. labels: [] — empty falls back to the :host labels in .runner
|
||||||
|
# 2. docker_host: "-" — never look for a Docker daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
In `config.yaml`, set:
|
||||||
|
- `labels: []` (replace the default ubuntu docker labels)
|
||||||
|
- `docker_host: "-"` (under `container:`)
|
||||||
|
|
||||||
|
Then start it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
act_runner daemon --config ~/.act_runner/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
To keep it running across reboots, load it as a LaunchAgent
|
||||||
|
(`~/Library/LaunchAgents/com.gitea.act-runner.plist`, see repo scripts).
|
||||||
|
|
||||||
|
The runner must have **Xcode 16+** installed and accepted the license:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo xcodebuild -license accept
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signing and TestFlight secrets
|
||||||
|
|
||||||
|
For the release workflow to archive and upload, the runner Mac needs:
|
||||||
|
|
||||||
|
1. The **iOS Distribution certificate** installed in its keychain (set up once in Xcode → Settings → Accounts → Manage Certificates).
|
||||||
|
2. Three **Gitea secrets** set under:
|
||||||
|
**Repository Settings → Actions → Secrets**
|
||||||
|
|
||||||
|
| Secret | Where to get it |
|
||||||
|
|---|---|
|
||||||
|
| `ASC_KEY_ID` | App Store Connect → Users & Access → API Keys |
|
||||||
|
| `ASC_ISSUER_ID` | Same page, above the key list |
|
||||||
|
| `ASC_PRIVATE_KEY` | The `.p8` file downloaded when you created the key (only downloadable once) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch protection (manual setup in Gitea UI)
|
||||||
|
|
||||||
|
Go to **Repository Settings → Branches → Add Branch Rule**:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Branch name pattern | `main` |
|
||||||
|
| Require pull request before merging | ✅ |
|
||||||
|
| Required approvals | 1 |
|
||||||
|
| Require status checks to pass | ✅ — select `Build · Debug` and `Build · Release` |
|
||||||
|
| Block force push | ✅ |
|
||||||
|
| Block deletion | ✅ |
|
||||||
|
|
||||||
|
Repeat with `develop` but set required approvals to 0 (CI gate only).
|
||||||
38
ExportOptions.plist
Normal file
38
ExportOptions.plist
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
method: "app-store" to upload to TestFlight/App Store.
|
||||||
|
"development" for ad-hoc device installs.
|
||||||
|
"ad-hoc" for distribution outside the App Store.
|
||||||
|
-->
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store</string>
|
||||||
|
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>K8BLMMR883</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
destination: "upload" sends directly to App Store Connect after export.
|
||||||
|
"export" writes the IPA locally only (upload manually or via altool).
|
||||||
|
-->
|
||||||
|
<key>destination</key>
|
||||||
|
<string>export</string>
|
||||||
|
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
|
||||||
|
<key>stripSwiftSymbols</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>uploadBitcode</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<key>compileBitcode</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<key>thinning</key>
|
||||||
|
<string><none></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,158 +0,0 @@
|
|||||||
// StoryStore.swift
|
|
||||||
// Jarvis — central observable store. Views read from here, never hit the API directly.
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class StoryStore: ObservableObject {
|
|
||||||
static let shared = StoryStore()
|
|
||||||
|
|
||||||
@Published var stories: [StorySummary] = []
|
|
||||||
@Published var isLoading = false
|
|
||||||
@Published var isLoadingMore = false
|
|
||||||
@Published var error: APIError?
|
|
||||||
@Published var lastSyncedAt: Date?
|
|
||||||
@Published var hasMore = false
|
|
||||||
@Published var selectedTopic: String? = nil
|
|
||||||
|
|
||||||
private var nextCursor: String?
|
|
||||||
private var refreshTask: Task<Void, Never>?
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
private let ws = WebSocketManager.shared
|
|
||||||
private let api = APIClient.shared
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
subscribeToWebSocket()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Load
|
|
||||||
|
|
||||||
func loadStories(refresh: Bool = false) async {
|
|
||||||
guard !isLoading else { return }
|
|
||||||
isLoading = true
|
|
||||||
error = nil
|
|
||||||
|
|
||||||
// Don't wipe the visible feed up-front. Only the cursor resets for a
|
|
||||||
// refresh; the list is replaced on success. A cancelled/failed refresh
|
|
||||||
// then keeps showing what we already have instead of blanking out.
|
|
||||||
let cursor = refresh ? nil : nextCursor
|
|
||||||
|
|
||||||
do {
|
|
||||||
let result = try await api.fetchStories(
|
|
||||||
cursor: cursor,
|
|
||||||
topic: selectedTopic
|
|
||||||
)
|
|
||||||
stories = refresh ? result.data : stories + result.data
|
|
||||||
nextCursor = result.nextCursor
|
|
||||||
hasMore = result.hasMore
|
|
||||||
lastSyncedAt = Date()
|
|
||||||
} catch let e as APIError where !e.isCancelled {
|
|
||||||
error = e
|
|
||||||
} catch {
|
|
||||||
// Benign cancellation (network blip / superseded request): keep state.
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadMore() async {
|
|
||||||
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
|
|
||||||
isLoadingMore = true
|
|
||||||
do {
|
|
||||||
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic)
|
|
||||||
stories += result.data
|
|
||||||
nextCursor = result.nextCursor
|
|
||||||
hasMore = result.hasMore
|
|
||||||
} catch {}
|
|
||||||
isLoadingMore = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coalesce bursts of `story.created` events into a single refresh so a busy
|
|
||||||
/// feed (47 sources) doesn't fire a refetch per story.
|
|
||||||
private func scheduleCoalescedRefresh() {
|
|
||||||
refreshTask?.cancel()
|
|
||||||
refreshTask = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await self?.loadStories(refresh: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setTopic(_ topic: String?) async {
|
|
||||||
selectedTopic = topic
|
|
||||||
await loadStories(refresh: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - WebSocket
|
|
||||||
|
|
||||||
private func subscribeToWebSocket() {
|
|
||||||
ws.events
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] event in
|
|
||||||
self?.handle(event: event)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handle(event: WSEvent) {
|
|
||||||
switch event.type {
|
|
||||||
|
|
||||||
case .storyUpdated:
|
|
||||||
guard let id = event.storyId,
|
|
||||||
let score = event.signalScore,
|
|
||||||
let count = event.sourceCount else { return }
|
|
||||||
updateStory(id: id, signalScore: score, sourceCount: count)
|
|
||||||
Task { await refetch(storyId: id) }
|
|
||||||
|
|
||||||
case .storyCreated:
|
|
||||||
scheduleCoalescedRefresh()
|
|
||||||
|
|
||||||
case .storyStale:
|
|
||||||
guard let id = event.storyId else { return }
|
|
||||||
removeStory(id: id)
|
|
||||||
|
|
||||||
case .feedHealth:
|
|
||||||
NotificationCenter.default.post(name: .feedHealthChanged, object: event)
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateStory(id: String, signalScore: Int, sourceCount: Int) {
|
|
||||||
guard let idx = stories.firstIndex(where: { $0.id == id }) else { return }
|
|
||||||
let old = stories[idx]
|
|
||||||
let updated = StorySummary(
|
|
||||||
id: old.id,
|
|
||||||
headline: old.headline,
|
|
||||||
summary: old.summary,
|
|
||||||
topic: old.topic,
|
|
||||||
tags: old.tags,
|
|
||||||
signalScore: signalScore,
|
|
||||||
scoreBreakdown: old.scoreBreakdown,
|
|
||||||
sourceCount: sourceCount,
|
|
||||||
sources: old.sources,
|
|
||||||
consensus: old.consensus,
|
|
||||||
conflict: old.conflict,
|
|
||||||
updatedAt: old.updatedAt,
|
|
||||||
createdAt: old.createdAt
|
|
||||||
)
|
|
||||||
stories[idx] = updated
|
|
||||||
stories.sort(by: StorySummary.feedOrder)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeStory(id: String) {
|
|
||||||
stories.removeAll { $0.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refetch(storyId: String) async {
|
|
||||||
// Lightweight: only update what changed via WS patch above.
|
|
||||||
// Full detail is fetched lazily when user taps into the story.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Notification.Name {
|
|
||||||
static let feedHealthChanged = Notification.Name("feedHealthChanged")
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
// Theme.swift
|
|
||||||
// Jarvis — shared design tokens, signal-score fade math, formatting, nav routes.
|
|
||||||
// Every hex value here is from the spec; nothing is invented.
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Hex colors
|
|
||||||
|
|
||||||
extension Color {
|
|
||||||
init(hex: String) {
|
|
||||||
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
|
||||||
var v: UInt64 = 0
|
|
||||||
Scanner(string: s).scanHexInt64(&v)
|
|
||||||
let r, g, b: Double
|
|
||||||
switch s.count {
|
|
||||||
case 6:
|
|
||||||
r = Double((v >> 16) & 0xFF) / 255
|
|
||||||
g = Double((v >> 8) & 0xFF) / 255
|
|
||||||
b = Double(v & 0xFF) / 255
|
|
||||||
default:
|
|
||||||
r = 0; g = 0; b = 0
|
|
||||||
}
|
|
||||||
self = Color(.sRGB, red: r, green: g, blue: b, opacity: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Palette
|
|
||||||
|
|
||||||
enum Palette {
|
|
||||||
static let orange = Color("KisaniOrange") // #FF5C00
|
|
||||||
static let black = Color.black // #000000
|
|
||||||
static let surface = Color(hex: "111111")
|
|
||||||
static let surface2 = Color(hex: "1A1A1A")
|
|
||||||
static let hairline = Color(hex: "1A1A1A")
|
|
||||||
|
|
||||||
// health
|
|
||||||
static let healthActive = Color(hex: "2A5A2A")
|
|
||||||
static let healthFailing = Color(hex: "AA6600")
|
|
||||||
static let healthDead = Color(hex: "5A1A1A")
|
|
||||||
|
|
||||||
// blocks
|
|
||||||
static let consensusBorder = Color(hex: "FF5C00")
|
|
||||||
static let consensusFill = Color(hex: "1F1206") // dark orange
|
|
||||||
static let conflictBorder = Color(hex: "5A1A1A") // dark red
|
|
||||||
static let conflictFill = Color(hex: "1A0C0C") // dark red
|
|
||||||
|
|
||||||
static let cachedGreen = Color(hex: "2A5A2A")
|
|
||||||
static let bodyText = Color(hex: "4A4A4A")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Signal-score fade
|
|
||||||
//
|
|
||||||
// Spec anchors:
|
|
||||||
// stripe : 97 → #FF5C00, fades to #1A1A1A at 0
|
|
||||||
// title : 97 → white, 20 → #444444, 0–19 near invisible
|
|
||||||
// score : 80–100 full orange · 50–79 dimmed orange · 20–49 dark brown · 0–19 near invisible
|
|
||||||
|
|
||||||
enum Signal {
|
|
||||||
private static func lerp(_ a: (Double, Double, Double),
|
|
||||||
_ b: (Double, Double, Double),
|
|
||||||
_ t: Double) -> Color {
|
|
||||||
let t = max(0, min(1, t))
|
|
||||||
return Color(.sRGB,
|
|
||||||
red: a.0 + (b.0 - a.0) * t,
|
|
||||||
green: a.1 + (b.1 - a.1) * t,
|
|
||||||
blue: a.2 + (b.2 - a.2) * t,
|
|
||||||
opacity: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 3pt left stripe color: #1A1A1A (0) → #FF5C00 (97+).
|
|
||||||
static func stripeColor(_ score: Int) -> Color {
|
|
||||||
let t = Double(score) / 97.0
|
|
||||||
return lerp((0x1A/255, 0x1A/255, 0x1A/255), // #1A1A1A
|
|
||||||
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
|
|
||||||
t)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Story title color: white (97+) → #444444 (20) → near invisible (0).
|
|
||||||
static func titleColor(_ score: Int) -> Color {
|
|
||||||
if score >= 97 { return .white }
|
|
||||||
if score >= 20 {
|
|
||||||
let t = Double(score - 20) / Double(97 - 20)
|
|
||||||
return lerp((0x44/255, 0x44/255, 0x44/255), // #444444
|
|
||||||
(1, 1, 1), // white
|
|
||||||
t)
|
|
||||||
}
|
|
||||||
// 0–19 near invisible: #1C1C1C → #444444
|
|
||||||
let t = Double(score) / 20.0
|
|
||||||
return lerp((0x1C/255, 0x1C/255, 0x1C/255),
|
|
||||||
(0x44/255, 0x44/255, 0x44/255),
|
|
||||||
t)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signal score number color: stays in orange/brown hue, fades with score.
|
|
||||||
/// near-invisible brown (0) → full #FF5C00 (100).
|
|
||||||
static func scoreColor(_ score: Int) -> Color {
|
|
||||||
let t = Double(score) / 100.0
|
|
||||||
return lerp((0x2A/255, 0x18/255, 0x10/255), // near-invisible brown
|
|
||||||
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
|
|
||||||
t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Topic display
|
|
||||||
|
|
||||||
enum Topic {
|
|
||||||
static func label(_ slug: String) -> String {
|
|
||||||
switch slug.lowercased() {
|
|
||||||
case "ai", "artificial-intelligence": return "AI"
|
|
||||||
case "us", "united-states": return "US"
|
|
||||||
case "formula-1": return "Formula 1"
|
|
||||||
case "ui-ux", "web-design-and-ui-ux": return "Web Design"
|
|
||||||
default:
|
|
||||||
// Slug → Title Case, e.g. "cloud-computing" → "Cloud Computing".
|
|
||||||
return slug.split(separator: "-")
|
|
||||||
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
|
||||||
.joined(separator: " ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Topic pills shown on the home screen.
|
|
||||||
static let filters: [(label: String, slug: String?)] = [
|
|
||||||
("All", nil),
|
|
||||||
("Finance", "finance"),
|
|
||||||
("Tech", "tech"),
|
|
||||||
("Politics", "politics"),
|
|
||||||
("Africa", "africa"),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Time formatting
|
|
||||||
|
|
||||||
extension Date {
|
|
||||||
/// Short relative string e.g. "just now", "3 min", "2 hr", "4 d".
|
|
||||||
func timeAgoShort(reference: Date = Date()) -> String {
|
|
||||||
let secs = max(0, Int(reference.timeIntervalSince(self)))
|
|
||||||
switch secs {
|
|
||||||
case 0..<60: return "just now"
|
|
||||||
case 60..<3600: return "\(secs / 60) min"
|
|
||||||
case 3600..<86400: return "\(secs / 3600) hr"
|
|
||||||
default: return "\(secs / 86400) d"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "08:03" style monospaced clock for timeline / source chips.
|
|
||||||
var clockShort: String {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.dateFormat = "HH:mm"
|
|
||||||
return f.string(from: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncedMinutesAgo(_ date: Date?, reference: Date = Date()) -> String {
|
|
||||||
guard let date else { return "never" }
|
|
||||||
return date.timeAgoShort(reference: reference)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Navigation routes
|
|
||||||
|
|
||||||
/// Route into the article reader. Carries the parent story context so the
|
|
||||||
/// back button can show the truncated headline.
|
|
||||||
struct ArticleRoute: Hashable {
|
|
||||||
let articleId: String
|
|
||||||
let storyId: String
|
|
||||||
let parentHeadline: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Appearance mode
|
|
||||||
|
|
||||||
enum AppearanceMode: String, CaseIterable {
|
|
||||||
case system, light, dark
|
|
||||||
|
|
||||||
var colorScheme: ColorScheme? {
|
|
||||||
switch self {
|
|
||||||
case .system: return nil
|
|
||||||
case .light: return .light
|
|
||||||
case .dark: return .dark
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .system: return "System"
|
|
||||||
case .light: return "Light"
|
|
||||||
case .dark: return "Dark"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var icon: String {
|
|
||||||
switch self {
|
|
||||||
case .system: return "circle.lefthalf.filled"
|
|
||||||
case .light: return "sun.max"
|
|
||||||
case .dark: return "moon.stars"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Stable feed ordering
|
|
||||||
|
|
||||||
extension StorySummary {
|
|
||||||
/// Deterministic ranking: signal score descending, then id descending — the
|
|
||||||
/// same order the backend uses (`signal_score DESC, id DESC`). Without the
|
|
||||||
/// id tiebreaker, equal-score stories reshuffle on every (unstable) re-sort.
|
|
||||||
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
|
|
||||||
a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Story pills (backend-tag driven)
|
|
||||||
//
|
|
||||||
// The backend classifies each story into canonical category slugs in `tags[]`.
|
|
||||||
// The client filters by pill -> slug membership only; no headline keyword
|
|
||||||
// matching. `topic` is a fallback while the backend finishes emitting `tags[]`.
|
|
||||||
|
|
||||||
enum StoryPill: String, CaseIterable, Identifiable {
|
|
||||||
case all = "All"
|
|
||||||
case f1 = "F1"
|
|
||||||
case sport = "Sport"
|
|
||||||
case ai = "AI"
|
|
||||||
case cloud = "Cloud"
|
|
||||||
case homelab = "HomeLab"
|
|
||||||
case tech = "Tech"
|
|
||||||
case uganda = "Uganda"
|
|
||||||
case southAfrica = "South Africa"
|
|
||||||
case canada = "Canada"
|
|
||||||
case unitedStates = "US"
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
var label: String { rawValue }
|
|
||||||
|
|
||||||
var slugs: Set<String> {
|
|
||||||
switch self {
|
|
||||||
case .all: return []
|
|
||||||
case .f1: return ["formula-1"]
|
|
||||||
case .sport: return ["sports", "esports"]
|
|
||||||
case .ai: return ["artificial-intelligence", "machine-learning", "robotics"]
|
|
||||||
case .cloud: return ["cloud-computing"]
|
|
||||||
case .homelab: return ["homelab"]
|
|
||||||
case .tech: return ["technology", "programming-and-software-development",
|
|
||||||
"cybersecurity", "security", "privacy-and-data-protection",
|
|
||||||
"web-design-and-ui-ux", "wordpress-and-web-development"]
|
|
||||||
case .uganda: return ["uganda"]
|
|
||||||
case .southAfrica: return ["south-africa"]
|
|
||||||
case .canada: return ["canada"]
|
|
||||||
case .unitedStates: return ["united-states"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StorySummary {
|
|
||||||
/// Backend `tags[]` are the source of truth; fall back to `topic` during the
|
|
||||||
/// transition while the backend finishes emitting tags.
|
|
||||||
var effectiveTags: Set<String> {
|
|
||||||
let clean = Set((tags ?? []).filter { !$0.isEmpty })
|
|
||||||
return clean.isEmpty ? [topic] : clean
|
|
||||||
}
|
|
||||||
|
|
||||||
func matches(_ pill: StoryPill) -> Bool {
|
|
||||||
pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func inSection(_ section: NewsSection) -> Bool {
|
|
||||||
!effectiveTags.isDisjoint(with: section.slugs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Wordmark
|
|
||||||
|
|
||||||
struct JarvisWordmark: View {
|
|
||||||
var size: CGFloat = 30
|
|
||||||
private var font: Font { .custom("Courier New", size: size).bold() }
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
Text("j").font(font).foregroundStyle(Palette.orange)
|
|
||||||
Text("arvis").font(font).foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - News sections (the "All" front page)
|
|
||||||
//
|
|
||||||
// Groups the firehose into Apple/Google-News-style sections. A story lands in
|
|
||||||
// the first section (by this order) whose slugs it carries; `pill` powers "See all".
|
|
||||||
|
|
||||||
struct NewsSection: Identifiable {
|
|
||||||
let id: String
|
|
||||||
let label: String
|
|
||||||
let slugs: Set<String>
|
|
||||||
let pill: StoryPill?
|
|
||||||
|
|
||||||
static let sections: [NewsSection] = [
|
|
||||||
.init(id: "us", label: "US", slugs: ["united-states"], pill: .unitedStates),
|
|
||||||
.init(id: "world", label: "World", slugs: ["world-news", "news", "news-and-current-affairs"], pill: nil),
|
|
||||||
.init(id: "politics", label: "Politics", slugs: ["politics", "human-rights", "investigative"], pill: nil),
|
|
||||||
.init(id: "business", label: "Business", slugs: ["business", "finance", "fintech", "cryptocurrency", "blockchain", "entrepreneur", "startups", "real-estate"], pill: nil),
|
|
||||||
.init(id: "ai", label: "AI", slugs: ["artificial-intelligence", "machine-learning", "robotics"], pill: .ai),
|
|
||||||
.init(id: "technology", label: "Technology", slugs: ["technology", "programming-and-software-development", "cybersecurity", "security", "web-design-and-ui-ux", "wordpress-and-web-development", "privacy-and-data-protection", "cloud-computing"], pill: .tech),
|
|
||||||
.init(id: "africa", label: "Africa", slugs: ["africa", "east-africa", "south-africa", "uganda"], pill: nil),
|
|
||||||
.init(id: "sport", label: "Sport", slugs: ["sports", "esports", "formula-1"], pill: .sport),
|
|
||||||
.init(id: "science", label: "Science", slugs: ["science", "astronomy", "environment", "green-technology", "sustainability"], pill: nil),
|
|
||||||
.init(id: "entertainment", label: "Entertainment", slugs: ["entertainment", "music", "pop-culture", "video-game", "arts-and-culture", "books-and-literature"], pill: nil),
|
|
||||||
.init(id: "health", label: "Health", slugs: ["health", "health-and-wellness", "mental-health"], pill: nil),
|
|
||||||
.init(id: "lifestyle", label: "Lifestyle", slugs: ["lifestyle", "food-and-drink", "travel", "gardening", "outdoor-and-hiking", "parenting-and-family", "interior-design-and-home-decor", "photography", "self-improvement-and-personal-development", "education", "e-learning"], pill: nil),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
BIN
Jervis/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Jervis/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
21
Jervis/Assets.xcassets/JervisIcon.imageset/Contents.json
vendored
Normal file
21
Jervis/Assets.xcassets/JervisIcon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "JervisIcon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Jervis/Assets.xcassets/JervisIcon.imageset/JervisIcon.png
vendored
Normal file
BIN
Jervis/Assets.xcassets/JervisIcon.imageset/JervisIcon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
@@ -106,6 +106,8 @@ final class ConnectivityManager: ObservableObject {
|
|||||||
guard host != lastActivated else { return } // avoid thrashing the WS
|
guard host != lastActivated else { return } // avoid thrashing the WS
|
||||||
lastActivated = host
|
lastActivated = host
|
||||||
await ServerSettings.shared.activate(host: host)
|
await ServerSettings.shared.activate(host: host)
|
||||||
await StoryStore.shared.loadStories(refresh: true)
|
// Full paginated load — page 1 only misses unread stories on pages 2–5
|
||||||
|
// that may have arrived since last session.
|
||||||
|
await StoryStore.shared.loadFeedFull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>com.kisani.jarvis.briefing.refresh</string>
|
<string>com.kisani.jarvis.briefing.refresh</string>
|
||||||
|
<string>com.kisani.jarvis.feed.refresh</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Jarvis</string>
|
<string>Jervis</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -21,9 +22,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSApplicationQueriesSchemes</key>
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>tailscale</string>
|
<string>tailscale</string>
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Jarvis connects to your self-hosted news platform on the local network.</string>
|
<string>Jervis connects to your self-hosted news platform on the local network.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
@@ -53,7 +54,5 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIUserInterfaceStyle</key>
|
|
||||||
<string>Dark</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -2,36 +2,49 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import UIKit
|
import UIKit // needed for AppDelegate
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
|
|
||||||
// MARK: - Logo + splash
|
// MARK: - Splash
|
||||||
|
|
||||||
/// The three-bar logo that mirrors the app icon.
|
|
||||||
struct JarvisLogoMark: View {
|
|
||||||
var width: CGFloat = 180
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .center, spacing: 8) {
|
|
||||||
// Orange (short) · White (longest) · Grey (medium) — same ratios as icon
|
|
||||||
Capsule().fill(Palette.orange)
|
|
||||||
.frame(width: width * 0.48, height: width * 0.075)
|
|
||||||
.shadow(color: Palette.orange.opacity(0.55), radius: 10, y: 2)
|
|
||||||
Capsule().fill(Color.white)
|
|
||||||
.frame(width: width, height: width * 0.075)
|
|
||||||
Capsule().fill(Color(hex: "888888"))
|
|
||||||
.frame(width: width * 0.74, height: width * 0.075)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SplashView: View {
|
struct SplashView: View {
|
||||||
|
var onDismiss: () -> Void
|
||||||
|
|
||||||
|
@State private var iconScale: CGFloat = 0.82
|
||||||
|
@State private var iconOpacity: Double = 0
|
||||||
|
@State private var markOpacity: Double = 0
|
||||||
|
@State private var rootOpacity: Double = 1
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0A0A0A").ignoresSafeArea()
|
Color(hex: "0A0A0A").ignoresSafeArea()
|
||||||
VStack(spacing: 28) {
|
VStack(spacing: 22) {
|
||||||
JarvisLogoMark(width: 180)
|
Image("JervisIcon")
|
||||||
JarvisWordmark(size: 34)
|
.resizable()
|
||||||
|
.frame(width: 108, height: 108)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(color: Palette.orange.opacity(0.35), radius: 22, y: 4)
|
||||||
|
.scaleEffect(iconScale)
|
||||||
|
.opacity(iconOpacity)
|
||||||
|
|
||||||
|
JarvisWordmark(size: 28)
|
||||||
|
.opacity(markOpacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.opacity(rootOpacity)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.spring(response: 0.48, dampingFraction: 0.74).delay(0.08)) {
|
||||||
|
iconScale = 1
|
||||||
|
iconOpacity = 1
|
||||||
|
}
|
||||||
|
withAnimation(.easeOut(duration: 0.28).delay(0.32)) {
|
||||||
|
markOpacity = 1
|
||||||
|
}
|
||||||
|
withAnimation(.easeIn(duration: 0.26).delay(1.6)) {
|
||||||
|
rootOpacity = 0
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.9) {
|
||||||
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +82,8 @@ struct JarvisApp: App {
|
|||||||
@StateObject private var connectivity = ConnectivitySettings.shared
|
@StateObject private var connectivity = ConnectivitySettings.shared
|
||||||
@StateObject private var connManager = ConnectivityManager.shared
|
@StateObject private var connManager = ConnectivityManager.shared
|
||||||
@StateObject private var notifications = NotificationManager.shared
|
@StateObject private var notifications = NotificationManager.shared
|
||||||
@State private var showSplash = true
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@State private var splashDone = false
|
||||||
|
|
||||||
// Single ModelContainer instance shared with BackgroundRefreshManager.
|
// Single ModelContainer instance shared with BackgroundRefreshManager.
|
||||||
private let container: ModelContainer = {
|
private let container: ModelContainer = {
|
||||||
@@ -81,10 +95,7 @@ struct JarvisApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ZStack {
|
ZStack {
|
||||||
if showSplash {
|
// Main app always rendered underneath so it's ready when splash fades.
|
||||||
SplashView()
|
|
||||||
.transition(.opacity)
|
|
||||||
} else {
|
|
||||||
Group {
|
Group {
|
||||||
if settings.isConfigured {
|
if settings.isConfigured {
|
||||||
RootTabView()
|
RootTabView()
|
||||||
@@ -92,10 +103,11 @@ struct JarvisApp: App {
|
|||||||
OnboardingView()
|
OnboardingView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
|
||||||
|
if !splashDone {
|
||||||
|
SplashView { splashDone = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeOut(duration: 0.5), value: showSplash)
|
|
||||||
.preferredColorScheme(appearanceMode.colorScheme)
|
.preferredColorScheme(appearanceMode.colorScheme)
|
||||||
.environmentObject(settings)
|
.environmentObject(settings)
|
||||||
.environmentObject(store)
|
.environmentObject(store)
|
||||||
@@ -106,12 +118,31 @@ struct JarvisApp: App {
|
|||||||
.task {
|
.task {
|
||||||
BackgroundRefreshManager.container = container
|
BackgroundRefreshManager.container = container
|
||||||
BackgroundRefreshManager.scheduleFeedRefresh()
|
BackgroundRefreshManager.scheduleFeedRefresh()
|
||||||
|
// Pre-populate the feed from SwiftData cache before the first API
|
||||||
|
// fetch completes — prevents the cold-launch empty state.
|
||||||
|
store.bootstrap(container: container)
|
||||||
connManager.start()
|
connManager.start()
|
||||||
await connManager.resolveAndActivate()
|
await connManager.resolveAndActivate()
|
||||||
await notifications.bootstrap()
|
await notifications.bootstrap()
|
||||||
// Hold splash until setup is done, minimum 1.3 s for legibility.
|
store.startForegroundSync()
|
||||||
try? await Task.sleep(nanoseconds: 1_300_000_000)
|
}
|
||||||
showSplash = false
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .active:
|
||||||
|
store.startForegroundSync()
|
||||||
|
// Only trigger a load if connectivity has already been resolved.
|
||||||
|
// On cold launch activeHost is nil until resolveAndActivate()
|
||||||
|
// completes — calling loadFeedFull() before that always throws
|
||||||
|
// APIError.notConnected and leaves lastSyncedAt permanently nil.
|
||||||
|
// activate() calls loadFeedFull() itself once the host is confirmed.
|
||||||
|
if connManager.activeHost != nil {
|
||||||
|
Task { await store.loadFeedFull() }
|
||||||
|
}
|
||||||
|
case .background:
|
||||||
|
store.stopForegroundSync()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(container)
|
.modelContainer(container)
|
||||||
@@ -61,6 +61,11 @@ struct StorySummary: Codable, Identifiable, Hashable {
|
|||||||
let conflict: String?
|
let conflict: String?
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
let createdAt: Date
|
let createdAt: Date
|
||||||
|
/// When the cluster first appeared. `updatedAt` bumps on every article the
|
||||||
|
/// backend attaches to the cluster (even a loosely-related one), so it can
|
||||||
|
/// read "1 hr ago" on stories that are actually weeks old — `firstSeenAt`
|
||||||
|
/// is the honest age. Optional so decoding tolerates older API responses.
|
||||||
|
let firstSeenAt: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ScoreBreakdown: Codable, Hashable {
|
struct ScoreBreakdown: Codable, Hashable {
|
||||||
@@ -136,6 +141,10 @@ struct PaginatedStories: Codable {
|
|||||||
let nextCursor: String?
|
let nextCursor: String?
|
||||||
let hasMore: Bool
|
let hasMore: Bool
|
||||||
let total: Int
|
let total: Int
|
||||||
|
let unreadCount: Int?
|
||||||
|
let readSuppressedCount: Int?
|
||||||
|
let lastReadSyncAt: Date?
|
||||||
|
let activePill: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - WebSocket Events
|
// MARK: - WebSocket Events
|
||||||
@@ -178,6 +187,13 @@ final class CachedStory {
|
|||||||
var consensus: String?
|
var consensus: String?
|
||||||
var conflict: String?
|
var conflict: String?
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
|
/// Original publish timestamp. Defaults to updatedAt for rows cached before
|
||||||
|
/// this field was added — SwiftData handles the automatic migration.
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
/// When the cluster first appeared — the honest age, unlike `updatedAt` which
|
||||||
|
/// bumps whenever the backend attaches another article. Defaults to `updatedAt`
|
||||||
|
/// for rows cached before this field was added.
|
||||||
|
var firstSeenAt: Date = Date()
|
||||||
var cachedAt: Date
|
var cachedAt: Date
|
||||||
|
|
||||||
init(from summary: StorySummary) {
|
init(from summary: StorySummary) {
|
||||||
@@ -191,6 +207,8 @@ final class CachedStory {
|
|||||||
self.consensus = summary.consensus
|
self.consensus = summary.consensus
|
||||||
self.conflict = summary.conflict
|
self.conflict = summary.conflict
|
||||||
self.updatedAt = summary.updatedAt
|
self.updatedAt = summary.updatedAt
|
||||||
|
self.createdAt = summary.createdAt
|
||||||
|
self.firstSeenAt = summary.firstSeenAt ?? summary.updatedAt
|
||||||
self.cachedAt = Date()
|
self.cachedAt = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,23 +33,37 @@ private struct APIErrorResponse: Decodable {
|
|||||||
let error: Inner
|
let error: Inner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct EmptyResponse: Decodable {}
|
||||||
|
|
||||||
actor APIClient {
|
actor APIClient {
|
||||||
static let shared = APIClient()
|
static let shared = APIClient()
|
||||||
|
|
||||||
private var baseURL: URL?
|
private var baseURL: URL?
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
|
private let deviceId: String
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.timeoutIntervalForRequest = 10
|
config.timeoutIntervalForRequest = 10
|
||||||
self.session = URLSession(configuration: config)
|
self.session = URLSession(configuration: config)
|
||||||
|
self.deviceId = APIClient.loadDeviceId()
|
||||||
|
|
||||||
self.decoder = JSONDecoder()
|
self.decoder = JSONDecoder()
|
||||||
self.decoder.dateDecodingStrategy = .iso8601
|
self.decoder.dateDecodingStrategy = .iso8601
|
||||||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func loadDeviceId() -> String {
|
||||||
|
let key = "jarvis.device.id"
|
||||||
|
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let value = UUID().uuidString.lowercased()
|
||||||
|
UserDefaults.standard.set(value, forKey: key)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func configure(host: String) throws {
|
func configure(host: String) throws {
|
||||||
guard let url = URL(string: "http://\(host)/api/v1") else {
|
guard let url = URL(string: "http://\(host)/api/v1") else {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
@@ -68,12 +82,15 @@ actor APIClient {
|
|||||||
func fetchStories(
|
func fetchStories(
|
||||||
cursor: String? = nil,
|
cursor: String? = nil,
|
||||||
topic: String? = nil,
|
topic: String? = nil,
|
||||||
|
tags: Set<String> = [],
|
||||||
minSignal: Int? = nil,
|
minSignal: Int? = nil,
|
||||||
limit: Int = 20
|
limit: Int = 20,
|
||||||
|
includeRead: Bool = false
|
||||||
) async throws -> PaginatedStories {
|
) async throws -> PaginatedStories {
|
||||||
var params: [String: String] = ["limit": "\(limit)"]
|
var params: [String: String] = ["limit": "\(limit)", "include_read": includeRead ? "true" : "false"]
|
||||||
if let cursor { params["after"] = cursor }
|
if let cursor { params["after"] = cursor }
|
||||||
if let topic { params["topic"] = topic }
|
if let topic { params["topic"] = topic }
|
||||||
|
if !tags.isEmpty { params["tags"] = tags.sorted().joined(separator: ",") }
|
||||||
if let minSignal { params["min_signal"] = "\(minSignal)" }
|
if let minSignal { params["min_signal"] = "\(minSignal)" }
|
||||||
return try await get("/stories", params: params)
|
return try await get("/stories", params: params)
|
||||||
}
|
}
|
||||||
@@ -82,6 +99,28 @@ actor APIClient {
|
|||||||
try await get("/stories/\(id)")
|
try await get("/stories/\(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markStoryRead(id: String) async throws {
|
||||||
|
try await postVoid("/stories/\(id)/read", body: [
|
||||||
|
"device_id": deviceId,
|
||||||
|
"read_at": ISO8601DateFormatter().string(from: Date()),
|
||||||
|
"source": "ios",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func markStoryUnread(id: String) async throws {
|
||||||
|
try await delete("/stories/\(id)/read")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markAllRead(storyIds: [String], pill: String) async throws {
|
||||||
|
try await postVoid("/me/read/bulk", body: [
|
||||||
|
"storyIds": storyIds,
|
||||||
|
"readAt": ISO8601DateFormatter().string(from: Date()),
|
||||||
|
"source": "mark_all_as_read",
|
||||||
|
"pill": pill,
|
||||||
|
"deviceId": deviceId,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Articles
|
// MARK: - Articles
|
||||||
|
|
||||||
func fetchArticle(id: String) async throws -> Article {
|
func fetchArticle(id: String) async throws -> Article {
|
||||||
@@ -116,6 +155,7 @@ actor APIClient {
|
|||||||
guard let url = components.url else { throw APIError.invalidURL }
|
guard let url = components.url else { throw APIError.invalidURL }
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
|
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||||
return try await execute(request)
|
return try await execute(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,16 +164,32 @@ actor APIClient {
|
|||||||
let url = base.appendingPathComponent(path)
|
let url = base.appendingPathComponent(path)
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
|
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
return try await execute(request)
|
return try await execute(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func postVoid(_ path: String, body: [String: Any]) async throws {
|
||||||
|
guard let base = baseURL else { throw APIError.notConnected }
|
||||||
|
let url = base.appendingPathComponent(path)
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
let (_, response) = try await session.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||||
|
throw APIError.serverError(code: "post_failed", message: "Post failed", status: 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func delete(_ path: String) async throws {
|
private func delete(_ path: String) async throws {
|
||||||
guard let base = baseURL else { throw APIError.notConnected }
|
guard let base = baseURL else { throw APIError.notConnected }
|
||||||
let url = base.appendingPathComponent(path)
|
let url = base.appendingPathComponent(path)
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "DELETE"
|
request.httpMethod = "DELETE"
|
||||||
|
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||||
let (_, response) = try await session.data(for: request)
|
let (_, response) = try await session.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
|
guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
|
||||||
throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500)
|
throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500)
|
||||||
@@ -15,7 +15,6 @@ import BackgroundTasks
|
|||||||
|
|
||||||
enum BriefingPeriod { case morning, evening
|
enum BriefingPeriod { case morning, evening
|
||||||
var greeting: String { self == .morning ? "Good morning" : "Good evening" }
|
var greeting: String { self == .morning ? "Good morning" : "Good evening" }
|
||||||
var emoji: String { self == .morning ? "☀️" : "🌙" }
|
|
||||||
var requestID: String { self == .morning ? "briefing.morning" : "briefing.evening" }
|
var requestID: String { self == .morning ? "briefing.morning" : "briefing.evening" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +40,11 @@ final class NotificationManager: NSObject, ObservableObject {
|
|||||||
func bootstrap() async {
|
func bootstrap() async {
|
||||||
center.delegate = self
|
center.delegate = self
|
||||||
await refreshAuthStatus()
|
await refreshAuthStatus()
|
||||||
|
// Prompt for permission on first launch (.notDetermined).
|
||||||
|
// Re-requesting after denial is a no-op (iOS ignores it) — safe to call every launch.
|
||||||
|
if authStatus == .notDetermined {
|
||||||
|
await requestAuthorization()
|
||||||
|
}
|
||||||
subscribeToBreakingEvents()
|
subscribeToBreakingEvents()
|
||||||
await applySettings()
|
await applySettings()
|
||||||
}
|
}
|
||||||
@@ -110,7 +114,7 @@ final class NotificationManager: NSObject, ObservableObject {
|
|||||||
/// Build the "Jarvis" briefing: greeting + weather/rain + top stories to watch.
|
/// Build the "Jarvis" briefing: greeting + weather/rain + top stories to watch.
|
||||||
private func buildBriefing(_ period: BriefingPeriod) async -> UNMutableNotificationContent {
|
private func buildBriefing(_ period: BriefingPeriod) async -> UNMutableNotificationContent {
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = "\(period.greeting) \(period.emoji)"
|
content.title = period.greeting
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
|
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
@@ -123,7 +127,7 @@ final class NotificationManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
let top = await topStories(limit: 3)
|
let top = await topStories(limit: 3)
|
||||||
if !top.isEmpty {
|
if !top.isEmpty {
|
||||||
lines.append("📣 Needs your attention:")
|
lines.append("Needs your attention:")
|
||||||
for s in top { lines.append("• \(s.headline) (\(s.signalScore))") }
|
for s in top { lines.append("• \(s.headline) (\(s.signalScore))") }
|
||||||
} else if lines.isEmpty {
|
} else if lines.isEmpty {
|
||||||
lines.append("No new high-signal stories right now.")
|
lines.append("No new high-signal stories right now.")
|
||||||
@@ -51,8 +51,8 @@ final class NotificationSettings: ObservableObject {
|
|||||||
weatherEnabled = s.weatherEnabled; locationName = s.locationName
|
weatherEnabled = s.weatherEnabled; locationName = s.locationName
|
||||||
latitude = s.latitude; longitude = s.longitude; resolvedPlace = s.resolvedPlace
|
latitude = s.latitude; longitude = s.longitude; resolvedPlace = s.resolvedPlace
|
||||||
} else {
|
} else {
|
||||||
enabled = false
|
enabled = true
|
||||||
breakingEnabled = true; breakingMinSignal = 80
|
breakingEnabled = true; breakingMinSignal = 65
|
||||||
morningEnabled = true; morningHour = 7; morningMinute = 0
|
morningEnabled = true; morningHour = 7; morningMinute = 0
|
||||||
eveningEnabled = true; eveningHour = 18; eveningMinute = 0
|
eveningEnabled = true; eveningHour = 18; eveningMinute = 0
|
||||||
weatherEnabled = true; locationName = ""
|
weatherEnabled = true; locationName = ""
|
||||||
@@ -65,6 +65,9 @@ enum BackgroundRefreshManager {
|
|||||||
|
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
||||||
|
// Pre-cache articles for offline reading
|
||||||
|
await preCacheArticles(page.data, container: container)
|
||||||
|
|
||||||
if !newStories.isEmpty {
|
if !newStories.isEmpty {
|
||||||
notifyNewStories(newStories)
|
notifyNewStories(newStories)
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,30 @@ enum BackgroundRefreshManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Article pre-caching
|
||||||
|
|
||||||
|
/// Fetch and store the top article for each high-signal uncached story so
|
||||||
|
/// the user can read them offline without having opened them first.
|
||||||
|
/// Safe to call in background or foreground — creates its own ModelContext.
|
||||||
|
static func preCacheArticles(_ stories: [StorySummary], container: ModelContainer) async {
|
||||||
|
let context = ModelContext(container)
|
||||||
|
let top = stories
|
||||||
|
.filter { $0.signalScore >= 60 && !$0.sources.isEmpty }
|
||||||
|
.sorted { $0.signalScore > $1.signalScore }
|
||||||
|
.prefix(20)
|
||||||
|
|
||||||
|
for story in top {
|
||||||
|
let sid = story.id
|
||||||
|
let alreadyCached = (try? context.fetch(
|
||||||
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
|
||||||
|
))?.isEmpty == false
|
||||||
|
guard !alreadyCached, let src = story.sources.first else { continue }
|
||||||
|
guard let art = try? await APIClient.shared.fetchArticle(id: src.id) else { continue }
|
||||||
|
context.insert(CachedArticle(from: art))
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - New-story notifications
|
// MARK: - New-story notifications
|
||||||
|
|
||||||
// Slugs for the featured categories the user cares about.
|
// Slugs for the featured categories the user cares about.
|
||||||
@@ -10,11 +10,8 @@ enum CacheMaintenance {
|
|||||||
private static let hour: TimeInterval = 3_600
|
private static let hour: TimeInterval = 3_600
|
||||||
private static let day: TimeInterval = 86_400
|
private static let day: TimeInterval = 86_400
|
||||||
|
|
||||||
// Aligned to the backend's aging model:
|
private static let recentHours = 72.0 // 3 days: mirrors backend active story window
|
||||||
// RECENT_HOURS = 72 → active feed window; drop cached stories past it
|
private static let markerHours = 192.0 // 8 days: read suppression survives weekly repeats
|
||||||
// raw-article retention ~14 days → match for cached article bodies
|
|
||||||
private static let recentHours = 72.0 // backend active window (story list)
|
|
||||||
private static let markerHours = 96.0 // seen/read markers: active window + buffer
|
|
||||||
private static let articleDays = 14.0 // cached article bodies (backend article retention)
|
private static let articleDays = 14.0 // cached article bodies (backend article retention)
|
||||||
|
|
||||||
/// Age-based eviction. Run on launch. Cheap; protects saved stories.
|
/// Age-based eviction. Run on launch. Cheap; protects saved stories.
|
||||||
334
Jervis/Store/StoryStore.swift
Normal file
334
Jervis/Store/StoryStore.swift
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
// StoryStore.swift
|
||||||
|
// Jarvis — central observable store. Views read from here, never hit the API directly.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class StoryStore: ObservableObject {
|
||||||
|
static let shared = StoryStore()
|
||||||
|
|
||||||
|
@Published var stories: [StorySummary] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var isLoadingMore = false
|
||||||
|
@Published var error: APIError?
|
||||||
|
@Published var lastSyncedAt: Date?
|
||||||
|
@Published var hasMore = false
|
||||||
|
@Published var selectedTopic: String? = nil
|
||||||
|
@Published var selectedTags: Set<String> = []
|
||||||
|
@Published var sectionSupplement: [String: [StorySummary]] = [:]
|
||||||
|
|
||||||
|
private var nextCursor: String?
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
private var foregroundSyncTask: Task<Void, Never>?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Quick cache (UserDefaults — synchronous, no async gap)
|
||||||
|
|
||||||
|
private let quickCacheKey = "jarvis.quickStories.v1"
|
||||||
|
private let quickCacheMaxAge: TimeInterval = 84 * 3600 // 3.5 days
|
||||||
|
|
||||||
|
/// Restore the last-known feed from UserDefaults synchronously on init.
|
||||||
|
/// This runs before the first SwiftUI render so the feed is never blank.
|
||||||
|
private func restoreQuickCache() {
|
||||||
|
guard let raw = UserDefaults.standard.data(forKey: quickCacheKey) else { return }
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
guard let payload = try? decoder.decode(QuickCachePayload.self, from: raw) else { return }
|
||||||
|
// Discard if too stale — matches CacheMaintenance window.
|
||||||
|
guard Date().timeIntervalSince(payload.savedAt) < quickCacheMaxAge else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: quickCacheKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !payload.stories.isEmpty { stories = payload.stories }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the current feed to UserDefaults so it survives process kills.
|
||||||
|
/// Encoding runs off the main thread; UserDefaults.set is thread-safe.
|
||||||
|
private func persistQuickCache() {
|
||||||
|
let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date())
|
||||||
|
let key = quickCacheKey
|
||||||
|
Task.detached {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
if let data = try? encoder.encode(payload) {
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct QuickCachePayload: Codable {
|
||||||
|
let stories: [StorySummary]
|
||||||
|
let savedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SwiftData bootstrap (secondary, async — covers first-ever launch before
|
||||||
|
// the quick cache exists, e.g. app reinstall).
|
||||||
|
func bootstrap(container: ModelContainer) {
|
||||||
|
guard stories.isEmpty else { return }
|
||||||
|
let ctx = ModelContext(container)
|
||||||
|
if let cached = try? ctx.fetch(FetchDescriptor<CachedStory>()) {
|
||||||
|
let sorted = cached.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
||||||
|
if !sorted.isEmpty { stories = sorted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Foreground sync
|
||||||
|
|
||||||
|
private var isFetchingFull = false
|
||||||
|
|
||||||
|
/// Load page 1 then paginate to ≤100 stories.
|
||||||
|
/// Guarded against concurrent invocations and rate-limited to ≥30 s between
|
||||||
|
/// fetches so rapid foreground/background cycles don't hammer the API.
|
||||||
|
func loadFeedFull() async {
|
||||||
|
guard !isFetchingFull else { return }
|
||||||
|
// Rate-limit: if the last *successful* sync was < 30 s ago, skip.
|
||||||
|
if let last = lastSyncedAt, Date().timeIntervalSince(last) < 30 { return }
|
||||||
|
isFetchingFull = true
|
||||||
|
defer { isFetchingFull = false }
|
||||||
|
|
||||||
|
await loadStories(refresh: true)
|
||||||
|
var pages = 0
|
||||||
|
while !Task.isCancelled, hasMore, stories.count < 100, pages < 4 {
|
||||||
|
await loadMore()
|
||||||
|
pages += 1
|
||||||
|
}
|
||||||
|
loadSectionSupplements()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call when app becomes active. Polls every 90 s so new stories surface fast.
|
||||||
|
func startForegroundSync() {
|
||||||
|
foregroundSyncTask?.cancel()
|
||||||
|
foregroundSyncTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 90 * 1_000_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await self?.loadFeedFull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopForegroundSync() {
|
||||||
|
foregroundSyncTask?.cancel()
|
||||||
|
foregroundSyncTask = nil
|
||||||
|
}
|
||||||
|
private let ws = WebSocketManager.shared
|
||||||
|
private let api = APIClient.shared
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
restoreQuickCache() // synchronous — stories are ready before first render
|
||||||
|
subscribeToWebSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func loadStories(refresh: Bool = false) async -> Int {
|
||||||
|
guard !isLoading else { return 0 }
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
let cursor = refresh ? nil : nextCursor
|
||||||
|
var newCount = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await api.fetchStories(
|
||||||
|
cursor: cursor,
|
||||||
|
topic: selectedTopic,
|
||||||
|
tags: selectedTags
|
||||||
|
)
|
||||||
|
if refresh {
|
||||||
|
// Replace list; count only genuinely new IDs for reshuffle logic.
|
||||||
|
let existingIds = Set(stories.map(\.id))
|
||||||
|
newCount = result.data.filter { !existingIds.contains($0.id) }.count
|
||||||
|
stories = result.data
|
||||||
|
} else {
|
||||||
|
// Append only IDs not already present — cursor can overlap.
|
||||||
|
let existingIds = Set(stories.map(\.id))
|
||||||
|
let novel = result.data.filter { !existingIds.contains($0.id) }
|
||||||
|
stories += novel
|
||||||
|
newCount = novel.count
|
||||||
|
}
|
||||||
|
nextCursor = result.nextCursor
|
||||||
|
hasMore = result.hasMore
|
||||||
|
lastSyncedAt = Date()
|
||||||
|
persistQuickCache() // survives the next process kill
|
||||||
|
preCacheArticlesInBackground()
|
||||||
|
} catch let e as APIError where !e.isCancelled {
|
||||||
|
error = e
|
||||||
|
print("[StoryStore] fetch failed: \(e.localizedDescription)")
|
||||||
|
} catch {
|
||||||
|
print("[StoryStore] unexpected fetch error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
return newCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preCacheArticlesInBackground() {
|
||||||
|
guard let container = BackgroundRefreshManager.container else { return }
|
||||||
|
let snapshot = stories
|
||||||
|
Task.detached { await BackgroundRefreshManager.preCacheArticles(snapshot, container: container) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge cached stories into the live feed (used when pull-refresh finds nothing new).
|
||||||
|
func mergeStories(_ extra: [StorySummary]) async {
|
||||||
|
let existing = Set(stories.map(\.id))
|
||||||
|
let novel = extra.filter { !existing.contains($0.id) }
|
||||||
|
guard !novel.isEmpty else { return }
|
||||||
|
stories = (stories + novel).sorted(by: StorySummary.feedOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMore() async {
|
||||||
|
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
do {
|
||||||
|
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags)
|
||||||
|
// Deduplicate: cursor-based pages can overlap when stories are added mid-fetch.
|
||||||
|
let existingIds = Set(stories.map(\.id))
|
||||||
|
let novel = result.data.filter { !existingIds.contains($0.id) }
|
||||||
|
stories += novel
|
||||||
|
nextCursor = result.nextCursor
|
||||||
|
hasMore = result.hasMore
|
||||||
|
} catch {
|
||||||
|
print("[StoryStore] loadMore error: \(error)")
|
||||||
|
}
|
||||||
|
isLoadingMore = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coalesce bursts of `story.created` WS events — waits 3 s then does a
|
||||||
|
/// full paginated load so stories on page 2+ are captured.
|
||||||
|
private func scheduleCoalescedRefresh() {
|
||||||
|
refreshTask?.cancel()
|
||||||
|
refreshTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await self?.loadFeedFull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Section supplements
|
||||||
|
// Sections with regional or niche tags rarely rank in the global top-100,
|
||||||
|
// so we fetch a small targeted pool for each sparse section and let
|
||||||
|
// makeDigest() use it as a fallback.
|
||||||
|
|
||||||
|
private let supplementSections: [(id: String, tags: Set<String>)] = [
|
||||||
|
("east-africa", ["east-africa"]),
|
||||||
|
("africa", ["africa"]),
|
||||||
|
("sport", ["sports", "esports", "formula-1"]),
|
||||||
|
("science", ["science", "astronomy"]),
|
||||||
|
("health", ["health", "health-and-wellness"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
func loadSectionSupplements() {
|
||||||
|
guard selectedTags.isEmpty else { return } // only for "All" pill
|
||||||
|
let sections = supplementSections
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
var merged: [String: [StorySummary]] = [:]
|
||||||
|
await withTaskGroup(of: (String, [StorySummary]?).self) { group in
|
||||||
|
for sec in sections {
|
||||||
|
let id = sec.id
|
||||||
|
let tags = sec.tags
|
||||||
|
group.addTask {
|
||||||
|
let result = try? await APIClient.shared.fetchStories(tags: tags, limit: 8)
|
||||||
|
return (id, result?.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for await (id, data) in group {
|
||||||
|
if let data { merged[id] = data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.sectionSupplement = merged // single main-thread update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTopic(_ topic: String?) async {
|
||||||
|
selectedTopic = topic
|
||||||
|
selectedTags = []
|
||||||
|
await loadFeedFull()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPill(_ pill: StoryPill) async {
|
||||||
|
selectedTopic = nil
|
||||||
|
selectedTags = pill.slugs
|
||||||
|
nextCursor = nil
|
||||||
|
// Filter changed — must fetch regardless of rate-limit, otherwise
|
||||||
|
// the old stories stay in place and the new pill finds almost nothing.
|
||||||
|
lastSyncedAt = nil
|
||||||
|
await loadFeedFull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WebSocket
|
||||||
|
|
||||||
|
private func subscribeToWebSocket() {
|
||||||
|
ws.events
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] event in
|
||||||
|
self?.handle(event: event)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(event: WSEvent) {
|
||||||
|
switch event.type {
|
||||||
|
|
||||||
|
case .storyUpdated:
|
||||||
|
guard let id = event.storyId,
|
||||||
|
let score = event.signalScore,
|
||||||
|
let count = event.sourceCount else { return }
|
||||||
|
updateStory(id: id, signalScore: score, sourceCount: count)
|
||||||
|
Task { await refetch(storyId: id) }
|
||||||
|
|
||||||
|
case .storyCreated:
|
||||||
|
scheduleCoalescedRefresh()
|
||||||
|
|
||||||
|
case .storyStale:
|
||||||
|
guard let id = event.storyId else { return }
|
||||||
|
removeStory(id: id)
|
||||||
|
|
||||||
|
case .feedHealth:
|
||||||
|
NotificationCenter.default.post(name: .feedHealthChanged, object: event)
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateStory(id: String, signalScore: Int, sourceCount: Int) {
|
||||||
|
guard let idx = stories.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
let old = stories[idx]
|
||||||
|
let updated = StorySummary(
|
||||||
|
id: old.id,
|
||||||
|
headline: old.headline,
|
||||||
|
summary: old.summary,
|
||||||
|
topic: old.topic,
|
||||||
|
tags: old.tags,
|
||||||
|
signalScore: signalScore,
|
||||||
|
scoreBreakdown: old.scoreBreakdown,
|
||||||
|
sourceCount: sourceCount,
|
||||||
|
sources: old.sources,
|
||||||
|
consensus: old.consensus,
|
||||||
|
conflict: old.conflict,
|
||||||
|
updatedAt: old.updatedAt,
|
||||||
|
createdAt: old.createdAt,
|
||||||
|
firstSeenAt: old.firstSeenAt
|
||||||
|
)
|
||||||
|
stories[idx] = updated
|
||||||
|
stories.sort(by: StorySummary.feedOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeStory(id: String) {
|
||||||
|
stories.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refetch(storyId: String) async {
|
||||||
|
// Lightweight: only update what changed via WS patch above.
|
||||||
|
// Full detail is fetched lazily when user taps into the story.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let feedHealthChanged = Notification.Name("feedHealthChanged")
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ struct ConnectivityView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 26) {
|
VStack(alignment: .leading, spacing: 26) {
|
||||||
header
|
header
|
||||||
@@ -44,7 +44,7 @@ struct ConnectivityView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("Connectivity")
|
Text("Connectivity")
|
||||||
.font(.system(size: 22, weight: .heavy))
|
.font(.system(size: 22, weight: .heavy))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
@@ -84,7 +84,7 @@ struct ConnectivityView: View {
|
|||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text("Use \(providerName) when remote")
|
Text("Use \(providerName) when remote")
|
||||||
.font(.system(size: 16, weight: .heavy))
|
.font(.system(size: 16, weight: .heavy))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
Text("Connects via VPN when not on home network")
|
Text("Connects via VPN when not on home network")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(Color(hex: "888888"))
|
.foregroundStyle(Color(hex: "888888"))
|
||||||
@@ -160,7 +160,7 @@ struct ConnectivityView: View {
|
|||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text("Auto-connect on network change")
|
Text("Auto-connect on network change")
|
||||||
.font(.system(size: 16, weight: .heavy))
|
.font(.system(size: 16, weight: .heavy))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
Text("Prefers the LAN on a trusted network; switches to \(providerName) when away")
|
Text("Prefers the LAN on a trusted network; switches to \(providerName) when away")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(Color(hex: "888888"))
|
.foregroundStyle(Color(hex: "888888"))
|
||||||
@@ -175,7 +175,7 @@ struct ConnectivityView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("Wait before switching")
|
Text("Wait before switching")
|
||||||
.font(.system(size: 15, weight: .bold))
|
.font(.system(size: 15, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
Spacer()
|
Spacer()
|
||||||
Stepper(value: $connectivity.switchDelaySeconds, in: 1...60, step: 1) {
|
Stepper(value: $connectivity.switchDelaySeconds, in: 1...60, step: 1) {
|
||||||
Text("\(connectivity.switchDelaySeconds)s")
|
Text("\(connectivity.switchDelaySeconds)s")
|
||||||
@@ -195,7 +195,7 @@ struct ConnectivityView: View {
|
|||||||
}
|
}
|
||||||
Text("iOS can't start the \(providerName) tunnel for Jarvis — keep the \(providerName) app connected when away. Jarvis only picks the reachable address.")
|
Text("iOS can't start the \(providerName) tunnel for Jarvis — keep the \(providerName) app connected when away. Jarvis only picks the reachable address.")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(Color(hex: "666666"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
.lineSpacing(2)
|
.lineSpacing(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ struct ConnectivityView: View {
|
|||||||
Button { Task { await recheck() } } label: {
|
Button { Task { await recheck() } } label: {
|
||||||
Text("Re-check")
|
Text("Re-check")
|
||||||
.font(.system(size: 15, weight: .bold))
|
.font(.system(size: 15, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
.frame(maxWidth: .infinity).frame(height: 50)
|
.frame(maxWidth: .infinity).frame(height: 50)
|
||||||
.background(Palette.surface2)
|
.background(Palette.surface2)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
@@ -254,7 +254,7 @@ struct ConnectivityView: View {
|
|||||||
Text(t)
|
Text(t)
|
||||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||||
.kerning(0.8)
|
.kerning(0.8)
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func Card<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
private func Card<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||||
@@ -267,7 +267,7 @@ struct ConnectivityView: View {
|
|||||||
HStack(alignment: .center, spacing: 14) {
|
HStack(alignment: .center, spacing: 14) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 18, weight: .regular))
|
.font(.system(size: 18, weight: .regular))
|
||||||
.foregroundStyle(Color(hex: "999999"))
|
.foregroundStyle(Palette.secondaryText)
|
||||||
.frame(width: 26)
|
.frame(width: 26)
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
@@ -287,7 +287,7 @@ struct ConnectivityView: View {
|
|||||||
.foregroundStyle(Color(hex: "888888"))
|
.foregroundStyle(Color(hex: "888888"))
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TextField("", text: text,
|
TextField("", text: text,
|
||||||
prompt: Text(placeholder).foregroundColor(Color(hex: "444444")))
|
prompt: Text(placeholder).foregroundColor(Palette.mutedText))
|
||||||
.font(.system(size: 16, weight: .regular, design: .monospaced))
|
.font(.system(size: 16, weight: .regular, design: .monospaced))
|
||||||
.foregroundStyle(Palette.orange)
|
.foregroundStyle(Palette.orange)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
@@ -312,7 +312,7 @@ struct ConnectivityView: View {
|
|||||||
case .reachable: return Color(hex: "33C25E")
|
case .reachable: return Color(hex: "33C25E")
|
||||||
case .unreachable: return Color(hex: "C25555")
|
case .unreachable: return Color(hex: "C25555")
|
||||||
case .checking: return Palette.healthFailing
|
case .checking: return Palette.healthFailing
|
||||||
case .unknown: return Color(hex: "555555")
|
case .unknown: return Palette.tertiaryText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private func dot(for r: Reachability) -> some View {
|
private func dot(for r: Reachability) -> some View {
|
||||||
@@ -21,12 +21,12 @@ struct AddFeedSheet: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Add feed")
|
Text("Add feed")
|
||||||
.font(.system(size: 22, weight: .heavy))
|
.font(.system(size: 22, weight: .heavy))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
@@ -87,9 +87,9 @@ struct AddFeedSheet: View {
|
|||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||||
.kerning(0.8)
|
.kerning(0.8)
|
||||||
.foregroundStyle(Color(hex: "666666"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
TextField("", text: text,
|
TextField("", text: text,
|
||||||
prompt: Text(placeholder).foregroundColor(Color(hex: "444444")))
|
prompt: Text(placeholder).foregroundColor(Palette.mutedText))
|
||||||
.font(.system(size: 15, weight: .regular, design: mono ? .monospaced : .default))
|
.font(.system(size: 15, weight: .regular, design: mono ? .monospaced : .default))
|
||||||
.foregroundStyle(mono ? Palette.orange : .white)
|
.foregroundStyle(mono ? Palette.orange : .white)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
@@ -86,7 +86,7 @@ struct FeedManagerView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
serverCard
|
serverCard
|
||||||
@@ -141,7 +141,7 @@ struct FeedManagerView: View {
|
|||||||
Text("PLATFORM")
|
Text("PLATFORM")
|
||||||
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
||||||
.kerning(0.8)
|
.kerning(0.8)
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
Text(settings.host ?? "not configured")
|
Text(settings.host ?? "not configured")
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundStyle(Palette.orange)
|
.foregroundStyle(Palette.orange)
|
||||||
@@ -156,7 +156,7 @@ struct FeedManagerView: View {
|
|||||||
}
|
}
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .bold))
|
.font(.system(size: 12, weight: .bold))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.background(Palette.surface)
|
.background(Palette.surface)
|
||||||
@@ -173,10 +173,10 @@ struct FeedManagerView: View {
|
|||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Color(hex: "555555")))
|
TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Palette.tertiaryText))
|
||||||
.font(.system(size: 15, weight: .regular))
|
.font(.system(size: 15, weight: .regular))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ struct FeedManagerView: View {
|
|||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.black)
|
.background(Palette.background)
|
||||||
.refreshable { await vm.load() }
|
.refreshable { await vm.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ struct FeedManagerView: View {
|
|||||||
Section {
|
Section {
|
||||||
ForEach(feeds) { feed in
|
ForEach(feeds) { feed in
|
||||||
FeedRowView(feed: feed)
|
FeedRowView(feed: feed)
|
||||||
.listRowBackground(Color.black)
|
.listRowBackground(Palette.background)
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
||||||
.listRowSeparatorTint(Palette.hairline)
|
.listRowSeparatorTint(Palette.hairline)
|
||||||
.swipeActions(edge: .trailing) {
|
.swipeActions(edge: .trailing) {
|
||||||
@@ -230,7 +230,7 @@ struct FeedManagerView: View {
|
|||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||||
.kerning(0.8)
|
.kerning(0.8)
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 6, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 6, trailing: 16))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,16 +238,16 @@ struct FeedManagerView: View {
|
|||||||
private var loadingRow: some View {
|
private var loadingRow: some View {
|
||||||
ProgressView().tint(Palette.orange)
|
ProgressView().tint(Palette.orange)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.listRowBackground(Color.black)
|
.listRowBackground(Palette.background)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyRow: some View {
|
private var emptyRow: some View {
|
||||||
Text(query.isEmpty ? (vm.error ?? "No feeds configured.") : "No feeds match “\(query)”.")
|
Text(query.isEmpty ? (vm.error ?? "No feeds configured.") : "No feeds match “\(query)”.")
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.listRowBackground(Color.black)
|
.listRowBackground(Palette.background)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,11 +267,11 @@ struct FeedRowView: View {
|
|||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(feed.name)
|
Text(feed.name)
|
||||||
.font(.system(size: 15, weight: .bold))
|
.font(.system(size: 15, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(metaLine)
|
Text(metaLine)
|
||||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||||
.foregroundStyle(Color(hex: "666666"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,8 @@ struct SignalFeedView: View {
|
|||||||
/// Reference-type set so marking a story seen on scroll doesn't re-render.
|
/// Reference-type set so marking a story seen on scroll doesn't re-render.
|
||||||
@State private var seenTracker = SeenTracker()
|
@State private var seenTracker = SeenTracker()
|
||||||
@State private var showRead = false
|
@State private var showRead = false
|
||||||
|
@State private var markAllToast: String?
|
||||||
|
@State private var markAllPending = false
|
||||||
|
|
||||||
/// Stories that have full article content cached → eligible for the green dot.
|
/// Stories that have full article content cached → eligible for the green dot.
|
||||||
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
|
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
|
||||||
@@ -33,6 +35,8 @@ struct SignalFeedView: View {
|
|||||||
|
|
||||||
/// Live stories when connected; cached fallback while loading or offline so
|
/// Live stories when connected; cached fallback while loading or offline so
|
||||||
/// switching to Tech / AI / F1 shows cached cards instantly.
|
/// switching to Tech / AI / F1 shows cached cards instantly.
|
||||||
|
/// Before the first sync of this session completes, only unread stories are
|
||||||
|
/// surfaced — read content is irrelevant until we know there's nothing newer.
|
||||||
private var filteredStories: [StorySummary] {
|
private var filteredStories: [StorySummary] {
|
||||||
let base: [StorySummary]
|
let base: [StorySummary]
|
||||||
if !store.stories.isEmpty {
|
if !store.stories.isEmpty {
|
||||||
@@ -40,15 +44,23 @@ struct SignalFeedView: View {
|
|||||||
} else {
|
} else {
|
||||||
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
||||||
}
|
}
|
||||||
return base.filter { $0.matches(selectedPill) }
|
let matched = base.filter { $0.matches(selectedPill) }
|
||||||
|
if store.lastSyncedAt == nil {
|
||||||
|
return matched.filter { !readStoryIds.contains($0.id) }
|
||||||
|
}
|
||||||
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main feed: unread stories, with already-seen ones sunk below fresh ones.
|
/// The main feed: unread stories, with already-seen ones sunk below fresh
|
||||||
|
/// ones, and fossils (old by firstSeenAt, see StorySummary.isFossil) sunk
|
||||||
|
/// below everything — a high signalScore alone can't make old news lead.
|
||||||
private var mainStories: [StorySummary] {
|
private var mainStories: [StorySummary] {
|
||||||
let notRead = filteredStories.filter { !readStoryIds.contains($0.id) }
|
let notRead = filteredStories.filter { !readStoryIds.contains($0.id) }
|
||||||
let fresh = notRead.filter { !seenSnapshot.contains($0.id) }
|
let current = notRead.filter { !$0.isFossil }
|
||||||
let seen = notRead.filter { seenSnapshot.contains($0.id) }
|
let fossils = notRead.filter { $0.isFossil }
|
||||||
return fresh + seen
|
let fresh = current.filter { !seenSnapshot.contains($0.id) }
|
||||||
|
let seen = current.filter { seenSnapshot.contains($0.id) }
|
||||||
|
return fresh + seen + fossils
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read stories, tucked into the collapsible "Read" shelf.
|
/// Read stories, tucked into the collapsible "Read" shelf.
|
||||||
@@ -59,19 +71,36 @@ struct SignalFeedView: View {
|
|||||||
/// Every pill shows the card layout — sections are scoped to "All".
|
/// Every pill shows the card layout — sections are scoped to "All".
|
||||||
private var isDigest: Bool { true }
|
private var isDigest: Bool { true }
|
||||||
|
|
||||||
/// Front-page digest: Top Stories + up to 3 per category section, deduped.
|
/// Front-page digest: Top Stories + up to 4 per category section, deduped.
|
||||||
private var digest: (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
|
/// Sections that score poorly in the global top-100 are supplemented from
|
||||||
|
/// per-section targeted fetches held in store.sectionSupplement.
|
||||||
|
private func makeDigest(sections: [NewsSection]) -> (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
|
||||||
let unread = filteredStories.filter { !readStoryIds.contains($0.id) }
|
let unread = filteredStories.filter { !readStoryIds.contains($0.id) }
|
||||||
let top = Array(unread.prefix(5))
|
// Fossils (old by firstSeenAt) sink below current stories so the lead
|
||||||
var pool = Array(unread.dropFirst(5))
|
// slot is never a high-scoring but stale story — see StorySummary.isFossil.
|
||||||
|
let current = unread.filter { !$0.isFossil }
|
||||||
|
let fossils = unread.filter { $0.isFossil }
|
||||||
|
let reordered = current + fossils
|
||||||
|
let top = Array(reordered.prefix(5))
|
||||||
|
var pool = Array(reordered.dropFirst(5))
|
||||||
|
var usedIds = Set(top.map(\.id))
|
||||||
var out: [(NewsSection, [StorySummary])] = []
|
var out: [(NewsSection, [StorySummary])] = []
|
||||||
for section in NewsSection.sections {
|
for section in sections {
|
||||||
var picked: [StorySummary] = [], rest: [StorySummary] = []
|
var picked: [StorySummary] = [], rest: [StorySummary] = []
|
||||||
for s in pool {
|
for s in pool {
|
||||||
if picked.count < 3 && s.inSection(section) { picked.append(s) }
|
if picked.count < 4 && s.inSection(section) { picked.append(s) }
|
||||||
else { rest.append(s) }
|
else { rest.append(s) }
|
||||||
}
|
}
|
||||||
pool = rest
|
pool = rest
|
||||||
|
// Supplement from section-specific fetch when main pool is sparse
|
||||||
|
if picked.count < 2, let extra = store.sectionSupplement[section.id] {
|
||||||
|
for s in extra where !usedIds.contains(s.id) && s.inSection(section) {
|
||||||
|
picked.append(s)
|
||||||
|
usedIds.insert(s.id)
|
||||||
|
if picked.count == 4 { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usedIds.formUnion(picked.map(\.id))
|
||||||
if !picked.isEmpty { out.append((section, picked)) }
|
if !picked.isEmpty { out.append((section, picked)) }
|
||||||
}
|
}
|
||||||
return (top, out)
|
return (top, out)
|
||||||
@@ -96,13 +125,14 @@ struct SignalFeedView: View {
|
|||||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
modelContext.delete(row)
|
modelContext.delete(row)
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
Task { try? await APIClient.shared.markStoryUnread(id: id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack(alignment: .bottom) {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
header
|
||||||
@@ -111,6 +141,25 @@ struct SignalFeedView: View {
|
|||||||
Divider().overlay(Palette.hairline)
|
Divider().overlay(Palette.hairline)
|
||||||
feedList
|
feedList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let toast = markAllToast {
|
||||||
|
Text(toast)
|
||||||
|
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundStyle(Color(hex: "E0E0E0"))
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 11)
|
||||||
|
.background(Color(hex: "1E1E1E"))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(Capsule().stroke(Color(hex: "333333"), lineWidth: 0.5))
|
||||||
|
.shadow(color: .black.opacity(0.4), radius: 12, y: 4)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.onAppear {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) { markAllToast = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
.navigationDestination(for: StorySummary.self) { story in
|
.navigationDestination(for: StorySummary.self) { story in
|
||||||
@@ -134,39 +183,49 @@ struct SignalFeedView: View {
|
|||||||
}
|
}
|
||||||
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
|
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
|
||||||
.task(id: selectedPill) {
|
.task(id: selectedPill) {
|
||||||
await store.setPill(selectedPill)
|
await store.setPill(selectedPill) // paginates to 100 stories internally
|
||||||
var pages = 0
|
|
||||||
if selectedPill == .all {
|
|
||||||
// Front page: pull enough stories to fill the sections.
|
|
||||||
while store.stories.count < 100 && store.hasMore && pages < 5 {
|
|
||||||
await store.loadMore(); pages += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center, spacing: 0) {
|
||||||
JarvisWordmark(size: 30)
|
JarvisWordmark(size: 26)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
// Connection + controls as one coherent surface
|
||||||
|
HStack(spacing: 2) {
|
||||||
ConnectionBanner(state: ws.connectionState)
|
ConnectionBanner(state: ws.connectionState)
|
||||||
Button {
|
Button {
|
||||||
showFeeds = true
|
showFeeds = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "dot.radiowaves.up.forward")
|
Image(systemName: "dot.radiowaves.up.forward")
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundStyle(Color(hex: "888888"))
|
.foregroundStyle(Palette.secondaryText)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 40, height: 40)
|
||||||
}
|
}
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
Task { await markAllRead() }
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
markAllPending ? "Marking…" : "Mark all as read",
|
||||||
|
systemImage: markAllPending ? "hourglass" : "checkmark.circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(markAllPending || mainStories.isEmpty)
|
||||||
|
Divider()
|
||||||
Button {
|
Button {
|
||||||
showSettings = true
|
showSettings = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gearshape")
|
Label("Settings", systemImage: "gearshape")
|
||||||
.font(.system(size: 17, weight: .semibold))
|
}
|
||||||
.foregroundStyle(Color(hex: "888888"))
|
} label: {
|
||||||
.frame(width: 44, height: 44)
|
Image(systemName: "ellipsis")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(Palette.secondaryText)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -175,18 +234,33 @@ struct SignalFeedView: View {
|
|||||||
|
|
||||||
// MARK: - Sync bar
|
// MARK: - Sync bar
|
||||||
|
|
||||||
|
/// True while the first sync of this session hasn't completed yet, OR while
|
||||||
|
/// a fetch is actively in flight. Used to gate the "caught up" state so we
|
||||||
|
/// never declare victory before we've actually checked the server.
|
||||||
|
private var isSyncing: Bool { store.isLoading || store.lastSyncedAt == nil }
|
||||||
|
|
||||||
private var syncBar: some View {
|
private var syncBar: some View {
|
||||||
Text(syncText)
|
HStack(spacing: 5) {
|
||||||
|
if isSyncing {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.controlSize(.mini)
|
||||||
|
.tint(Palette.orange)
|
||||||
|
}
|
||||||
|
Text(isSyncing ? "Syncing signals…" : syncText)
|
||||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||||
.foregroundStyle(Color(hex: "5A5A5A"))
|
.foregroundStyle(Color(hex: "5A5A5A"))
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: store.isLoading)
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var syncText: String {
|
private var syncText: String {
|
||||||
let ago = syncedMinutesAgo(store.lastSyncedAt)
|
guard let last = store.lastSyncedAt else { return "Loading from cache…" }
|
||||||
let phrase = (ago == "just now" || ago == "never") ? ago : "\(ago) ago"
|
let ago = last.timeAgoShort()
|
||||||
|
let phrase = ago == "just now" ? ago : "\(ago) ago"
|
||||||
if ws.connectionState.isLive {
|
if ws.connectionState.isLive {
|
||||||
return "Synced \(phrase) · \(cachedStoryIds.count) cached"
|
return "Synced \(phrase) · \(cachedStoryIds.count) cached"
|
||||||
} else {
|
} else {
|
||||||
@@ -213,10 +287,10 @@ struct SignalFeedView: View {
|
|||||||
private func pill(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
private func pill(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 13, weight: .bold))
|
.font(.system(size: 12, weight: selected ? .semibold : .medium))
|
||||||
.foregroundStyle(selected ? .black : Color(hex: "AAAAAA"))
|
.foregroundStyle(selected ? .black : Palette.secondaryText)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 13)
|
||||||
.frame(height: 32)
|
.frame(height: 28)
|
||||||
.background(selected ? Palette.orange : Palette.surface)
|
.background(selected ? Palette.orange : Palette.surface)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
@@ -233,7 +307,7 @@ struct SignalFeedView: View {
|
|||||||
.kerning(0.8)
|
.kerning(0.8)
|
||||||
}
|
}
|
||||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
@@ -246,48 +320,92 @@ struct SignalFeedView: View {
|
|||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.black)
|
.background(Palette.background)
|
||||||
.animation(.snappy, value: showRead)
|
.animation(.snappy, value: showRead)
|
||||||
.animation(.snappy, value: readStories.count)
|
.animation(.snappy, value: readStories.count)
|
||||||
.onAppear { captureSeenSnapshot() }
|
.onAppear {
|
||||||
|
captureSeenSnapshot()
|
||||||
|
// If the cache already shows all-read on first render, open the
|
||||||
|
// shelf immediately so the user sees their previous stories.
|
||||||
|
if !readItems.isEmpty && mainStories.isEmpty { showRead = true }
|
||||||
|
}
|
||||||
|
// Expand shelf when loading finishes and there are still no unread stories.
|
||||||
|
.onChange(of: store.isLoading) { _, loading in
|
||||||
|
if !loading, !readItems.isEmpty, mainStories.isEmpty {
|
||||||
|
withAnimation(.snappy) { showRead = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Expand shelf if SwiftData @Query hydrates after the first render and
|
||||||
|
// all cached stories turn out to already be read.
|
||||||
|
.onChange(of: readItems.count) { _, count in
|
||||||
|
if count > 0, mainStories.isEmpty, !showRead {
|
||||||
|
showRead = true
|
||||||
|
}
|
||||||
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
// Reshuffle: clear the seen snapshot so all stories re-rank by signal
|
|
||||||
// score, and any stories cached by background refresh float to the top.
|
|
||||||
seenSnapshot = []
|
seenSnapshot = []
|
||||||
seenTracker.ids = []
|
seenTracker.ids = []
|
||||||
await store.loadStories(refresh: true)
|
await store.loadFeedFull()
|
||||||
|
// Merge any stories cached by background refresh that aren't in
|
||||||
|
// the live feed yet — mergeStories deduplicates, so always safe.
|
||||||
|
let liveIds = Set(store.stories.map(\.id))
|
||||||
|
let extra = cachedStories
|
||||||
|
.filter { !liveIds.contains($0.id) }
|
||||||
|
.map(StorySummary.init(cached:))
|
||||||
|
if !extra.isEmpty {
|
||||||
|
await store.mergeStories(extra)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card layout for every pill. "All" gets full multi-section front page;
|
/// Card layout for every pill.
|
||||||
/// topic pills get a hero card + flat list within that topic.
|
/// "All" → multi-section digest. Tech (sub-sections) → sub-section digest.
|
||||||
|
/// Everything else → hero card + flat scroll of all stories for that pill.
|
||||||
@ViewBuilder private var digestContent: some View {
|
@ViewBuilder private var digestContent: some View {
|
||||||
let d = digest
|
|
||||||
let headerLabel = selectedPill == .all
|
|
||||||
? "TOP STORIES"
|
|
||||||
: selectedPill.label.uppercased()
|
|
||||||
sectionHeader(headerLabel, pill: nil)
|
|
||||||
if let lead = d.top.first {
|
|
||||||
mainRow(lead, hero: true)
|
|
||||||
ForEach(d.top.dropFirst()) { mainRow($0) }
|
|
||||||
} else {
|
|
||||||
emptyState.plainBlackRow()
|
|
||||||
}
|
|
||||||
if selectedPill == .all {
|
if selectedPill == .all {
|
||||||
|
let d = makeDigest(sections: NewsSection.sections)
|
||||||
|
sectionHeader("TOP STORIES", pill: nil)
|
||||||
|
pillContent(top: d.top)
|
||||||
ForEach(d.sections, id: \.0.id) { section, stories in
|
ForEach(d.sections, id: \.0.id) { section, stories in
|
||||||
sectionHeader(section.label.uppercased(), pill: section.pill)
|
sectionHeader(section.label.uppercased(), pill: section.pill)
|
||||||
ForEach(stories) { mainRow($0) }
|
ForEach(stories) { mainRow($0) }
|
||||||
}
|
}
|
||||||
|
} else if let subSections = selectedPill.subSections {
|
||||||
|
let d = makeDigest(sections: subSections)
|
||||||
|
sectionHeader(selectedPill.label.uppercased(), pill: nil)
|
||||||
|
pillContent(top: d.top)
|
||||||
|
ForEach(d.sections, id: \.0.id) { section, stories in
|
||||||
|
sectionHeader(section.label.uppercased(), pill: section.pill)
|
||||||
|
ForEach(stories) { mainRow($0) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Flat list: hero + all stories for this pill
|
||||||
|
sectionHeader(selectedPill.label.uppercased(), pill: nil)
|
||||||
|
pillContent(top: mainStories, flat: true)
|
||||||
}
|
}
|
||||||
readShelf
|
readShelf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared hero + body rows. `flat` = render all items; otherwise only top 5.
|
||||||
|
@ViewBuilder private func pillContent(top stories: [StorySummary], flat: Bool = false) -> some View {
|
||||||
|
if let lead = stories.first {
|
||||||
|
mainRow(lead, hero: true)
|
||||||
|
let rest = flat ? Array(stories.dropFirst()) : Array(stories.dropFirst().prefix(4))
|
||||||
|
ForEach(rest) { mainRow($0) }
|
||||||
|
} else if !filteredStories.isEmpty {
|
||||||
|
if isSyncing { checkingRow.plainBlackRow() }
|
||||||
|
else { caughtUpRow.plainBlackRow() }
|
||||||
|
} else {
|
||||||
|
emptyState.plainBlackRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func sectionHeader(_ label: String, pill: StoryPill?) -> some View {
|
private func sectionHeader(_ label: String, pill: StoryPill?) -> some View {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 13, weight: .heavy, design: .monospaced))
|
.font(.system(size: 13, weight: .heavy, design: .monospaced))
|
||||||
.kerning(0.6)
|
.kerning(0.6)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
Rectangle().fill(Palette.orange).frame(width: 18, height: 2)
|
Rectangle().fill(Palette.orange).frame(width: 18, height: 2)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let pill {
|
if let pill {
|
||||||
@@ -316,7 +434,7 @@ struct SignalFeedView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets())
|
.listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets())
|
||||||
.listRowBackground(Color.black)
|
.listRowBackground(Palette.background)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
markSeen(story)
|
markSeen(story)
|
||||||
@@ -348,33 +466,33 @@ struct SignalFeedView: View {
|
|||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
SignalStripe(score: story.signalScore, width: 4)
|
SignalStripe(score: story.signalScore, width: 4)
|
||||||
VStack(alignment: .leading, spacing: 11) {
|
VStack(alignment: .leading, spacing: 11) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .center) {
|
||||||
Text(Topic.label(story.topic).uppercased())
|
Text(Topic.label(story.topic).uppercased())
|
||||||
.font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8)
|
.font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8)
|
||||||
.foregroundStyle(Color(hex: "8A8A8A"))
|
.foregroundStyle(Palette.secondaryText)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(story.signalScore)")
|
Text("\(story.signalScore)")
|
||||||
.font(.system(size: 21, weight: .heavy, design: .monospaced))
|
.font(.system(size: 15, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(Signal.scoreColor(story.signalScore))
|
.foregroundStyle(Signal.scoreColor(story.signalScore))
|
||||||
}
|
}
|
||||||
Text(story.headline)
|
Text(story.headline)
|
||||||
.font(.system(size: 24, weight: .heavy))
|
.font(.system(size: 25, weight: .bold, design: .default))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
.lineLimit(4)
|
.lineLimit(4)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
if !story.summary.isEmpty {
|
if !story.summary.isEmpty {
|
||||||
Text(story.summary)
|
Text(story.summary)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 16, weight: .regular, design: .default))
|
||||||
.foregroundStyle(Color(hex: "9A9A9A"))
|
.foregroundStyle(Palette.bodyText)
|
||||||
.lineLimit(3).lineSpacing(2)
|
.lineLimit(3).lineSpacing(2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
if !story.sources.isEmpty {
|
if !story.sources.isEmpty {
|
||||||
SourceChips(names: story.sources.map(\.name), total: story.sourceCount)
|
SourceChips(names: story.sources.map(\.name), total: story.sourceCount)
|
||||||
}
|
}
|
||||||
Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago")
|
Text("\(story.sourceCount) sources · \((story.firstSeenAt ?? story.updatedAt).timeAgoShort()) ago")
|
||||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||||
.foregroundStyle(Color(hex: "666666"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -384,11 +502,27 @@ struct SignalFeedView: View {
|
|||||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5))
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shown when all cached stories are read AND the first sync hasn't finished yet.
|
||||||
|
/// Keeps the UI honest — we haven't confirmed "no new content" until the server responds.
|
||||||
|
private var checkingRow: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.controlSize(.mini)
|
||||||
|
.tint(Palette.orange)
|
||||||
|
Text("Checking for new signals…")
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 50)
|
||||||
|
}
|
||||||
|
|
||||||
private var caughtUpRow: some View {
|
private var caughtUpRow: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "checkmark.circle")
|
Image(systemName: "checkmark.circle")
|
||||||
.font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E"))
|
.font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E"))
|
||||||
Text("You're all caught up").font(.system(size: 16, weight: .heavy)).foregroundStyle(.white)
|
Text("You're all caught up").font(.system(size: 16, weight: .semibold, design: .default)).foregroundStyle(Palette.primaryText)
|
||||||
Text("Pull to refresh for more")
|
Text("Pull to refresh for more")
|
||||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||||
}
|
}
|
||||||
@@ -413,7 +547,7 @@ struct SignalFeedView: View {
|
|||||||
Image(systemName: showRead ? "chevron.up" : "chevron.down")
|
Image(systemName: showRead ? "chevron.up" : "chevron.down")
|
||||||
.font(.system(size: 11, weight: .bold))
|
.font(.system(size: 11, weight: .bold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(Color(hex: "666666"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
.padding(.horizontal, 16).padding(.vertical, 16)
|
.padding(.horizontal, 16).padding(.vertical, 16)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
@@ -445,8 +579,8 @@ struct SignalFeedView: View {
|
|||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
if store.isLoading {
|
if isSyncing {
|
||||||
// Actively polling the server.
|
// First sync hasn't completed yet, or a fetch is actively in flight.
|
||||||
ProgressView().tint(Palette.orange)
|
ProgressView().tint(Palette.orange)
|
||||||
Text("Polling signals…")
|
Text("Polling signals…")
|
||||||
.font(.system(size: 14, weight: .heavy))
|
.font(.system(size: 14, weight: .heavy))
|
||||||
@@ -456,7 +590,7 @@ struct SignalFeedView: View {
|
|||||||
// Reached the empty state because the fetch failed.
|
// Reached the empty state because the fetch failed.
|
||||||
emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555"))
|
emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555"))
|
||||||
Text("Couldn't reach the server")
|
Text("Couldn't reach the server")
|
||||||
.font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
|
.font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||||
Text(error.errorDescription ?? "Unknown error")
|
Text(error.errorDescription ?? "Unknown error")
|
||||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -464,14 +598,14 @@ struct SignalFeedView: View {
|
|||||||
refreshButton("Retry")
|
refreshButton("Retry")
|
||||||
} else if !ws.connectionState.isLive {
|
} else if !ws.connectionState.isLive {
|
||||||
emptyIcon("wifi.slash")
|
emptyIcon("wifi.slash")
|
||||||
Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
|
Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||||
Text("No cached stories to show")
|
Text("No cached stories to show")
|
||||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||||
refreshButton("Retry")
|
refreshButton("Retry")
|
||||||
} else if selectedPill != .all {
|
} else if selectedPill != .all {
|
||||||
emptyIcon("line.3.horizontal.decrease.circle")
|
emptyIcon("line.3.horizontal.decrease.circle")
|
||||||
Text("No \(selectedPill.label) stories yet")
|
Text("No \(selectedPill.label) stories yet")
|
||||||
.font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
|
.font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||||
if store.isLoadingMore || store.hasMore {
|
if store.isLoadingMore || store.hasMore {
|
||||||
Text("Pulling more to find them…")
|
Text("Pulling more to find them…")
|
||||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||||
@@ -484,7 +618,7 @@ struct SignalFeedView: View {
|
|||||||
} else {
|
} else {
|
||||||
// Connected, loaded, genuinely nothing yet.
|
// Connected, loaded, genuinely nothing yet.
|
||||||
emptyIcon("antenna.radiowaves.left.and.right")
|
emptyIcon("antenna.radiowaves.left.and.right")
|
||||||
Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
|
Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||||
Text("Pull to refresh")
|
Text("Pull to refresh")
|
||||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||||
refreshButton("Refresh")
|
refreshButton("Refresh")
|
||||||
@@ -495,7 +629,7 @@ struct SignalFeedView: View {
|
|||||||
.padding(.top, 80)
|
.padding(.top, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emptyIcon(_ name: String, color: Color = Color(hex: "333333")) -> some View {
|
private func emptyIcon(_ name: String, color: Color = Palette.mutedText) -> some View {
|
||||||
Image(systemName: name).font(.system(size: 30)).foregroundStyle(color)
|
Image(systemName: name).font(.system(size: 30)).foregroundStyle(color)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,13 +637,13 @@ struct SignalFeedView: View {
|
|||||||
if let host = settings.host {
|
if let host = settings.host {
|
||||||
Text(host)
|
Text(host)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshButton(_ title: String) -> some View {
|
private func refreshButton(_ title: String) -> some View {
|
||||||
Button {
|
Button {
|
||||||
Task { await store.loadStories(refresh: true) }
|
Task { await store.loadFeedFull() }
|
||||||
} label: {
|
} label: {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 14, weight: .bold)).foregroundStyle(.black)
|
.font(.system(size: 14, weight: .bold)).foregroundStyle(.black)
|
||||||
@@ -543,10 +677,49 @@ struct SignalFeedView: View {
|
|||||||
if existing?.first == nil {
|
if existing?.first == nil {
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
Task { try? await APIClient.shared.markStoryRead(id: id) }
|
||||||
withAnimation(.snappy) { showRead = true }
|
withAnimation(.snappy) { showRead = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func markAllRead() async {
|
||||||
|
let ids = mainStories.map(\.id).filter { !readStoryIds.contains($0) }
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
withAnimation { markAllToast = "No unread \(selectedPill.label) stories right now." }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markAllPending = true
|
||||||
|
|
||||||
|
// Optimistic: insert ReadStory for every ID being marked.
|
||||||
|
for id in ids {
|
||||||
|
let exists = (try? modelContext.fetch(
|
||||||
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first
|
||||||
|
if exists == nil { modelContext.insert(ReadStory(id: id)) }
|
||||||
|
}
|
||||||
|
try? modelContext.save()
|
||||||
|
withAnimation(.snappy) {
|
||||||
|
showRead = true
|
||||||
|
markAllToast = "Marked \(ids.count) \(ids.count == 1 ? "story" : "stories") as read."
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.markAllRead(storyIds: ids, pill: selectedPill.label)
|
||||||
|
} catch {
|
||||||
|
// Rollback optimistic state.
|
||||||
|
for id in ids {
|
||||||
|
if let row = (try? modelContext.fetch(
|
||||||
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
|
modelContext.delete(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? modelContext.save()
|
||||||
|
withAnimation {
|
||||||
|
markAllToast = "Sync failed. Stories may reappear until retry."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markAllPending = false
|
||||||
|
}
|
||||||
|
|
||||||
private func shareItems(_ s: StorySummary) -> [Any] {
|
private func shareItems(_ s: StorySummary) -> [Any] {
|
||||||
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
|
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
|
||||||
if let first = s.sources.first, let url = URL(string: first.url) {
|
if let first = s.sources.first, let url = URL(string: first.url) {
|
||||||
@@ -559,8 +732,10 @@ struct SignalFeedView: View {
|
|||||||
|
|
||||||
private func cacheStories(_ stories: [StorySummary]) {
|
private func cacheStories(_ stories: [StorySummary]) {
|
||||||
guard !stories.isEmpty else { return }
|
guard !stories.isEmpty else { return }
|
||||||
// One fetch + an in-memory index, instead of a query per story.
|
let container = modelContext.container
|
||||||
let existing = (try? modelContext.fetch(FetchDescriptor<CachedStory>())) ?? []
|
Task.detached {
|
||||||
|
let ctx = ModelContext(container)
|
||||||
|
let existing = (try? ctx.fetch(FetchDescriptor<CachedStory>())) ?? []
|
||||||
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||||
for summary in stories {
|
for summary in stories {
|
||||||
if let found = byId[summary.id] {
|
if let found = byId[summary.id] {
|
||||||
@@ -570,11 +745,12 @@ struct SignalFeedView: View {
|
|||||||
found.updatedAt = summary.updatedAt
|
found.updatedAt = summary.updatedAt
|
||||||
} else {
|
} else {
|
||||||
let c = CachedStory(from: summary)
|
let c = CachedStory(from: summary)
|
||||||
modelContext.insert(c)
|
ctx.insert(c)
|
||||||
byId[summary.id] = c
|
byId[summary.id] = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try? modelContext.save()
|
try? ctx.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,7 +777,8 @@ extension StorySummary {
|
|||||||
consensus: cached.consensus,
|
consensus: cached.consensus,
|
||||||
conflict: cached.conflict,
|
conflict: cached.conflict,
|
||||||
updatedAt: cached.updatedAt,
|
updatedAt: cached.updatedAt,
|
||||||
createdAt: cached.updatedAt
|
createdAt: cached.createdAt,
|
||||||
|
firstSeenAt: cached.firstSeenAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,7 +787,7 @@ private extension View {
|
|||||||
/// Full-bleed black List row with no separator — keeps the flat feed look.
|
/// Full-bleed black List row with no separator — keeps the flat feed look.
|
||||||
func plainBlackRow() -> some View {
|
func plainBlackRow() -> some View {
|
||||||
self.listRowInsets(EdgeInsets())
|
self.listRowInsets(EdgeInsets())
|
||||||
.listRowBackground(Color.black)
|
.listRowBackground(Palette.background)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,8 @@ struct StoryRowView: View {
|
|||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
SignalStripe(score: story.signalScore, muted: true)
|
SignalStripe(score: story.signalScore, muted: true)
|
||||||
Text(story.headline)
|
Text(story.headline)
|
||||||
.font(.system(size: 14, weight: .bold))
|
.font(.system(size: 15, weight: .semibold, design: .default))
|
||||||
.foregroundStyle(Color(hex: "6A6A6A"))
|
.foregroundStyle(Palette.secondaryText)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.leading, 14)
|
.padding(.leading, 14)
|
||||||
.padding(.vertical, 11)
|
.padding(.vertical, 11)
|
||||||
@@ -34,7 +34,7 @@ struct StoryRowView: View {
|
|||||||
.padding(.trailing, 16)
|
.padding(.trailing, 16)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color.black)
|
.background(Palette.background)
|
||||||
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
|
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
@@ -49,54 +49,48 @@ struct StoryRowView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 9) {
|
VStack(alignment: .leading, spacing: 9) {
|
||||||
// Title (+ cached dot)
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
Text(story.headline)
|
Text(story.headline)
|
||||||
.font(.system(size: 17, weight: .heavy))
|
.font(.system(size: 19, weight: .bold, design: .default))
|
||||||
.foregroundStyle(Signal.titleColor(story.signalScore))
|
.foregroundStyle(Signal.titleColor(story.signalScore))
|
||||||
.lineLimit(3)
|
.lineLimit(3)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
if isCached {
|
|
||||||
CachedDot()
|
|
||||||
.padding(.top, 6)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary preview — the gist, without opening the story.
|
|
||||||
if !story.summary.isEmpty {
|
if !story.summary.isEmpty {
|
||||||
Text(story.summary)
|
Text(story.summary)
|
||||||
.font(.system(size: 13, weight: .regular))
|
.font(.system(size: 15, weight: .regular, design: .default))
|
||||||
.foregroundStyle(Color(hex: "8A8A8A"))
|
.foregroundStyle(Palette.bodyText)
|
||||||
.lineLimit(6)
|
.lineLimit(5)
|
||||||
.lineSpacing(2)
|
.lineSpacing(3)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source chips
|
|
||||||
if !sourceNames.isEmpty {
|
if !sourceNames.isEmpty {
|
||||||
SourceChips(names: sourceNames, total: story.sourceCount)
|
SourceChips(names: sourceNames, total: story.sourceCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata
|
|
||||||
Text(metaLine)
|
Text(metaLine)
|
||||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||||
.foregroundStyle(Color(hex: "666666"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
}
|
}
|
||||||
.padding(.leading, 14)
|
.padding(.leading, 14)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
|
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
|
|
||||||
// Signal score column
|
// Signal cluster: score + offline dot, compact and aligned
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
Text("\(story.signalScore)")
|
Text("\(story.signalScore)")
|
||||||
.font(.system(size: 22, weight: .heavy, design: .monospaced))
|
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||||
.foregroundStyle(Signal.scoreColor(story.signalScore))
|
.foregroundStyle(Signal.scoreColor(story.signalScore))
|
||||||
|
if isCached {
|
||||||
|
CachedDot(size: 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(.trailing, 16)
|
.padding(.trailing, 16)
|
||||||
.padding(.top, 16)
|
.padding(.top, 18)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color.black)
|
.background(Palette.background)
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
Rectangle().fill(Palette.hairline).frame(height: 0.5)
|
Rectangle().fill(Palette.hairline).frame(height: 0.5)
|
||||||
}
|
}
|
||||||
@@ -105,7 +99,11 @@ struct StoryRowView: View {
|
|||||||
|
|
||||||
private var metaLine: String {
|
private var metaLine: String {
|
||||||
let sources = "\(story.sourceCount) source\(story.sourceCount == 1 ? "" : "s")"
|
let sources = "\(story.sourceCount) source\(story.sourceCount == 1 ? "" : "s")"
|
||||||
return "\(sources) · \(Topic.label(story.topic)) · \(story.updatedAt.timeAgoShort()) ago"
|
// firstSeenAt is when the cluster appeared — updatedAt bumps on every
|
||||||
|
// article the backend attaches, so it can read "just now" on stories
|
||||||
|
// that are actually weeks old.
|
||||||
|
let age = (story.firstSeenAt ?? story.updatedAt).timeAgoShort()
|
||||||
|
return "\(sources) · \(Topic.label(story.topic)) · \(age) ago"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,10 +121,11 @@ struct StoryRowView: View {
|
|||||||
id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points",
|
id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points",
|
||||||
summary: "", topic: "finance", tags: ["finance"], signalScore: score, scoreBreakdown: breakdown,
|
summary: "", topic: "finance", tags: ["finance"], signalScore: score, scoreBreakdown: breakdown,
|
||||||
sourceCount: 7, sources: sources, consensus: nil, conflict: nil,
|
sourceCount: 7, sources: sources, consensus: nil, conflict: nil,
|
||||||
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()),
|
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date(),
|
||||||
|
firstSeenAt: Date().addingTimeInterval(-3600)),
|
||||||
isCached: score == 64
|
isCached: score == 64
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.black)
|
.background(Palette.background)
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Spacer().frame(height: 48)
|
Spacer().frame(height: 48)
|
||||||
@@ -175,7 +175,7 @@ struct ArticleReaderView: View {
|
|||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Text(article.source)
|
Text(article.source)
|
||||||
.font(.system(size: 11, weight: .bold))
|
.font(.system(size: 11, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.horizontal, 10).padding(.vertical, 5)
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||||
.background(Palette.orange)
|
.background(Palette.orange)
|
||||||
@@ -431,8 +431,10 @@ struct ArticleReaderView: View {
|
|||||||
if let row = (try? modelContext.fetch(
|
if let row = (try? modelContext.fetch(
|
||||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
modelContext.delete(row)
|
modelContext.delete(row)
|
||||||
|
Task { try? await APIClient.shared.markStoryUnread(id: id) }
|
||||||
} else {
|
} else {
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
|
Task { try? await APIClient.shared.markStoryRead(id: id) }
|
||||||
}
|
}
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ private struct TabHeader: View {
|
|||||||
JarvisWordmark(size: 26)
|
JarvisWordmark(size: 26)
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 13, weight: .bold))
|
.font(.system(size: 13, weight: .bold))
|
||||||
.foregroundStyle(Color(hex: "666666"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ struct LatestView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
TabHeader(title: "Latest")
|
TabHeader(title: "Latest")
|
||||||
Divider().overlay(Palette.hairline)
|
Divider().overlay(Palette.hairline)
|
||||||
@@ -98,20 +98,20 @@ struct SavedView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
TabHeader(title: "Saved")
|
TabHeader(title: "Saved")
|
||||||
Divider().overlay(Palette.hairline)
|
Divider().overlay(Palette.hairline)
|
||||||
if stories.isEmpty {
|
if stories.isEmpty {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "bookmark")
|
Image(systemName: "bookmark")
|
||||||
.font(.system(size: 28)).foregroundStyle(Color(hex: "333333"))
|
.font(.system(size: 28)).foregroundStyle(Palette.mutedText)
|
||||||
Text("Nothing saved yet")
|
Text("Nothing saved yet")
|
||||||
.font(.system(size: 14, weight: .bold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
Text("Swipe a story left to save it")
|
Text("Swipe a story left to save it")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(Color(hex: "444444"))
|
.foregroundStyle(Palette.mutedText)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
@@ -145,7 +145,7 @@ struct SearchView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
TabHeader(title: "Search")
|
TabHeader(title: "Search")
|
||||||
searchBar
|
searchBar
|
||||||
@@ -168,11 +168,11 @@ struct SearchView: View {
|
|||||||
private var searchBar: some View {
|
private var searchBar: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.font(.system(size: 14)).foregroundStyle(Color(hex: "555555"))
|
.font(.system(size: 14)).foregroundStyle(Palette.tertiaryText)
|
||||||
TextField("", text: $query,
|
TextField("", text: $query,
|
||||||
prompt: Text("Search").foregroundColor(Color(hex: "555555")))
|
prompt: Text("Search").foregroundColor(Palette.tertiaryText))
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
@@ -186,7 +186,7 @@ struct SearchView: View {
|
|||||||
private func placeholder(_ text: String) -> some View {
|
private func placeholder(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 14, weight: .bold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ struct NotificationsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
header
|
header
|
||||||
@@ -46,13 +46,13 @@ struct NotificationsView: View {
|
|||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(.white)
|
Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var permissionCard: some View {
|
private var permissionCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("Jarvis needs permission to send briefings and breaking alerts.")
|
Text("Jervis needs permission to send briefings and breaking alerts.")
|
||||||
.font(.system(size: 14)).foregroundStyle(Color(hex: "BBBBBB"))
|
.font(.system(size: 14)).foregroundStyle(Color(hex: "BBBBBB"))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Button {
|
Button {
|
||||||
@@ -78,8 +78,8 @@ struct NotificationsView: View {
|
|||||||
divider
|
divider
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: "dial.medium").font(.system(size: 18))
|
Image(systemName: "dial.medium").font(.system(size: 18))
|
||||||
.foregroundStyle(Color(hex: "999999")).frame(width: 26)
|
.foregroundStyle(Palette.secondaryText).frame(width: 26)
|
||||||
Text("Only above signal").font(.system(size: 15, weight: .bold)).foregroundStyle(.white)
|
Text("Only above signal").font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText)
|
||||||
Spacer()
|
Spacer()
|
||||||
Stepper(value: $settings.breakingMinSignal, in: 0...100, step: 5) {}
|
Stepper(value: $settings.breakingMinSignal, in: 0...100, step: 5) {}
|
||||||
.labelsHidden().fixedSize()
|
.labelsHidden().fixedSize()
|
||||||
@@ -103,8 +103,8 @@ struct NotificationsView: View {
|
|||||||
divider
|
divider
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: "clock").font(.system(size: 18))
|
Image(systemName: "clock").font(.system(size: 18))
|
||||||
.foregroundStyle(Color(hex: "999999")).frame(width: 26)
|
.foregroundStyle(Palette.secondaryText).frame(width: 26)
|
||||||
Text("Time").font(.system(size: 15, weight: .bold)).foregroundStyle(.white)
|
Text("Time").font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText)
|
||||||
Spacer()
|
Spacer()
|
||||||
DatePicker("", selection: timeBinding(hour: hour, minute: minute),
|
DatePicker("", selection: timeBinding(hour: hour, minute: minute),
|
||||||
displayedComponents: .hourAndMinute)
|
displayedComponents: .hourAndMinute)
|
||||||
@@ -142,13 +142,13 @@ struct NotificationsView: View {
|
|||||||
divider
|
divider
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 18))
|
Image(systemName: "mappin.and.ellipse").font(.system(size: 18))
|
||||||
.foregroundStyle(Color(hex: "999999")).frame(width: 26)
|
.foregroundStyle(Palette.secondaryText).frame(width: 26)
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text("Location").font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
Text("Location").font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||||
TextField("", text: $settings.locationName,
|
TextField("", text: $settings.locationName,
|
||||||
prompt: Text("Kampala").foregroundColor(Color(hex: "444444")))
|
prompt: Text("Kampala").foregroundColor(Palette.mutedText))
|
||||||
.font(.system(size: 16, weight: .regular))
|
.font(.system(size: 16, weight: .regular))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.onSubmit { resolveLocation() }
|
.onSubmit { resolveLocation() }
|
||||||
if let place = settings.resolvedPlace {
|
if let place = settings.resolvedPlace {
|
||||||
@@ -180,7 +180,7 @@ struct NotificationsView: View {
|
|||||||
private var testButton: some View {
|
private var testButton: some View {
|
||||||
Button { Task { await manager.sendTestBriefing() } } label: {
|
Button { Task { await manager.sendTestBriefing() } } label: {
|
||||||
Text("Send a test briefing")
|
Text("Send a test briefing")
|
||||||
.font(.system(size: 15, weight: .bold)).foregroundStyle(.white)
|
.font(.system(size: 15, weight: .bold)).foregroundStyle(Palette.primaryText)
|
||||||
.frame(maxWidth: .infinity).frame(height: 50)
|
.frame(maxWidth: .infinity).frame(height: 50)
|
||||||
.background(Palette.surface2).clipShape(RoundedRectangle(cornerRadius: 14))
|
.background(Palette.surface2).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ struct NotificationsView: View {
|
|||||||
private func section<C: View>(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
|
private func section<C: View>(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
|
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||||
.kerning(0.8).foregroundStyle(Color(hex: "555555"))
|
.kerning(0.8).foregroundStyle(Palette.tertiaryText)
|
||||||
VStack(spacing: 0) { content() }
|
VStack(spacing: 0) { content() }
|
||||||
.background(Palette.surface).clipShape(RoundedRectangle(cornerRadius: 14))
|
.background(Palette.surface).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
@@ -201,10 +201,10 @@ struct NotificationsView: View {
|
|||||||
|
|
||||||
private func toggleRow(icon: String, title: String, subtitle: String, isOn: Binding<Bool>) -> some View {
|
private func toggleRow(icon: String, title: String, subtitle: String, isOn: Binding<Bool>) -> some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Color(hex: "999999")).frame(width: 26)
|
Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Palette.secondaryText).frame(width: 26)
|
||||||
Toggle(isOn: isOn) {
|
Toggle(isOn: isOn) {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(.white)
|
Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||||
Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ struct SettingsView: View {
|
|||||||
@EnvironmentObject var settings: ServerSettings
|
@EnvironmentObject var settings: ServerSettings
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
|
||||||
@Query private var cachedStories: [CachedStory]
|
@Query private var cachedStories: [CachedStory]
|
||||||
@Query private var cachedArticles: [CachedArticle]
|
@Query private var cachedArticles: [CachedArticle]
|
||||||
@State private var showClearConfirm = false
|
@State private var showClearConfirm = false
|
||||||
@@ -15,11 +16,15 @@ struct SettingsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 26) {
|
VStack(alignment: .leading, spacing: 26) {
|
||||||
header
|
header
|
||||||
|
|
||||||
|
group("APPEARANCE") {
|
||||||
|
appearancePicker
|
||||||
|
}
|
||||||
|
|
||||||
group("GENERAL") {
|
group("GENERAL") {
|
||||||
NavigationLink { NotificationsView() } label: {
|
NavigationLink { NotificationsView() } label: {
|
||||||
row(icon: "bell.badge", title: "Notifications",
|
row(icon: "bell.badge", title: "Notifications",
|
||||||
@@ -60,13 +65,47 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var appearancePicker: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "circle.lefthalf.filled")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(Palette.secondaryText)
|
||||||
|
.frame(width: 26)
|
||||||
|
Text("Theme")
|
||||||
|
.font(.system(size: 16, weight: .heavy))
|
||||||
|
.foregroundStyle(Palette.primaryText)
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(AppearanceMode.allCases, id: \.self) { mode in
|
||||||
|
Button {
|
||||||
|
appearanceMode = mode
|
||||||
|
} label: {
|
||||||
|
Text(mode.label)
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundStyle(appearanceMode == mode ? .black : Palette.secondaryText)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 30)
|
||||||
|
.background(appearanceMode == mode ? Palette.orange : Color.clear)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Palette.surface2)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(Capsule().stroke(Palette.surface2, lineWidth: 0.5))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 14)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Settings").font(.system(size: 22, weight: .heavy)).foregroundStyle(.white)
|
Text("Settings").font(.system(size: 22, weight: .heavy)).foregroundStyle(Palette.primaryText)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark").font(.system(size: 15, weight: .bold))
|
Image(systemName: "xmark").font(.system(size: 15, weight: .bold))
|
||||||
.foregroundStyle(Color(hex: "888888")).frame(width: 44, height: 44)
|
.foregroundStyle(Palette.secondaryText).frame(width: 44, height: 44)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -75,7 +114,7 @@ struct SettingsView: View {
|
|||||||
private func group<C: View>(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
|
private func group<C: View>(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
|
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||||
.kerning(0.8).foregroundStyle(Color(hex: "555555"))
|
.kerning(0.8).foregroundStyle(Palette.tertiaryText)
|
||||||
VStack(spacing: 0) { content() }
|
VStack(spacing: 0) { content() }
|
||||||
.background(Palette.surface)
|
.background(Palette.surface)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
@@ -83,18 +122,19 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func row(icon: String, title: String, subtitle: String,
|
private func row(icon: String, title: String, subtitle: String,
|
||||||
chevron: Bool = true, tint: Color = .white) -> some View {
|
chevron: Bool = true, tint: Color? = nil) -> some View {
|
||||||
HStack(spacing: 14) {
|
let resolvedTint = tint ?? Palette.primaryText
|
||||||
|
return HStack(spacing: 14) {
|
||||||
Image(systemName: icon).font(.system(size: 18))
|
Image(systemName: icon).font(.system(size: 18))
|
||||||
.foregroundStyle(tint == .white ? Color(hex: "999999") : tint).frame(width: 26)
|
.foregroundStyle(tint == nil ? Palette.secondaryText : resolvedTint).frame(width: 26)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(tint).lineLimit(1)
|
Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(resolvedTint).lineLimit(1)
|
||||||
Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888")).lineLimit(1)
|
Text(subtitle).font(.system(size: 12)).foregroundStyle(Palette.secondaryText).lineLimit(1)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if chevron {
|
if chevron {
|
||||||
Image(systemName: "chevron.right").font(.system(size: 13, weight: .bold))
|
Image(systemName: "chevron.right").font(.system(size: 13, weight: .bold))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14).padding(.vertical, 14).frame(minHeight: 44)
|
.padding(.horizontal, 14).padding(.vertical, 14).frame(minHeight: 44)
|
||||||
@@ -39,5 +39,5 @@ struct CachedBadge: View {
|
|||||||
CachedBadge(text: "All 7 articles cached · available offline")
|
CachedBadge(text: "All 7 articles cached · available offline")
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.black)
|
.background(Palette.background)
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ extension ConnectionState {
|
|||||||
case .connected: return Color(hex: "33C25E")
|
case .connected: return Color(hex: "33C25E")
|
||||||
case .connecting,
|
case .connecting,
|
||||||
.reconnecting: return Palette.healthFailing
|
.reconnecting: return Palette.healthFailing
|
||||||
case .disconnected: return Color(hex: "555555")
|
case .disconnected: return Palette.tertiaryText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,5 +56,5 @@ struct ConnectionBanner: View {
|
|||||||
ConnectionBanner(state: .disconnected)
|
ConnectionBanner(state: .disconnected)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.black)
|
.background(Palette.background)
|
||||||
}
|
}
|
||||||
@@ -29,5 +29,5 @@ struct SignalStripe: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.black)
|
.background(Palette.background)
|
||||||
}
|
}
|
||||||
@@ -26,11 +26,11 @@ struct SourceChips: View {
|
|||||||
|
|
||||||
private func chip(_ text: String, muted: Bool = false) -> some View {
|
private func chip(_ text: String, muted: Bool = false) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 12, weight: .bold))
|
.font(.system(size: 11, weight: .semibold))
|
||||||
.foregroundStyle(muted ? Color(hex: "777777") : Color(hex: "CCCCCC"))
|
.foregroundStyle(muted ? Palette.chipMutedText : Palette.chipText)
|
||||||
.padding(.horizontal, 9)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 3)
|
||||||
.background(Palette.surface2)
|
.background(Palette.chipFill)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,5 +38,5 @@ struct SourceChips: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
SourceChips(names: ["Daily Monitor", "NilePost", "The EastAfrican"], total: 7)
|
SourceChips(names: ["Daily Monitor", "NilePost", "The EastAfrican"], total: 7)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.black)
|
.background(Palette.background)
|
||||||
}
|
}
|
||||||
441
Jervis/Views/Shared/Theme.swift
Normal file
441
Jervis/Views/Shared/Theme.swift
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
// Theme.swift
|
||||||
|
// Jarvis — shared design tokens, signal-score fade math, formatting, nav routes.
|
||||||
|
// Every hex value here is from the spec; nothing is invented.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// MARK: - Hex colors
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
init(hex: String) {
|
||||||
|
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||||
|
var v: UInt64 = 0
|
||||||
|
Scanner(string: s).scanHexInt64(&v)
|
||||||
|
let r, g, b: Double
|
||||||
|
switch s.count {
|
||||||
|
case 6:
|
||||||
|
r = Double((v >> 16) & 0xFF) / 255
|
||||||
|
g = Double((v >> 8) & 0xFF) / 255
|
||||||
|
b = Double(v & 0xFF) / 255
|
||||||
|
default:
|
||||||
|
r = 0; g = 0; b = 0
|
||||||
|
}
|
||||||
|
self = Color(.sRGB, red: r, green: g, blue: b, opacity: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
convenience init(hex: String) {
|
||||||
|
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||||
|
var v: UInt64 = 0
|
||||||
|
Scanner(string: s).scanHexInt64(&v)
|
||||||
|
let r, g, b: CGFloat
|
||||||
|
switch s.count {
|
||||||
|
case 6:
|
||||||
|
r = CGFloat((v >> 16) & 0xFF) / 255
|
||||||
|
g = CGFloat((v >> 8) & 0xFF) / 255
|
||||||
|
b = CGFloat(v & 0xFF) / 255
|
||||||
|
default:
|
||||||
|
r = 0; g = 0; b = 0
|
||||||
|
}
|
||||||
|
self.init(red: r, green: g, blue: b, alpha: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Palette
|
||||||
|
|
||||||
|
enum Palette {
|
||||||
|
static let orange = Color("KisaniOrange") // #FF5C00
|
||||||
|
|
||||||
|
static func adaptive(dark: String, light: String) -> Color {
|
||||||
|
Color(UIColor { traits in
|
||||||
|
traits.userInterfaceStyle == .dark ? UIColor(hex: dark) : UIColor(hex: light)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static let background = adaptive(dark: "0A0A0A", light: "F7F4EF")
|
||||||
|
static let black = background
|
||||||
|
static let surface = adaptive(dark: "111111", light: "EFE9DD")
|
||||||
|
static let surface2 = adaptive(dark: "1A1A1A", light: "E3DBCE")
|
||||||
|
static let hairline = adaptive(dark: "1A1A1A", light: "D6CEC0")
|
||||||
|
|
||||||
|
static let primaryText = adaptive(dark: "F5F2ED", light: "171411")
|
||||||
|
static let secondaryText = adaptive(dark: "9A9A9A", light: "4F4941")
|
||||||
|
static let tertiaryText = adaptive(dark: "666666", light: "6F675E")
|
||||||
|
static let mutedText = adaptive(dark: "4A4A4A", light: "948B80")
|
||||||
|
static let chipFill = adaptive(dark: "242424", light: "DDD3C1")
|
||||||
|
static let chipText = adaptive(dark: "E0E0E0", light: "28231E")
|
||||||
|
static let chipMutedText = adaptive(dark: "787878", light: "68625A")
|
||||||
|
|
||||||
|
// health
|
||||||
|
static let healthActive = adaptive(dark: "2A5A2A", light: "DCEBDD")
|
||||||
|
static let healthFailing = adaptive(dark: "AA6600", light: "F2D6A6")
|
||||||
|
static let healthDead = adaptive(dark: "5A1A1A", light: "E9C7C7")
|
||||||
|
|
||||||
|
// blocks
|
||||||
|
static let consensusBorder = Color(hex: "FF5C00")
|
||||||
|
static let consensusFill = adaptive(dark: "1F1206", light: "FFE2CA")
|
||||||
|
static let conflictBorder = adaptive(dark: "5A1A1A", light: "BF5B5B")
|
||||||
|
static let conflictFill = adaptive(dark: "1A0C0C", light: "F3D8D8")
|
||||||
|
|
||||||
|
static let cachedGreen = adaptive(dark: "2A5A2A", light: "2C6A38")
|
||||||
|
static let bodyText = adaptive(dark: "8A8A8A", light: "4A453F")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Signal-score fade
|
||||||
|
//
|
||||||
|
// Spec anchors:
|
||||||
|
// stripe : 97 → #FF5C00, fades to #1A1A1A at 0
|
||||||
|
// title : 97 → white, 20 → #444444, 0–19 near invisible
|
||||||
|
// score : 80–100 full orange · 50–79 dimmed orange · 20–49 dark brown · 0–19 near invisible
|
||||||
|
|
||||||
|
enum Signal {
|
||||||
|
private static func lerp(_ a: (Double, Double, Double),
|
||||||
|
_ b: (Double, Double, Double),
|
||||||
|
_ t: Double) -> Color {
|
||||||
|
let t = max(0, min(1, t))
|
||||||
|
return Color(.sRGB,
|
||||||
|
red: a.0 + (b.0 - a.0) * t,
|
||||||
|
green: a.1 + (b.1 - a.1) * t,
|
||||||
|
blue: a.2 + (b.2 - a.2) * t,
|
||||||
|
opacity: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lerpTuple(_ a: (Double, Double, Double),
|
||||||
|
_ b: (Double, Double, Double),
|
||||||
|
_ t: Double) -> (Double, Double, Double) {
|
||||||
|
let t = max(0, min(1, t))
|
||||||
|
return (
|
||||||
|
a.0 + (b.0 - a.0) * t,
|
||||||
|
a.1 + (b.1 - a.1) * t,
|
||||||
|
a.2 + (b.2 - a.2) * t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func adaptiveLerp(_ darkA: (Double, Double, Double),
|
||||||
|
_ darkB: (Double, Double, Double),
|
||||||
|
_ lightA: (Double, Double, Double),
|
||||||
|
_ lightB: (Double, Double, Double),
|
||||||
|
_ t: Double) -> Color {
|
||||||
|
Color(UIColor { traits in
|
||||||
|
let rgb = traits.userInterfaceStyle == .dark
|
||||||
|
? lerpTuple(darkA, darkB, t)
|
||||||
|
: lerpTuple(lightA, lightB, t)
|
||||||
|
return UIColor(red: rgb.0, green: rgb.1, blue: rgb.2, alpha: 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 3pt left stripe color: #1A1A1A (0) → #FF5C00 (97+).
|
||||||
|
static func stripeColor(_ score: Int) -> Color {
|
||||||
|
let t = Double(score) / 97.0
|
||||||
|
return lerp((0x1A/255, 0x1A/255, 0x1A/255), // #1A1A1A
|
||||||
|
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
|
||||||
|
t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Story title color: white (97+) → #444444 (20) → near invisible (0).
|
||||||
|
static func titleColor(_ score: Int) -> Color {
|
||||||
|
if score >= 97 { return Palette.primaryText }
|
||||||
|
if score >= 20 {
|
||||||
|
let t = Double(score - 20) / Double(97 - 20)
|
||||||
|
return adaptiveLerp(
|
||||||
|
(0x44/255, 0x44/255, 0x44/255), (0xF5/255, 0xF2/255, 0xED/255),
|
||||||
|
(0x8A/255, 0x81/255, 0x76/255), (0x17/255, 0x14/255, 0x11/255),
|
||||||
|
t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let t = Double(score) / 20.0
|
||||||
|
return adaptiveLerp(
|
||||||
|
(0x1C/255, 0x1C/255, 0x1C/255), (0x44/255, 0x44/255, 0x44/255),
|
||||||
|
(0xD6/255, 0xCE/255, 0xC0/255), (0x8A/255, 0x81/255, 0x76/255),
|
||||||
|
t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal score number color: stays in orange/brown hue, fades with score.
|
||||||
|
/// near-invisible brown (0) → full #FF5C00 (100).
|
||||||
|
static func scoreColor(_ score: Int) -> Color {
|
||||||
|
let t = Double(score) / 100.0
|
||||||
|
return lerp((0x2A/255, 0x18/255, 0x10/255), // near-invisible brown
|
||||||
|
(0xFF/255, 0x5C/255, 0x00/255), // #FF5C00
|
||||||
|
t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Topic display
|
||||||
|
|
||||||
|
enum Topic {
|
||||||
|
static func label(_ slug: String) -> String {
|
||||||
|
switch slug.lowercased() {
|
||||||
|
case "ai", "artificial-intelligence": return "AI"
|
||||||
|
case "us", "united-states": return "US"
|
||||||
|
case "formula-1": return "Formula 1"
|
||||||
|
case "ui-ux", "web-design-and-ui-ux": return "Web Design"
|
||||||
|
default:
|
||||||
|
// Slug → Title Case, e.g. "cloud-computing" → "Cloud Computing".
|
||||||
|
return slug.split(separator: "-")
|
||||||
|
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Topic pills shown on the home screen.
|
||||||
|
static let filters: [(label: String, slug: String?)] = [
|
||||||
|
("All", nil),
|
||||||
|
("Finance", "finance"),
|
||||||
|
("Tech", "tech"),
|
||||||
|
("Politics", "politics"),
|
||||||
|
("Africa", "africa"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Time formatting
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
/// Short relative string e.g. "just now", "3 min", "2 hr", "4 d".
|
||||||
|
func timeAgoShort(reference: Date = Date()) -> String {
|
||||||
|
let secs = max(0, Int(reference.timeIntervalSince(self)))
|
||||||
|
switch secs {
|
||||||
|
case 0..<60: return "just now"
|
||||||
|
case 60..<3600: return "\(secs / 60) min"
|
||||||
|
case 3600..<86400: return "\(secs / 3600) hr"
|
||||||
|
default: return "\(secs / 86400) d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "08:03" style monospaced clock for timeline / source chips.
|
||||||
|
var clockShort: String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f.string(from: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncedMinutesAgo(_ date: Date?, reference: Date = Date()) -> String {
|
||||||
|
guard let date else { return "never" }
|
||||||
|
return date.timeAgoShort(reference: reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation routes
|
||||||
|
|
||||||
|
/// Route into the article reader. Carries the parent story context so the
|
||||||
|
/// back button can show the truncated headline.
|
||||||
|
struct ArticleRoute: Hashable {
|
||||||
|
let articleId: String
|
||||||
|
let storyId: String
|
||||||
|
let parentHeadline: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Appearance mode
|
||||||
|
|
||||||
|
enum AppearanceMode: String, CaseIterable {
|
||||||
|
case system, light, dark
|
||||||
|
|
||||||
|
var colorScheme: ColorScheme? {
|
||||||
|
switch self {
|
||||||
|
case .system: return nil
|
||||||
|
case .light: return .light
|
||||||
|
case .dark: return .dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .system: return "System"
|
||||||
|
case .light: return "Light"
|
||||||
|
case .dark: return "Dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .system: return "circle.lefthalf.filled"
|
||||||
|
case .light: return "sun.max"
|
||||||
|
case .dark: return "moon.stars"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Freshness-weighted feed ordering
|
||||||
|
//
|
||||||
|
// The server already applies freshness when computing signalScore, but that
|
||||||
|
// snapshot ages once it's cached on the client. clientRank applies a lightweight
|
||||||
|
// time-decay so fresh stories naturally surface over stale high-scorers.
|
||||||
|
//
|
||||||
|
// Formula: rank = score × (0.80 + 0.20 × decay)
|
||||||
|
// decay = 1 / (1 + ageHours / 24) → 1.0 now · 0.50 @ 24 h · 0.33 @ 48 h
|
||||||
|
//
|
||||||
|
// Effect: a score-80 story from 1 hour ago (rank ≈ 79.8) beats a score-82 story
|
||||||
|
// from 48 hours ago (rank ≈ 71.4). Pure score still dominates for same-age stories.
|
||||||
|
//
|
||||||
|
// Age is measured from `firstSeenAt`, not `updatedAt` — the backend bumps
|
||||||
|
// `updatedAt` every time it attaches another article to a cluster, however
|
||||||
|
// loosely related (see BACKLOG #15), so a story can read as "just now" while
|
||||||
|
// being weeks old by `firstSeenAt`. Using `updatedAt` here previously
|
||||||
|
// neutralized the whole point of this decay: a fossil story's age always
|
||||||
|
// computed near zero.
|
||||||
|
|
||||||
|
extension StorySummary {
|
||||||
|
var clientRank: Double {
|
||||||
|
let ageHours = max(0.0, -(firstSeenAt ?? updatedAt).timeIntervalSinceNow) / 3600.0
|
||||||
|
let decay = 1.0 / (1.0 + ageHours / 24.0)
|
||||||
|
return Double(signalScore) * (0.80 + 0.20 * decay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Even with the fix above, this decay is soft (max 20% penalty) — a
|
||||||
|
/// high enough signalScore can still outrank fresher, lower-scored
|
||||||
|
/// stories. `isFossil` is a hard backstop views use to keep genuinely
|
||||||
|
/// old stories out of the lead/hero slot regardless of score.
|
||||||
|
static let fossilThresholdDays: Double = 5
|
||||||
|
|
||||||
|
var isFossil: Bool {
|
||||||
|
let ageSeconds = -(firstSeenAt ?? updatedAt).timeIntervalSinceNow
|
||||||
|
return ageSeconds > StorySummary.fossilThresholdDays * 86400
|
||||||
|
}
|
||||||
|
|
||||||
|
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
|
||||||
|
let diff = a.clientRank - b.clientRank
|
||||||
|
// Stable tiebreak for nearly-equal ranks: newer ID wins (IDs are
|
||||||
|
// lexicographically monotone from the backend).
|
||||||
|
if abs(diff) < 0.5 { return a.id > b.id }
|
||||||
|
return diff > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Story pills (backend-tag driven)
|
||||||
|
//
|
||||||
|
// The backend classifies each story into canonical category slugs in `tags[]`.
|
||||||
|
// The client filters by pill -> slug membership only; no headline keyword
|
||||||
|
// matching. `topic` is a fallback while the backend finishes emitting `tags[]`.
|
||||||
|
|
||||||
|
enum StoryPill: String, CaseIterable, Identifiable {
|
||||||
|
case all = "All"
|
||||||
|
case f1 = "F1"
|
||||||
|
case sport = "Sport"
|
||||||
|
case tech = "Tech" // AI, Cloud, HomeLab — sub-sections inside
|
||||||
|
case eastAfrica = "East Africa" // Uganda + broader East Africa, Uganda pinned first
|
||||||
|
case southernAfrica = "Southern Africa" // SA, Namibia, Botswana pinned first
|
||||||
|
case canada = "Canada"
|
||||||
|
case unitedStates = "US"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
var label: String { rawValue }
|
||||||
|
|
||||||
|
var slugs: Set<String> {
|
||||||
|
switch self {
|
||||||
|
case .all: return []
|
||||||
|
case .f1: return ["formula-1"]
|
||||||
|
case .sport: return ["sports", "esports"]
|
||||||
|
case .tech: return [
|
||||||
|
"technology", "programming-and-software-development",
|
||||||
|
"cybersecurity", "security", "privacy-and-data-protection",
|
||||||
|
"web-design-and-ui-ux", "wordpress-and-web-development",
|
||||||
|
"artificial-intelligence", "machine-learning", "robotics",
|
||||||
|
"cloud-computing", "homelab",
|
||||||
|
]
|
||||||
|
case .eastAfrica: return ["east-africa", "uganda"]
|
||||||
|
case .southernAfrica: return [
|
||||||
|
"south-africa", "namibia", "botswana",
|
||||||
|
"zimbabwe", "zambia", "mozambique", "southern-africa",
|
||||||
|
]
|
||||||
|
case .canada: return ["canada"]
|
||||||
|
case .unitedStates: return ["united-states"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sub-sections to show inside the card layout for this pill (nil = flat).
|
||||||
|
var subSections: [NewsSection]? {
|
||||||
|
switch self {
|
||||||
|
case .tech: return NewsSection.techSubSections
|
||||||
|
case .eastAfrica: return NewsSection.eastAfricaSubSections
|
||||||
|
case .southernAfrica: return NewsSection.southernAfricaSubSections
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StorySummary {
|
||||||
|
/// Backend `tags[]` are the source of truth; fall back to `topic` during the
|
||||||
|
/// transition while the backend finishes emitting tags.
|
||||||
|
var effectiveTags: Set<String> {
|
||||||
|
let clean = Set((tags ?? []).filter { !$0.isEmpty })
|
||||||
|
return clean.isEmpty ? [topic] : clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func matches(_ pill: StoryPill) -> Bool {
|
||||||
|
pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inSection(_ section: NewsSection) -> Bool {
|
||||||
|
!effectiveTags.isDisjoint(with: section.slugs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wordmark
|
||||||
|
|
||||||
|
struct JarvisWordmark: View {
|
||||||
|
var size: CGFloat = 30
|
||||||
|
private var font: Font { .custom("Courier New", size: size).bold() }
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("j").font(font).foregroundStyle(Palette.orange)
|
||||||
|
Text("ervis").font(font).foregroundStyle(Palette.primaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - News sections (the "All" front page)
|
||||||
|
//
|
||||||
|
// Groups the firehose into Apple/Google-News-style sections. A story lands in
|
||||||
|
// the first section (by this order) whose slugs it carries; `pill` powers "See all".
|
||||||
|
|
||||||
|
struct NewsSection: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let label: String
|
||||||
|
let slugs: Set<String>
|
||||||
|
let pill: StoryPill?
|
||||||
|
|
||||||
|
static let sections: [NewsSection] = [
|
||||||
|
.init(id: "us", label: "US", slugs: ["united-states"], pill: .unitedStates),
|
||||||
|
.init(id: "world", label: "World", slugs: ["world-news", "news", "news-and-current-affairs"], pill: nil),
|
||||||
|
.init(id: "politics", label: "Politics", slugs: ["politics", "human-rights", "investigative"], pill: nil),
|
||||||
|
.init(id: "business", label: "Business", slugs: ["business", "finance", "fintech", "cryptocurrency", "blockchain", "entrepreneur", "startups", "real-estate"], pill: nil),
|
||||||
|
.init(id: "tech", label: "Tech", slugs: ["technology", "programming-and-software-development", "cybersecurity", "security", "web-design-and-ui-ux", "wordpress-and-web-development", "privacy-and-data-protection", "cloud-computing", "artificial-intelligence", "machine-learning", "robotics", "homelab"], pill: .tech),
|
||||||
|
.init(id: "east-africa", label: "East Africa", slugs: ["east-africa", "uganda"], pill: .eastAfrica),
|
||||||
|
.init(id: "southern-africa",label: "Southern Africa", slugs: ["south-africa", "namibia", "botswana", "zimbabwe", "zambia", "mozambique", "southern-africa"], pill: .southernAfrica),
|
||||||
|
.init(id: "africa", label: "Africa", slugs: ["africa"], pill: nil),
|
||||||
|
.init(id: "sport", label: "Sport", slugs: ["sports", "esports", "formula-1"], pill: .sport),
|
||||||
|
.init(id: "science", label: "Science", slugs: ["science", "astronomy", "environment", "green-technology", "sustainability"], pill: nil),
|
||||||
|
.init(id: "entertainment", label: "Entertainment",slugs: ["entertainment", "music", "pop-culture", "video-game", "arts-and-culture", "books-and-literature"], pill: nil),
|
||||||
|
.init(id: "health", label: "Health", slugs: ["health", "health-and-wellness", "mental-health"], pill: nil),
|
||||||
|
.init(id: "lifestyle", label: "Lifestyle", slugs: ["lifestyle", "food-and-drink", "travel", "gardening", "outdoor-and-hiking", "parenting-and-family", "interior-design-and-home-decor", "photography", "self-improvement-and-personal-development", "education", "e-learning"], pill: nil),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Sub-sections shown inside the Tech pill view.
|
||||||
|
static let techSubSections: [NewsSection] = [
|
||||||
|
.init(id: "t-ai", label: "AI", slugs: ["artificial-intelligence", "machine-learning", "robotics"], pill: nil),
|
||||||
|
.init(id: "t-cloud", label: "Cloud", slugs: ["cloud-computing"], pill: nil),
|
||||||
|
.init(id: "t-homelab", label: "HomeLab", slugs: ["homelab"], pill: nil),
|
||||||
|
.init(id: "t-security", label: "Security", slugs: ["cybersecurity", "security", "privacy-and-data-protection"], pill: nil),
|
||||||
|
.init(id: "t-dev", label: "Dev", slugs: ["programming-and-software-development", "web-design-and-ui-ux", "wordpress-and-web-development"], pill: nil),
|
||||||
|
.init(id: "t-tech", label: "Technology", slugs: ["technology"], pill: nil),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Sub-sections shown inside the East Africa pill view — Uganda pinned first.
|
||||||
|
static let eastAfricaSubSections: [NewsSection] = [
|
||||||
|
.init(id: "ea-uganda", label: "Uganda", slugs: ["uganda"], pill: nil),
|
||||||
|
.init(id: "ea-kenya", label: "Kenya", slugs: ["east-africa"], pill: nil),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Sub-sections shown inside the Southern Africa pill — SA, Namibia, Botswana first.
|
||||||
|
static let southernAfricaSubSections: [NewsSection] = [
|
||||||
|
.init(id: "sa-za", label: "South Africa", slugs: ["south-africa"], pill: nil),
|
||||||
|
.init(id: "sa-na", label: "Namibia", slugs: ["namibia"], pill: nil),
|
||||||
|
.init(id: "sa-bw", label: "Botswana", slugs: ["botswana"], pill: nil),
|
||||||
|
.init(id: "sa-zw", label: "Zimbabwe", slugs: ["zimbabwe"], pill: nil),
|
||||||
|
.init(id: "sa-zm", label: "Zambia", slugs: ["zambia"], pill: nil),
|
||||||
|
.init(id: "sa-mz", label: "Mozambique", slugs: ["mozambique"], pill: nil),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -69,20 +69,21 @@ struct StoryDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Palette.background.ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
categoryRow
|
categoryRow
|
||||||
Text(headline)
|
Text(headline)
|
||||||
.font(.system(size: 28, weight: .heavy))
|
.font(.system(size: 31, weight: .bold, design: .default))
|
||||||
.foregroundStyle(.white)
|
.lineSpacing(2)
|
||||||
|
.foregroundStyle(Palette.primaryText)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
if !summary.isEmpty {
|
if !summary.isEmpty {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
.font(.system(size: 15, weight: .regular))
|
.font(.system(size: 18, weight: .regular, design: .default))
|
||||||
.foregroundStyle(Color(hex: "9A9A9A"))
|
.foregroundStyle(Palette.secondaryText)
|
||||||
.lineSpacing(4)
|
.lineSpacing(5)
|
||||||
}
|
}
|
||||||
|
|
||||||
SourceChips(names: sourceNames, total: sourceCount)
|
SourceChips(names: sourceNames, total: sourceCount)
|
||||||
@@ -123,7 +124,7 @@ struct StoryDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbarBackground(Color.black, for: .navigationBar)
|
.toolbarBackground(Palette.background, for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
|
||||||
.task {
|
.task {
|
||||||
@@ -141,6 +142,7 @@ struct StoryDetailView: View {
|
|||||||
else { return }
|
else { return }
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
Task { try? await APIClient.shared.markStoryRead(id: id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleRead() {
|
private func toggleRead() {
|
||||||
@@ -148,8 +150,10 @@ struct StoryDetailView: View {
|
|||||||
if let row = (try? modelContext.fetch(
|
if let row = (try? modelContext.fetch(
|
||||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
modelContext.delete(row)
|
modelContext.delete(row)
|
||||||
|
Task { try? await APIClient.shared.markStoryUnread(id: id) }
|
||||||
} else {
|
} else {
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
|
Task { try? await APIClient.shared.markStoryRead(id: id) }
|
||||||
}
|
}
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
}
|
}
|
||||||
@@ -195,7 +199,7 @@ struct StoryDetailView: View {
|
|||||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||||
.kerning(0.8)
|
.kerning(0.8)
|
||||||
.foregroundStyle(Color(hex: "888888"))
|
.foregroundStyle(Color(hex: "888888"))
|
||||||
Text("·").foregroundStyle(Color(hex: "444444"))
|
Text("·").foregroundStyle(Palette.mutedText)
|
||||||
Text("\(score) signal")
|
Text("\(score) signal")
|
||||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||||
.foregroundStyle(Signal.scoreColor(score))
|
.foregroundStyle(Signal.scoreColor(score))
|
||||||
@@ -228,8 +232,8 @@ struct StoryDetailView: View {
|
|||||||
.kerning(0.8)
|
.kerning(0.8)
|
||||||
.foregroundStyle(titleColor)
|
.foregroundStyle(titleColor)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 14, weight: .regular))
|
.font(.system(size: 16, weight: .regular, design: .default))
|
||||||
.foregroundStyle(Color(hex: "C8C8C8"))
|
.foregroundStyle(Palette.secondaryText)
|
||||||
.lineSpacing(3)
|
.lineSpacing(3)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -245,7 +249,7 @@ struct StoryDetailView: View {
|
|||||||
Text("COVERAGE TIMELINE")
|
Text("COVERAGE TIMELINE")
|
||||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||||
.kerning(0.8)
|
.kerning(0.8)
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
.padding(.bottom, 14)
|
.padding(.bottom, 14)
|
||||||
|
|
||||||
if vm.isLoading && timeline.isEmpty {
|
if vm.isLoading && timeline.isEmpty {
|
||||||
@@ -253,7 +257,7 @@ struct StoryDetailView: View {
|
|||||||
} else if timeline.isEmpty {
|
} else if timeline.isEmpty {
|
||||||
Text(vm.error ?? "No coverage available.")
|
Text(vm.error ?? "No coverage available.")
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, entry in
|
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, entry in
|
||||||
NavigationLink(value: ArticleRoute(articleId: entry.articleId,
|
NavigationLink(value: ArticleRoute(articleId: entry.articleId,
|
||||||
@@ -275,11 +279,11 @@ struct StoryDetailView: View {
|
|||||||
// Node + connector
|
// Node + connector
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(isFirst ? Palette.orange : Color(hex: "333333"))
|
.fill(isFirst ? Palette.orange : Palette.mutedText)
|
||||||
.frame(width: 11, height: 11)
|
.frame(width: 11, height: 11)
|
||||||
.overlay(Circle().stroke(Color.black, lineWidth: 2))
|
.overlay(Circle().stroke(Palette.background, lineWidth: 2))
|
||||||
if !isLast {
|
if !isLast {
|
||||||
Rectangle().fill(Color(hex: "222222")).frame(width: 1.5)
|
Rectangle().fill(Palette.hairline).frame(width: 1.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 11)
|
.frame(width: 11)
|
||||||
@@ -287,12 +291,12 @@ struct StoryDetailView: View {
|
|||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text(entry.source)
|
Text(entry.source)
|
||||||
.font(.system(size: 12, weight: .bold))
|
.font(.system(size: 14, weight: .semibold, design: .default))
|
||||||
.foregroundStyle(isFirst ? Palette.orange : Color(hex: "999999"))
|
.foregroundStyle(isFirst ? Palette.orange : Palette.primaryText)
|
||||||
if entry.isBreaking {
|
if entry.isBreaking {
|
||||||
Text("BREAKING")
|
Text("BREAKING")
|
||||||
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(Palette.primaryText)
|
||||||
.padding(.horizontal, 5).padding(.vertical, 2)
|
.padding(.horizontal, 5).padding(.vertical, 2)
|
||||||
.background(Palette.conflictBorder)
|
.background(Palette.conflictBorder)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
@@ -300,12 +304,12 @@ struct StoryDetailView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Text(entry.publishedAt.clockShort)
|
Text(entry.publishedAt.clockShort)
|
||||||
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
||||||
.foregroundStyle(Color(hex: "555555"))
|
.foregroundStyle(Palette.tertiaryText)
|
||||||
if cached { CachedDot(size: 6) }
|
if cached { CachedDot(size: 6) }
|
||||||
}
|
}
|
||||||
Text(entry.headline)
|
Text(entry.headline)
|
||||||
.font(.system(size: 15, weight: .regular))
|
.font(.system(size: 16, weight: .regular, design: .default))
|
||||||
.foregroundStyle(Color(hex: "D0D0D0"))
|
.foregroundStyle(Palette.secondaryText)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
}
|
}
|
||||||
10
README.md
10
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Jarvis (iOS)
|
# Jervis (iOS)
|
||||||
|
|
||||||
A SwiftUI iOS client for a self-hosted RSS **news-correlation** platform. It shows
|
A SwiftUI iOS client for a self-hosted RSS **news-correlation** platform. It shows
|
||||||
stories ranked by a server-computed **signal score**, caches full articles for offline
|
stories ranked by a server-computed **signal score**, caches full articles for offline
|
||||||
@@ -18,7 +18,7 @@ reading, and receives live updates over WebSocket.
|
|||||||
directly.
|
directly.
|
||||||
|
|
||||||
```
|
```
|
||||||
Jarvis/
|
Jervis/
|
||||||
Models/ Codable + SwiftData models
|
Models/ Codable + SwiftData models
|
||||||
Networking/ APIClient (REST), WebSocketManager
|
Networking/ APIClient (REST), WebSocketManager
|
||||||
Store/ StoryStore, ServerSettings
|
Store/ StoryStore, ServerSettings
|
||||||
@@ -37,10 +37,10 @@ Jarvis/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install xcodegen # once
|
brew install xcodegen # once
|
||||||
xcodegen generate # produces Jarvis.xcodeproj
|
xcodegen generate # produces Jervis.xcodeproj
|
||||||
open Jarvis.xcodeproj # ⌘R in Xcode, or:
|
open Jervis.xcodeproj # ⌘R in Xcode, or:
|
||||||
|
|
||||||
xcodebuild -project Jarvis.xcodeproj -scheme Jarvis \
|
xcodebuild -project Jervis.xcodeproj -scheme Jervis \
|
||||||
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build
|
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,42 @@ Status: 🔴 open · 🟡 mitigated client-side (real fix still pending) · 🟢
|
|||||||
3. 🔴 **[backend] Add accurate per-story tags (topic + region).** So the client can read
|
3. 🔴 **[backend] Add accurate per-story tags (topic + region).** So the client can read
|
||||||
tags (sport, uganda, south-africa, …) instead of keyword-guessing. Unblocks #6.
|
tags (sport, uganda, south-africa, …) instead of keyword-guessing. Unblocks #6.
|
||||||
4. 🔴 **[backend] Topic-classifier coverage for the 47 feeds.** science/world/tech mapping.
|
4. 🔴 **[backend] Topic-classifier coverage for the 47 feeds.** science/world/tech mapping.
|
||||||
|
15. 🔴 **[backend] Freshness score decays from `lastUpdatedAt` (cluster last touched), not
|
||||||
|
from the actual news event age — old stories never leave the top of the feed.**
|
||||||
|
Confirmed live on `/api/v1/stories`: 13 of the top 20 stories are 5+ days old by
|
||||||
|
`firstSeenAt` but still score 90+; the #1 story (`story_511c67eb064d`) has
|
||||||
|
`firstSeenAt: 2026-06-17`, `lastUpdatedAt: 2026-07-09` (21 days later), `freshnessScore:
|
||||||
|
1.0`, signal 100. Compounds with #2 (over-clustering): a loose cluster keeps absorbing
|
||||||
|
tangential articles indefinitely (this story's timeline includes "Security forces ready
|
||||||
|
for June 30 marches: Ramaphosa" — unrelated to the football story it's attached to),
|
||||||
|
each attachment resets `lastUpdatedAt` to now, so the cluster's clock never runs out and
|
||||||
|
its canonical headline drifts to whatever was clustered last (list view showed "South
|
||||||
|
Africa stun South Korea…"; story detail showed "Updated FIFA rankings after Bafana
|
||||||
|
Bafana exit" for the *same* `story_id`). **Fix: decay freshness from `firstSeenAt` (or
|
||||||
|
the newest article whose relevance to the cluster core is verified), not from
|
||||||
|
"last touched."** Tightening the TF-IDF threshold in #2 should also reduce how often
|
||||||
|
unrelated articles keep a stale cluster's clock alive. Also confirmed the API's own
|
||||||
|
`isStale` field is unusable as a stopgap — it reads `false` on every story checked
|
||||||
|
regardless of actual age, so it isn't computed from a real staleness threshold.
|
||||||
|
*Client mitigations shipped:*
|
||||||
|
1. Age display (row meta line, hero card) now reads from `firstSeenAt` instead of
|
||||||
|
`updatedAt`, so the UI no longer shows "1 hr ago" on stories that are actually
|
||||||
|
weeks old.
|
||||||
|
2. Found that `StorySummary.clientRank` — an *existing* time-decay mechanism, already
|
||||||
|
in the codebase specifically to stop stale high-scorers from leading the feed — was
|
||||||
|
itself silently neutralized by this same bug: it computed age from `updatedAt`, so a
|
||||||
|
fossil's age always read as ~0. Fixed to use `firstSeenAt`. This isn't new
|
||||||
|
client-side ranking logic; it's un-breaking a pre-existing, intentional one.
|
||||||
|
3. Added `StorySummary.isFossil` (`firstSeenAt` > 5 days old) as a hard backstop —
|
||||||
|
`clientRank`'s decay is soft (max 20% score penalty) and isn't always enough to
|
||||||
|
overcome a high `signalScore` alone. Fossils are sunk below current stories in
|
||||||
|
`mainStories` and `makeDigest`, so a stale story can never occupy the hero/lead slot
|
||||||
|
even if the server still ranks it #1. They still appear in the feed, just not in
|
||||||
|
the most prominent, most misleading position.
|
||||||
|
*Still wrong at the source* — the server's own `signalScore`/`freshnessScore` ordering
|
||||||
|
(what page 2+, pagination cutoffs, and `min_signal` filtering all rely on) is
|
||||||
|
unaffected; these are display/ordering patches downstream of it. The real fix is
|
||||||
|
still backend-side.
|
||||||
|
|
||||||
## Backend — features
|
## Backend — features
|
||||||
|
|
||||||
|
|||||||
18
project.yml
18
project.yml
@@ -1,4 +1,4 @@
|
|||||||
name: Jarvis
|
name: Jervis
|
||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.kisani
|
bundleIdPrefix: com.kisani
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
@@ -6,22 +6,23 @@ options:
|
|||||||
createIntermediateGroups: true
|
createIntermediateGroups: true
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
Jarvis:
|
Jervis:
|
||||||
type: application
|
type: application
|
||||||
platform: iOS
|
platform: iOS
|
||||||
deploymentTarget: "17.0"
|
deploymentTarget: "17.0"
|
||||||
sources:
|
sources:
|
||||||
- path: Jarvis
|
- path: Jervis
|
||||||
info:
|
info:
|
||||||
path: Jarvis/Info.plist
|
path: Jervis/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Jarvis
|
CFBundleDisplayName: Jervis
|
||||||
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
UILaunchScreen:
|
UILaunchScreen:
|
||||||
UIColorName: ""
|
UIColorName: ""
|
||||||
UISupportedInterfaceOrientations:
|
UISupportedInterfaceOrientations:
|
||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
UIUserInterfaceStyle: Dark
|
NSLocalNetworkUsageDescription: "Jervis connects to your self-hosted news platform on the local network."
|
||||||
NSLocalNetworkUsageDescription: "Jarvis connects to your self-hosted news platform on the local network."
|
|
||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsArbitraryLoads: true
|
NSAllowsArbitraryLoads: true
|
||||||
NSAllowsLocalNetworking: true
|
NSAllowsLocalNetworking: true
|
||||||
@@ -38,8 +39,9 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis
|
PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis
|
||||||
|
PRODUCT_NAME: Jervis
|
||||||
MARKETING_VERSION: "1.0"
|
MARKETING_VERSION: "1.0"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "2"
|
||||||
TARGETED_DEVICE_FAMILY: "1"
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
# Device builds: automatic signing. Change DEVELOPMENT_TEAM to your team
|
# Device builds: automatic signing. Change DEVELOPMENT_TEAM to your team
|
||||||
|
|||||||
Reference in New Issue
Block a user