From 561f7204282047ed3942372cc3010a9708ff2cb2 Mon Sep 17 00:00:00 2001 From: MrSuttonmann <7480500+MrSuttonmann@users.noreply.github.com> Date: Fri, 22 May 2026 12:56:22 +0100 Subject: [PATCH] Rework P2P federation as opt-in Community Feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend name flips to "Community Feed"; backend identifiers (P2PConfigStore, /api/p2p/config, /data/p2p.json, P2P_* env vars) stay put. Fresh installs no longer share by default — the relay client refuses to dial out until the operator opts in. Existing v1 p2p.json files grandfather forward (consent_given = enabled) so anyone already sharing keeps sharing across the upgrade. The entry point moves from a buried sidebar filter chip to a discoverable header status pill: accent-blue when off, muted with live "N receivers" once on. On a fresh browser the pill is followed up by an auto-opened onboarding dialog 8 s after the first snapshot — long enough for the map to populate, short enough that the user is still oriented. A localStorage flag ensures it fires once. P2P_ENABLED env-var is removed: consent-gated opt-in makes the kill switch redundant. Tests and the Playwright harness inherit "no outbound" from the fresh-install default. --- CLAUDE.md | 43 ++- README.md | 41 ++- app/static/app.css | 39 ++- app/static/app.js | 2 + app/static/community_feed_dialog.js | 322 ++++++++++++++++++ app/static/detail_panel.js | 12 +- app/static/dialogs.css | 107 +++++- app/static/dialogs.js | 88 ----- app/static/index.html | 135 ++++++-- app/static/sidebar.js | 50 +-- app/static/state.js | 3 - app/static/trails.js | 6 +- .../Configuration/AppOptionsBinder.cs | 1 - .../FlightJar.Api/Endpoints/P2PEndpoints.cs | 22 +- .../Hosting/P2PRelayClientService.cs | 6 +- dotnet/src/FlightJar.Api/Program.cs | 18 +- .../Configuration/AppOptions.cs | 21 +- .../FlightJar.Core/State/RegistrySnapshot.cs | 8 +- .../P2P/P2PConfigStore.cs | 50 ++- .../FlightJar.Api.Tests/ApiEndpointsTests.cs | 10 +- .../Auth/AuthEndpointsTests.cs | 1 - .../BeastReplayE2ETests.cs | 1 - .../P2P/P2PConfigStoreTests.cs | 142 ++++++++ playwright.config.js | 9 +- 24 files changed, 860 insertions(+), 277 deletions(-) create mode 100644 app/static/community_feed_dialog.js create mode 100644 dotnet/tests/FlightJar.Persistence.Tests/P2P/P2PConfigStoreTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index ef03c41..b1af670 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -416,15 +416,22 @@ Reference-data loaders live under `FlightJar.Core.ReferenceData`: - **`AirlinesDb`** — OpenFlights `airlines.dat`; callsign prefix → IATA + airline + alliance. Hand-curated Star / oneworld / SkyTeam dict. -### P2P federation +### Community Feed (P2P federation internally) + +User-facing name is **Community Feed** (entered via the sidebar-footer +"Share planes" / "Community Feed" button → `#community-feed-dialog`). +Backend identifiers stay as `P2P*` / `/api/p2p/config` / `/data/p2p.json` +/ `/p2p/ws` / `P2P_*` env vars — internal-only, never renamed. Four moving parts split across the .NET service and a separate Cloudflare Worker: - **`P2PConfigStore`** (`FlightJar.Persistence.P2P`) — UI-managed - on/off + share-site-name toggles. Persisted to `/data/p2p.json` - (atomic write-to-temp + rename). Defaults `enabled=true`, - `share_site_name=false` — federation is on out of the box. + on/off + consent + share-site-name. Persisted to `/data/p2p.json` + (atomic write-to-temp + rename). Schema v2 adds `consent_given`; + fresh installs default `Enabled=false, ConsentGiven=false`, + v1 files are grandfathered on load (`ConsentGiven = Enabled`). + The relay client refuses to dial out unless both flags are true. Exposes a `Changed` event the background service hooks into. - **`P2PRelayCredentialsStore`** + **`P2PRelayRegistrar`** — token acquisition. Precedence: `P2P_RELAY_TOKEN` env override → token @@ -436,9 +443,10 @@ Cloudflare Worker: next connect re-registers — called from the WS client when the relay rejects with HTTP 401 (TTL eviction or rotation). - **`P2PRelayClientService : BackgroundService`** — always - registered. Outer loop consults `P2PConfigStore.Current.Enabled`; - when disabled it parks on a `TaskCompletionSource` until the - store fires `Changed`, when re-enabled it calls + registered. Outer loop consults + `P2PConfigStore.Current.{Enabled,ConsentGiven}`; when either is + false it parks on a `TaskCompletionSource` until the store fires + `Changed`, when both flip on it calls `P2PRelayRegistrar.EnsureTokenAsync` and dials the relay with the resulting bearer token. Mid-connection toggles cancel the live `ClientWebSocket` via the registered `_connectionCts`. Push side: @@ -477,15 +485,14 @@ The relay URL, bearer token, and push interval are env-only are the advanced "self-hosted relay" knobs. `P2P_RELAY_TOKEN` is normally unset; the registrar auto-registers on first connect and persists the issued token. Set it explicitly only when pointing at -a relay that issues tokens out-of-band. `P2P_ENABLED` is a hard -env-only kill switch (default `1`): when `0` the -`P2PRelayClientService` is never registered, so the process makes -no outbound WebSocket connections at all. Used by the e2e harness -(`playwright.config.js`) and the `WebApplicationFactory`-based API -tests so the relay's outbound chatter doesn't destabilise -timing-sensitive assertions or pollute snapshot counts. Everything -user-facing still goes through `GET/POST /api/p2p/config`, both -auth-gated by `RequireAuthSession()`. +a relay that issues tokens out-of-band. There is no env-only kill +switch any more: the relay BackgroundService is always registered +but parks on a signal until `P2PConfigStore.Current.Enabled && +ConsentGiven` is true. Fresh installs (and the e2e harness, which +uses a clean data dir each run) default to off, so the relay stays +parked unless the operator opts in via the Community Feed dialog. +Everything user-facing still goes through `GET/POST /api/p2p/config`, +both auth-gated by `RequireAuthSession()`. ### Watchlist + alerts @@ -533,7 +540,7 @@ Handled in `FlightJar.Api.Configuration.AppOptionsBinder` against the `BLACKSPOTS_ANTENNA_MSL_M` (optional override; preferred when known), `BLACKSPOTS_RADIUS_KM`, `BLACKSPOTS_GRID_DEG`, `BLACKSPOTS_MAX_AGL_M`, `BLACKSPOTS_IDLE_TIMEOUT_MIN`, `TERRAIN_CACHE_DIR`, -`P2P_ENABLED`, `P2P_RELAY_URL`, `P2P_RELAY_TOKEN`, `P2P_PUSH_INTERVAL_S`. +`P2P_RELAY_URL`, `P2P_RELAY_TOKEN`, `P2P_PUSH_INTERVAL_S`. Target altitude is UI-only (slider on the map), not env-driven. The README has the full reference table. @@ -569,7 +576,7 @@ throughput ceiling on CI and build boxes): | `heatmap.json` | `TrafficHeatmap` | 7×24 weekday/hour grid. | | `watchlist.json` | `WatchlistStore` | Watchlist of ICAO24 hex codes + last-seen map. | | `notifications.json` | `NotificationsConfigStore` | UI-managed alert channels. | -| `p2p.json` | `P2PConfigStore` | UI-managed P2P federation on/off + share-site-name (schema v1). Default on first run: `enabled=true`, `share_site_name=false`. | +| `p2p.json` | `P2PConfigStore` | UI-managed Community Feed on/off + consent + share-site-name (schema v2). Default on fresh install: `enabled=false`, `consent_given=false`, `share_site_name=false`. v1 files (which only had `enabled`/`share_site_name`) are grandfathered on load: `consent_given = enabled`. | | `p2p_credentials.json` | `P2PRelayCredentialsStore` | Bearer token issued by the relay's `/register` endpoint on first connect. Reused across restarts so we don't re-register on every container start. Cleared on HTTP 401 (relay-side eviction or rotation). | | `vfrmap_cycle.json` | `VfrmapCycle` | Auto-discovered FAA chart cycle date. | | `openaip.json.gz` | `OpenAipClient` | Cached OpenAIP airspaces / obstacles / reporting points per 2° bbox tile (schema v1, 7-day TTL). | diff --git a/README.md b/README.md index 55eca2d..2053885 100644 --- a/README.md +++ b/README.md @@ -390,39 +390,45 @@ or your own tooling. Browser notifications (while a tab is open) still fire alongside; the Alerts channels are what keep working once every tab is closed. -## P2P federation +## Community Feed Flightjar instances can pool what they see — each one shares its locally decoded aircraft with a small community network and receives the aggregated feed from every other connected instance. Aircraft -seen by peers appear on your map with a dashed indigo outline and a -"Network" badge in the detail panel; the "Peers" chip in the sidebar -filter bar toggles them on or off. - -**Enabled by default.** Fresh installs participate out of the box — -no setup, no signup, no key to copy. To opt out, open the **About** -dialog from the sidebar footer and uncheck "Enable P2P federation". +seen via the feed appear with a "Network" badge in the detail panel; +on the map they're indistinguishable from aircraft your own radio +picked up. + +**Opt-in.** Fresh installs do **not** connect to the network until +you opt in. Click **Share planes** in the sidebar footer to open the +Community Feed dialog — it explains what's shared and what isn't, +then a single "Turn on Community Feed" button starts the relay +connection. Toggling it back off later is one click in the same +dialog. (Existing installs that had the previous "enabled by +default" behaviour are grandfathered in across the upgrade and +continue to share unless you turn it off.) **Aircraft seen by multiple receivers are combined, not duplicated.** -When your receiver and a peer both see the same aircraft, the records -merge: local data wins where both sides have a value, peer data fills -gaps. Receiver-specific values like distance, signal strength, and -trail always stay local. +When your receiver and another both see the same aircraft, the +records merge: local data wins where both sides have a value, the +peer's data fills gaps. Receiver-specific values like distance, +signal strength, and trail always stay local. **What's shared, and what isn't.** Each instance sanitises its outbound payload before it leaves the container: - **Stripped:** your receiver coordinates, per-aircraft distance (which would let observers back-solve your location), and your - site name (unless you opt in via the second checkbox in About). + site name (unless you opt in via the second checkbox in the + Community Feed dialog). - **Kept:** aircraft ICAO24, position, callsign, altitude, speed, track, squawk, and trail — all derived from public ADS-B broadcasts that anyone in radio range can already pick up. **Self-hosting.** If you'd rather run your own relay (e.g. a private -federation between a small group of receivers), the worker source -lives in `relay-worker/`. Deploy it under your own Cloudflare account -and point your Flightjar instance at it with `P2P_RELAY_URL`. +network between a small group of receivers), the worker source lives +in `relay-worker/`. Deploy it under your own Cloudflare account and +point your Flightjar instance at it with `P2P_RELAY_URL`. ## Running multiple receivers @@ -465,8 +471,7 @@ It shows up next to "Flightjar" in the sidebar and in the browser tab title | `BLACKSPOTS_IDLE_TIMEOUT_MIN` | `15` | Minutes the blackspots feature can sit idle before reclaiming its in-memory caches. Disk caches survive eviction, so re-engaging the layer is cheap. `0` disables eviction. | | `TERRAIN_CACHE_DIR` | `/data/terrain` | Directory for the downloaded elevation tiles. | | `TELEMETRY_ENABLED` | `1` | Anonymous usage telemetry — see below. | -| `P2P_ENABLED` | `1` | Set to `0` to disable P2P federation entirely (no outbound relay connections). The runtime on/off and share-`SITE_NAME` toggle live in the **About** dialog. | -| `P2P_RELAY_URL` | `wss://relay.flightjar.xyz/ws` | Override the relay this instance connects to. Leave at the default to use the community relay; change it to point at one you've self-hosted. | +| `P2P_RELAY_URL` | `wss://relay.flightjar.xyz/ws` | Override the relay this instance connects to. Leave at the default to use the community relay; change it to point at one you've self-hosted. The Community Feed runtime on/off, consent, and share-`SITE_NAME` toggle live in the **Community Feed** dialog reached from the sidebar footer — fresh installs default to off until you opt in. | | `P2P_RELAY_TOKEN` | (unset) | Override the bearer token sent to the relay. Normally unset — instances acquire a token automatically on first connect. Set only when pointing at a relay that issues tokens out-of-band. | | `P2P_PUSH_INTERVAL_S` | `5` | Seconds between snapshot pushes to the relay. | | `FLIGHTJAR_PASSWORD` | (unset) | Optional shared secret. When non-empty, the watchlist + notification-channel endpoints (`/api/watchlist`, `/api/notifications/*`) require an authenticated session cookie minted by `POST /api/auth/login`. Empty disables auth entirely (default — fine on a private LAN). Set this when exposing the instance to the internet so unauthenticated callers can't read your bot tokens or scrape your watchlist. See **Optional password protection** below. | diff --git a/app/static/app.css b/app/static/app.css index 741f1cd..e613ebc 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -54,12 +54,39 @@ background: #888; margin-right: 6px; vertical-align: middle; } #status.live .dot { background: #4ade80; } #status.dead .dot { background: #ef4444; } - #p2p-status { font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } - #p2p-status[hidden] { display: none; } - #p2p-status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; - background: #888; margin-right: 6px; vertical-align: middle; } - #p2p-status.live .dot { background: #818cf8; } - #p2p-status.dead .dot { background: #ef4444; } + /* Community Feed status pill. Lives in the header next to the + connection status; doubles as the dialog entry point so the + feature isn't buried in the footer. Two states: + - .is-off: accent-coloured dot, drives the eye on a fresh + install. Click to enable. + - .is-on: muted, dot live (green) when the relay's connected, + dead (amber) while reconnecting. Click to manage. */ + .community-feed-pill { + font-family: inherit; font-size: 11px; + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 9px; border-radius: 11px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + color: var(--muted); cursor: pointer; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + } + .community-feed-pill:hover { + color: var(--text); background: rgba(255, 255, 255, 0.08); + } + .community-feed-pill-dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: #888; + } + .community-feed-pill.is-off { + color: var(--accent); border-color: rgba(37, 99, 235, 0.55); + background: rgba(37, 99, 235, 0.08); + } + .community-feed-pill.is-off:hover { + color: #60a5fa; background: rgba(37, 99, 235, 0.15); + } + .community-feed-pill.is-off .community-feed-pill-dot { background: var(--accent); } + .community-feed-pill.is-on .community-feed-pill-dot { background: #4ade80; } + .community-feed-pill.is-disconnected .community-feed-pill-dot { background: #fbbf24; } .home-control { /* Mirror Leaflet's .leaflet-bar a sizing — 26×26 by default, bumped to 30×30 on touch devices — so the home button lines up flush with diff --git a/app/static/app.js b/app/static/app.js index b3c582d..507a494 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -11,6 +11,7 @@ import { initAboutDialog, initMapKeyDialog, initStatsDialog, initWatchlistDialog import { initAirspaceFiltersDialog } from './airspace_filters_dialog.js'; import { initAirspaceSlice, refreshSlice, syncSliceEnabled } from './airspace_slice.js'; import { initAlertsDialog } from './alerts_dialog.js'; +import { initCommunityFeedDialog } from './community_feed_dialog.js'; import { initAirportTooltip } from './tooltip.js'; import { initDetailPanel, @@ -99,6 +100,7 @@ async function boot() { setAirspaceCacheListener(refreshSlice); syncSliceEnabled(); initAboutDialog(); + initCommunityFeedDialog(); initMapKeyDialog(); initStatsDialog(); initWatchlistDialog(); diff --git a/app/static/community_feed_dialog.js b/app/static/community_feed_dialog.js new file mode 100644 index 0000000..d7ee078 --- /dev/null +++ b/app/static/community_feed_dialog.js @@ -0,0 +1,322 @@ +// Community Feed dialog + header status pill. +// +// The pill lives in the sidebar header (#community-feed-pill) and is +// the only entry point — click to open the dialog. It also doubles as +// the always-visible status indicator: "off" with an accent dot when +// the operator hasn't opted in, "on · N receivers" once they have. +// +// The dialog renders one of two states depending on the server's +// reported config: +// +// - Onboarding (#cf-onboarding): the operator has never opted in. +// Explains what's shared / not shared, has a "Turn on" button. +// - Manage (#cf-manage): the feed is on. Shows live status + a +// site-name toggle + a "Turn off" button. +// +// First-run auto-open: on a fresh browser (localStorage flag +// `flightjar.cf_onboarding_shown` absent) the dialog opens itself +// ~8s after the first snapshot arrives. The 8s delay gives the map +// time to populate so the user sees the app working before being +// asked to opt into anything; the localStorage flag means we only +// nudge once. Auth-required + locked instances still see the dialog +// (with the locked hint) — they just can't enable until they unlock. + +import { authedFetch, getAuthStatus, subscribeAuth } from './auth.js'; + +const DIALOG_ID = 'community-feed-dialog'; +const PILL_ID = 'community-feed-pill'; +const AUTOPOPEN_STORAGE_KEY = 'flightjar.cf_onboarding_shown'; +const AUTOPOPEN_DELAY_MS = 8000; + +let lastConfig = null; // last server-confirmed config +let lastP2PStatus = null; // last p2p block from the snapshot +let firstSnapshotSeen = false; +let autoOpenTimer = null; + +function dialogEl() { return document.getElementById(DIALOG_ID); } +function pillEl() { return document.getElementById(PILL_ID); } + +async function fetchConfig() { + const auth = getAuthStatus(); + // Skip silent open-time fetch when locked — would pop the unlock + // prompt the moment the dialog mounted. Onboarding mode still + // renders fine with lastConfig = null (defaults to off). + if (auth.required && !auth.unlocked) return null; + try { + const r = await fetch('/api/p2p/config', { credentials: 'same-origin' }); + if (!r.ok) return null; + return await r.json(); + } catch (_) { + return null; + } +} + +async function postConfig(patch) { + const r = await authedFetch('/api/p2p/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return await r.json(); +} + +function setStatus(elId, text, kind) { + const el = document.getElementById(elId); + if (!el) return; + el.textContent = text; + el.hidden = !text; + el.classList.remove('is-error', 'is-ok'); + if (kind) el.classList.add(`is-${kind}`); +} + +function isOnState() { + return !!(lastConfig && lastConfig.enabled && lastConfig.consent_given); +} + +function renderDialog() { + const onboarding = document.getElementById('cf-onboarding'); + const manage = document.getElementById('cf-manage'); + if (!onboarding || !manage) return; + + // A user is "on" only when both flags are set on the server. Anything + // else (fresh install, locked + never seen config, mid-toggle) renders + // the onboarding state. + const isOn = isOnState(); + onboarding.hidden = isOn; + manage.hidden = !isOn; + + // Lock-hint inside onboarding visible iff the instance is locked. + const auth = getAuthStatus(); + const locked = auth.required && !auth.unlocked; + const lockedHint = document.getElementById('cf-locked-hint'); + if (lockedHint) lockedHint.hidden = !locked; + const enableBtn = document.getElementById('cf-enable-btn'); + if (enableBtn) enableBtn.disabled = locked; + + // Site-name checkboxes: keep both surfaces in sync with the same value. + const shareValue = !!(lastConfig && lastConfig.share_site_name); + const shareA = document.getElementById('cf-share-site-name'); + const shareB = document.getElementById('cf-share-site-name-manage'); + if (shareA) shareA.checked = shareValue; + if (shareB) shareB.checked = shareValue; + + // Live status (manage mode only). Sourced from the snapshot's p2p + // block via updateP2PStatus(); fall back to "Connecting…" before the + // first snapshot arrives. + if (isOn) { + const dot = document.getElementById('cf-status-dot'); + const text = document.getElementById('cf-status-text'); + const p2p = lastP2PStatus; + const connected = !!(p2p && p2p.connected); + const peers = connected ? (p2p.peers | 0) : 0; + if (dot) { + dot.classList.toggle('live', connected); + dot.classList.toggle('dead', !!p2p && !connected); + } + if (text) { + text.textContent = !p2p + ? 'Connecting…' + : connected + ? (peers === 1 + ? 'Connected · 1 other receiver online' + : `Connected · ${peers} other receivers online`) + : 'Disconnected — will retry automatically'; + } + } +} + +function renderPill() { + const pill = pillEl(); + if (!pill) return; + const isOn = isOnState(); + const p2p = lastP2PStatus; + const connected = isOn && !!(p2p && p2p.connected); + const disconnected = isOn && !!p2p && !connected; + pill.classList.toggle('is-off', !isOn); + pill.classList.toggle('is-on', isOn && (connected || !p2p)); + pill.classList.toggle('is-disconnected', disconnected); + + const peers = connected ? (p2p.peers | 0) : 0; + const label = pill.querySelector('.community-feed-pill-label'); + if (label) { + if (!isOn) { + label.textContent = 'Community Feed: off'; + } else if (!p2p) { + label.textContent = 'Community Feed · connecting…'; + } else if (connected) { + label.textContent = peers === 1 + ? 'Community Feed · 1 receiver' + : `Community Feed · ${peers} receivers`; + } else { + label.textContent = 'Community Feed · reconnecting…'; + } + } + pill.title = isOn + ? 'Community Feed — sharing with the network. Click to manage.' + : 'Community Feed — share aircraft this receiver detects with the network. Click to enable.'; +} + +function openDialog() { + const dialog = dialogEl(); + if (!dialog) return; + renderDialog(); + if (typeof dialog.showModal === 'function') dialog.showModal(); + else dialog.setAttribute('open', ''); +} + +// Schedule the first-run auto-open. Fires once `firstSnapshotSeen` is +// true and a single setTimeout has elapsed. Marks the localStorage +// flag immediately on fire so a slow user who lets it open, closes +// it, and refreshes the page doesn't get nudged twice. +function maybeScheduleAutoOpen() { + if (autoOpenTimer != null) return; + if (!firstSnapshotSeen) return; + let alreadyShown = false; + try { alreadyShown = localStorage.getItem(AUTOPOPEN_STORAGE_KEY) === '1'; } + catch (_) { /* storage disabled — treat as not shown */ } + if (alreadyShown) return; + // Already opted in? Skip the nudge entirely. + if (isOnState()) return; + + autoOpenTimer = setTimeout(() => { + autoOpenTimer = null; + try { localStorage.setItem(AUTOPOPEN_STORAGE_KEY, '1'); } + catch (_) { /* storage disabled */ } + // Don't reopen if the user has manually opened it in the meantime, + // or if they've opted in via some other path (very unlikely, but + // belt-and-braces). + if (dialogEl()?.open) return; + if (isOnState()) return; + openDialog(); + }, AUTOPOPEN_DELAY_MS); +} + +// Called by sidebar.js on every snapshot tick — feeds the pill and the +// dialog's live status, and triggers the first-run auto-open. +export function updateP2PStatus(p2p) { + lastP2PStatus = p2p || null; + if (!firstSnapshotSeen) { + firstSnapshotSeen = true; + maybeScheduleAutoOpen(); + } + renderPill(); + if (dialogEl()?.open) renderDialog(); +} + +function wireOnboarding() { + const enableBtn = document.getElementById('cf-enable-btn'); + const shareEl = document.getElementById('cf-share-site-name'); + if (!enableBtn || !shareEl) return; + + enableBtn.addEventListener('click', async () => { + setStatus('cf-onboarding-status', 'Turning on…'); + enableBtn.disabled = true; + try { + lastConfig = await postConfig({ + enabled: true, + consent_given: true, + share_site_name: shareEl.checked, + }); + setStatus('cf-onboarding-status', '', null); + renderPill(); + renderDialog(); + } catch (err) { + setStatus('cf-onboarding-status', `Couldn't turn on: ${err.message || err}`, 'error'); + enableBtn.disabled = false; + } + }); +} + +function wireManage() { + const shareEl = document.getElementById('cf-share-site-name-manage'); + const disableBtn = document.getElementById('cf-disable-btn'); + if (!shareEl || !disableBtn) return; + + shareEl.addEventListener('change', async () => { + setStatus('cf-manage-status', 'Saving…'); + try { + lastConfig = await postConfig({ share_site_name: shareEl.checked }); + setStatus('cf-manage-status', 'Saved.', 'ok'); + renderDialog(); + } catch (err) { + setStatus('cf-manage-status', `Save failed: ${err.message || err}`, 'error'); + } + }); + + disableBtn.addEventListener('click', async () => { + setStatus('cf-manage-status', 'Turning off…'); + disableBtn.disabled = true; + try { + // Leave consent_given true — they've already opted in once. Just + // flip enabled off. Re-enabling later won't need a fresh consent + // moment; the manage view becomes a one-toggle thing. + lastConfig = await postConfig({ enabled: false }); + setStatus('cf-manage-status', '', null); + renderPill(); + renderDialog(); + } catch (err) { + setStatus('cf-manage-status', `Couldn't turn off: ${err.message || err}`, 'error'); + } finally { + disableBtn.disabled = false; + } + }); +} + +export function initCommunityFeedDialog() { + const dialog = dialogEl(); + const pill = pillEl(); + if (!dialog || !pill) return; + + wireOnboarding(); + wireManage(); + renderPill(); + + pill.addEventListener('click', async () => { + // Mark the auto-open as "handled" the moment the user clicks the + // pill themselves — no point auto-opening 8s later if they've + // already engaged with it. + if (autoOpenTimer != null) { + clearTimeout(autoOpenTimer); + autoOpenTimer = null; + } + try { localStorage.setItem(AUTOPOPEN_STORAGE_KEY, '1'); } + catch (_) { /* storage disabled */ } + // Pull fresh config every open so other tabs / restart-time + // migrations are reflected. + const cfg = await fetchConfig(); + if (cfg) lastConfig = cfg; + openDialog(); + }); + + dialog.addEventListener('close', () => { + setStatus('cf-onboarding-status', '', null); + setStatus('cf-manage-status', '', null); + }); + dialog.addEventListener('click', (e) => { + const r = dialog.getBoundingClientRect(); + const inside = e.clientX >= r.left && e.clientX <= r.right + && e.clientY >= r.top && e.clientY <= r.bottom; + if (!inside) dialog.close(); + }); + + // Re-render when auth state changes — unlocking can reveal config we + // skipped on the silent open-time fetch. + subscribeAuth(async () => { + const cfg = await fetchConfig(); + if (cfg) { + lastConfig = cfg; + renderPill(); + } + if (dialog.open) renderDialog(); + }); + + // Eagerly fetch once on init so the pill reflects state even before + // the first snapshot arrives. Skips silently when locked. + fetchConfig().then((cfg) => { + if (cfg) { + lastConfig = cfg; + renderPill(); + } + }); +} diff --git a/app/static/detail_panel.js b/app/static/detail_panel.js index 2679229..4673b61 100644 --- a/app/static/detail_panel.js +++ b/app/static/detail_panel.js @@ -175,7 +175,7 @@ export function buildPopupContent(a, now, airports) { `` + `` + `` + - `` + + `` + `` + `` + `` + @@ -608,13 +608,15 @@ export function updatePopupContent(root, a, now, airports) { const firstSeen = entry?.sessionFirstSeen || a.first_seen; q('.pop-first-seen').textContent = relativeAge(firstSeen, now); - // P2P "also seen by N peers" — surfaced only when the relay reported a - // non-zero count for this aircraft. Locally-only aircraft (e.g. P2P off, - // or no peer is currently reporting this ICAO) leave the stat hidden. + // Community Feed "also seen by N receivers" — surfaced only when the + // relay reported a non-zero count for this aircraft. Locally-only + // aircraft (Community Feed off, or no other receiver currently + // reporting this ICAO) leave the stat hidden. const peersStat = q('.pop-peers-stat'); const seenN = a.seen_by_others; if (typeof seenN === 'number' && seenN > 0) { - q('.pop-peers-val').textContent = `${seenN} peer${seenN === 1 ? '' : 's'}`; + q('.pop-peers-val').textContent = + `${seenN} receiver${seenN === 1 ? '' : 's'}`; peersStat.hidden = false; } else { peersStat.hidden = true; diff --git a/app/static/dialogs.css b/app/static/dialogs.css index 4bc4b4a..9ca322a 100644 --- a/app/static/dialogs.css +++ b/app/static/dialogs.css @@ -60,15 +60,14 @@ } #about-dialog .telemetry-status.is-error { color: #fca5a5; } #about-dialog .telemetry-status.is-ok { color: #4ade80; } - #about-dialog .p2p-config-row { - display: flex; align-items: center; gap: 8px; - margin: 6px 0; font-size: 13px; color: var(--text); - } - #about-dialog .p2p-config-row input { accent-color: var(--accent); } /* Lock hint: shown when the instance requires a password and the user hasn't unlocked yet, so they know why the gated sections - (P2P federation, telemetry reset) are missing. */ - #about-dialog .about-locked-hint { + (telemetry reset) are missing. The Community Feed dialog has its + own copy of this rule scoped under #community-feed-dialog. + The :not([hidden]) guard matters — without it the rule's + specificity beats the UA's `[hidden] { display: none }` and the + hint paints even when the JS toggles the attribute off. */ + #about-dialog .about-locked-hint:not([hidden]) { display: flex; align-items: flex-start; gap: 8px; margin: 16px 0 0; padding: 8px 10px; background: rgba(255, 255, 255, 0.04); @@ -475,3 +474,97 @@ cursor: pointer; background: var(--accent); color: #fff; border: 0; border-radius: 3px; } .auth-submit:hover { background: #1d4ed8; } + + /* Community Feed dialog — opens from the sidebar footer "Share planes" + button. Two states: onboarding (#cf-onboarding) when the user has + never opted in, and manage (#cf-manage) once the feed is on. */ + #community-feed-dialog { + max-width: 540px; width: 90vw; + border: 1px solid var(--border); border-radius: 6px; + background: var(--panel); color: var(--text); + padding: 20px 24px 24px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + font-size: 13px; line-height: 1.45; + } + #community-feed-dialog::backdrop { background: rgba(0, 0, 0, 0.55); } + #community-feed-dialog h2 { margin: 0 0 8px; font-size: 18px; font-weight: 600; } + #community-feed-dialog h3 { + margin: 16px 0 6px; font-size: 11px; + text-transform: uppercase; letter-spacing: 0.6px; + color: var(--muted); font-weight: 600; + } + #community-feed-dialog p { margin: 0 0 8px; color: var(--muted); } + #community-feed-dialog .about-close-form { + margin: 0; padding: 0; display: flex; justify-content: flex-end; + } + #community-feed-dialog .about-close { + background: transparent; border: 0; color: var(--muted); + cursor: pointer; padding: 2px 4px; margin: -6px -8px 0 0; + display: inline-flex; align-items: center; justify-content: center; + } + #community-feed-dialog .about-close svg { display: block; } + #community-feed-dialog .about-close:hover { color: var(--text); } + #community-feed-dialog .cf-section { margin-top: 8px; } + #community-feed-dialog .cf-list { + margin: 0; padding-left: 18px; color: var(--muted); + } + #community-feed-dialog .cf-list li { margin-bottom: 4px; } + #community-feed-dialog .cf-inline-badge { + display: inline-block; margin: 0 2px; + } + #community-feed-dialog .cf-share-row { + display: flex; align-items: center; gap: 8px; + margin: 12px 0 0; font-size: 13px; color: var(--text); + } + #community-feed-dialog .cf-share-row input { accent-color: var(--accent); } + #community-feed-dialog .cf-primary-btn { + margin-top: 12px; padding: 8px 16px; + font-family: inherit; font-size: 13px; font-weight: 600; + cursor: pointer; background: var(--accent); color: #fff; + border: 0; border-radius: 3px; + } + #community-feed-dialog .cf-primary-btn:hover { background: #1d4ed8; } + #community-feed-dialog .cf-primary-btn:disabled { + opacity: 0.5; cursor: not-allowed; + } + #community-feed-dialog .cf-secondary-btn { + margin-top: 12px; padding: 5px 12px; + font-family: inherit; font-size: 12px; cursor: pointer; + background: transparent; color: var(--muted); + border: 1px solid var(--border); border-radius: 3px; + } + #community-feed-dialog .cf-secondary-btn:hover { + color: var(--text); border-color: var(--muted); + } + #community-feed-dialog .telemetry-status { + margin: 8px 0 0; font-size: 11px; color: var(--muted); + } + #community-feed-dialog .telemetry-status.is-error { color: #fca5a5; } + #community-feed-dialog .telemetry-status.is-ok { color: #4ade80; } + #community-feed-dialog .about-locked-hint:not([hidden]) { + display: flex; align-items: flex-start; gap: 8px; + margin: 12px 0 0; padding: 8px 10px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); border-radius: 4px; + font-size: 12px; color: var(--muted); + } + #community-feed-dialog .about-locked-hint svg { + flex-shrink: 0; margin-top: 1px; + } + #community-feed-dialog .cf-status-card { + margin: 4px 0 8px; padding: 8px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); border-radius: 4px; + } + #community-feed-dialog .cf-status-row { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--text); + } + #community-feed-dialog .cf-status-dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: #888; + } + #community-feed-dialog .cf-status-dot.live { background: #4ade80; } + #community-feed-dialog .cf-status-dot.dead { background: #ef4444; } + + diff --git a/app/static/dialogs.js b/app/static/dialogs.js index b96a255..f06a8ad 100644 --- a/app/static/dialogs.js +++ b/app/static/dialogs.js @@ -64,16 +64,6 @@ function applyTelemetryResetVisibility(snap) { section.hidden = snap.required && !snap.unlocked; } -// P2P federation toggles. Mirrors the wiring shape of telemetry reset: -// the section hides itself when the instance is locked + not unlocked, -// since both POSTs go through authedFetch and would otherwise just pop -// an unlock prompt the moment a checkbox is touched. -function applyP2PSectionVisibility(snap) { - const section = document.getElementById('p2p-config-section'); - if (!section) return; - section.hidden = snap.required && !snap.unlocked; -} - // Show a short hint explaining why the gated sections are absent. // Visible exactly when the instance is locked, hidden otherwise — kept // in sync via the same auth subscription that hides the sections. @@ -83,78 +73,6 @@ function applyAboutLockedHintVisibility(snap) { hint.hidden = !(snap.required && !snap.unlocked); } -async function loadP2PConfig() { - const enabledEl = document.getElementById('p2p-enabled'); - const shareEl = document.getElementById('p2p-share-site-name'); - if (!enabledEl || !shareEl) return; - // Skip the fetch when the instance is locked. The endpoint is - // auth-gated, so calling authedFetch here would pop the unlock - // dialog the moment the user opens About — which is exactly the - // surprise we're avoiding by hiding the section in the first place. - const auth = getAuthStatus(); - if (auth.required && !auth.unlocked) return; - try { - const r = await fetch('/api/p2p/config', { credentials: 'same-origin' }); - if (!r.ok) return; - const cfg = await r.json(); - enabledEl.checked = !!cfg.enabled; - shareEl.checked = !!cfg.share_site_name; - } catch (_) { /* offline — leave checkboxes as-is */ } -} - -function wireP2PConfig() { - const enabledEl = document.getElementById('p2p-enabled'); - const shareEl = document.getElementById('p2p-share-site-name'); - const status = document.getElementById('p2p-config-status'); - if (!enabledEl || !shareEl || !status) return; - - applyP2PSectionVisibility(getAuthStatus()); - subscribeAuth((snap) => { - applyP2PSectionVisibility(snap); - // Just unlocked while the dialog is open: pull the current config - // so the checkboxes reflect server state without needing to close - // and reopen the About dialog. - if (snap.ready && (!snap.required || snap.unlocked)) { - const dialog = aboutDialogEl(); - if (dialog?.open) loadP2PConfig(); - } - }); - - function setStatus(text, kind) { - status.textContent = text; - status.hidden = !text; - status.classList.remove('is-error', 'is-ok'); - if (kind) status.classList.add(`is-${kind}`); - } - - async function save() { - setStatus('Saving…'); - try { - const r = await authedFetch('/api/p2p/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - enabled: enabledEl.checked, - share_site_name: shareEl.checked, - }), - }); - if (!r.ok) { - setStatus(`Save failed (${r.status})`, 'error'); - return; - } - const cfg = await r.json(); - enabledEl.checked = !!cfg.enabled; - shareEl.checked = !!cfg.share_site_name; - setStatus('Saved.', 'ok'); - } catch (err) { - setStatus(`Save failed: ${err.message || err}`, 'error'); - } - } - - enabledEl.addEventListener('change', save); - shareEl.addEventListener('change', save); -} - function wireTelemetryReset() { const btn = document.getElementById('telemetry-reset-btn'); const status = document.getElementById('telemetry-reset-status'); @@ -191,17 +109,12 @@ export function initAboutDialog() { document.getElementById('about-btn').addEventListener('click', async () => { await populateAboutVersion(); maybePrependWrightBrothersNote(); - // Refresh checkbox state from the server every time the dialog opens - // so a config change made from another browser tab is reflected. - loadP2PConfig(); if (typeof dialog.showModal === 'function') dialog.showModal(); else dialog.setAttribute('open', ''); }); dialog.addEventListener('close', () => { const telStatus = document.getElementById('telemetry-reset-status'); if (telStatus) { telStatus.hidden = true; telStatus.textContent = ''; } - const p2pStatus = document.getElementById('p2p-config-status'); - if (p2pStatus) { p2pStatus.hidden = true; p2pStatus.textContent = ''; } }); dialog.addEventListener('click', (e) => { const r = dialog.getBoundingClientRect(); @@ -210,7 +123,6 @@ export function initAboutDialog() { if (!inside) dialog.close(); }); wireTelemetryReset(); - wireP2PConfig(); applyAboutLockedHintVisibility(getAuthStatus()); subscribeAuth(applyAboutLockedHintVisibility); } diff --git a/app/static/index.html b/app/static/index.html index 99a3313..08099a2 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -31,7 +31,11 @@

Flightjar

Connecting…
- +
@@ -58,8 +62,6 @@

Flightjar

title="Show only aircraft squawking emergency">Emergency -
@@ -277,6 +279,109 @@

Alerts

+ +
+ +
+ + +
+

Share your planes with the Community Feed

+

+ Flightjar can pool what your receiver sees with other Flightjar instances + via a small relay. Once you opt in, aircraft beyond your radio horizon — + ones in terrain shadows, over the curve, or just out of range — appear on + your map, fed by other receivers' antennas. +

+ +
+

What you get

+
    +
  • Aircraft your radio can't reach show up on your map, tagged + Network in the detail + panel.
  • +
  • A live count of how many other receivers are online in the feed.
  • +
+
+ +
+

What you share

+
    +
  • ICAO, callsign, altitude, position, velocity for aircraft your + receiver can see.
  • +
  • Optionally, your SITE_NAME — off by default.
  • +
+
+ +
+

What you don't share

+
    +
  • Your receiver's location and per-aircraft distance.
  • +
  • Signal strength, MLAT, raw BEAST messages.
  • +
  • Your IP address or any account identifier.
  • +
+
+ +
+ + + +
+ + +
+ + + +
+