Share-to-Mapvest + home-screen widgets
Two related features that let Mapvest live outside the app itself:
- Share-to-Mapvest — share any photo from Photos, Messages, Safari, Chrome, Claude, ChatGPT, Google Photos, or anywhere else the OS shows a share sheet, and Mapvest shows up as a target, the same way "Open in…" does for a photo. Mapvest identifies the shared image and offers to save the ticker or open the full detail sheet.
- Home-screen widgets — a "Nearby" list widget and a "Nearby" map widget (iOS WidgetKit + Android App Widget) showing investable brands near the last location Mapvest saw, without opening the app.
Both ship as real source in this repo, but both require a native rebuild
to activate — bun test/tsc --noEmit cover the JS/TS surface and the
API endpoints, but a share extension and a widget extension are separate
native targets that only exist after expo prebuild regenerates the ios/
and android/ directories. This environment has no Xcode/Android Studio
to build and smoke-test those targets, so treat everything under
"Activation checklist" below as required, not optional, before the next
TestFlight/Play build.
Share-to-Mapvest
- Library:
expo-share-intent(5.1.1, matches Expo SDK 54 — see the package's own SDK compatibility table before bumping). Its config plugin generates the iOS Share Extension target and the AndroidACTION_SENDintent filters atexpo prebuildtime — no hand-written native share-extension code lives in this repo. - Config:
apps/ios/app.json→plugins: ["expo-share-intent", {...}]. Accepts images, web URLs/pages, and text; only images run through/v1/identifytoday (see below). - Receiving flow:
apps/ios/app/_layout.tsxmountsShareIntentProviderfrom the very first render but starts itdisabled, flipping the provider'sdisabledoption off after first paint (DeferredShareIntent) so a hung native init cannot black-screen launch. Do NOT go back to conditionally wrappingchildrenin the provider: swapping the element type at that slot 800ms into launch remounted the entire app (query client, session, navigator, MapView) mid share-handoff and crashed "Share to Mapvest" cold-starts.ShareIntentListenerthen routes to/share-intent.apps/ios/app/+native-intent.tsxshort-circuits the extension'smapvest://dataUrl=…wake-up URL straight to/share-intent(andapp/+not-found.tsxcatches anything else) so the router never lands on the Unmatched screen mid-share.- Build 11–42 shipped without the provider (black-screen fix). Those
IPAs will not show Mapvest in the iOS share sheet. Need a build after
this wiring, plus the share-extension target from
expo prebuild. apps/ios/app/share-intent.tsxreads the shared image, runs it through the sameidentifyPhoto()call the Camera tab uses (with a best-effort location fix), and shows the same result card (Save / View details). A shared text/URL with no image shows a friendly "photos only for now" message instead of erroring.
- Outbound half: the detail sheet (
apps/ios/app/detail/[id].tsx) has a header Share button using React Native's built-inShareAPI — the native OS share sheet, so a ticker can be shared right back out to Messages, Notes, Claude, etc. Shared investables use the canonicalhttps://www.mapvest.app/app/ticker/{symbol-or-brand}URL. Thewwwhost serves the landing app directly (the apex currently redirects there), so Apple can fetch its association file without following a redirect. The web app resolves that route for recipients without Mapvest; iOS builds associatemapvest.appand rewrite the same path to native/detail/[id]throughapp/+native-intent.tsx. The AASA file is served fromapps/landing/public/.well-known/apple-app-site-association.
Activation checklist (share)
cd apps/ios && bun install(pullsexpo-share-intent).bunx expo prebuild --clean— regeneratesios//android/with the share extension target and Android intent filters wired in.- If prebuild reports
Config sync failedon the Xcode project mod, see thepatch-packagenote in theexpo-share-intentREADME ("Config sync failed" section) — this is a known issue with some Xcode project versions and is fixed with a small patch, not a code change here. expo run:ios/expo run:androidin a simulator, then use the simulator's Photos app (or a browser) to share an image to Mapvest and confirm/share-intentopens with a result.- Before the next EAS build, check that only one iOS extension target
exists in
app.json/credentials (EAS flags multipleappExtensionsentries — see expo-share-intent's README FAQ). - After deploying the landing app and installing a fresh native build, tap
a
https://www.mapvest.app/app/ticker/AAPLlink from Notes or Messages. It must open AAPL detail in Mapvest; the same URL must remain a useful web ticker page on a device without the app.
Universe summary sharing
The Universe screen's counterfactual summary has a dedicated outbound share
card. It is a fixed 4:5, Mapvest-branded PNG sized for social feeds and
contains only the server-produced summary: the hypothetical basis, find
coverage, value, and change. The card and its text fallback explicitly label
the result hypothetical and include https://mapvest.app; they never include
find photos, precise locations, email addresses, or raw Find records. They also
carry the server's calculation date, returned provider names, and the lowest
returned confidence. An empty source list is labeled uncited/low confidence;
the calculation date is not presented as quote freshness.
apps/ios/src/util/share.ts uses react-native-view-shot@5.1.0 to capture the
off-screen card, then expo-sharing hands the PNG to the native share sheet.
If capture, native sharing, or the module is unavailable, the same summary is
shared as paste-safe text instead. The Share button shows a preparation state
until the off-screen card has laid out and its local brand mark is ready, then
blocks duplicate sheets until the current share attempt resolves.
This native module is activated only in a rebuilt binary. After dependency
changes, run cd apps/ios && bun install && bunx expo prebuild --clean, then
exercise the Universe Share button on an iOS simulator or device. A simulator
run should show the branded PNG in the native share sheet; forcing the
capture path unavailable should still produce the text fallback.
Home-screen widgets
iOS and Android intentionally use different data paths in this release:
- iOS renders one app-authored
WidgetDiscoverySnapshotV1. After Map or List resolves nearby results, the app atomically composes public company cards with the current account's Find, quest, and Sector Dex truth. The WidgetKit extension never carries a bearer token, makes a personalized network join, or independently combines records from different refreshes. - Android retains the existing public
GET /v1/widget/nearbyflow. Its redesign and atomic snapshot parity are deferred; the public endpoint andGET /v1/widget/map-snapshotremain supported for Android and backwards compatibility.
The iOS snapshot contains display-ready tickers, names, sectors, coarse
distances, collection/quest totals, freshness, account scope/epoch, exact
mapvest:/// links, and up to three cited finance sources per card. Each card
shows its categorical confidence and source count; a public comparable for a
private brand is always labeled with ≈$ rather than presented as the brand's
own ticker. A card with no usable citation is forced to low confidence. The
snapshot excludes precise coordinates, bearer tokens, email, photos, provider
credentials, news, and price movement. The native widget marks signed-in
content and all device/map-derived guest content privacy-sensitive so iOS may
redact location-revealing signals when protected widget data is unavailable;
only the explicitly labeled demo area can remain public.
Where the widgets get a location
Widgets can't prompt for GPS permission themselves. Whenever the Map or List
tab gets a location fix, it calls saveLastLocationForWidgets()
(apps/ios/src/widgets/widgetLocation.ts), which persists the origin for the
legacy Android path and the coarse iOS heartbeat. The iOS app separately
turns that context and its resolved nearby response into a display-only
snapshot with a six-hour expiry. A GPS result uses source: "device"; a
user-panned center uses source: "map" and is labeled "Map area" rather than
implying the device is there.
The storage paths are:
- Android: writes to
AsyncStorage— the widget's headless task handler (apps/ios/src/widgets/widget-task-handler.tsx, registered fromapps/ios/index.js) runs in the same JS engine and reads it straight back. - iOS: mirrors both the heartbeat origin and one JSON snapshot string into
the shared App Group (
group.com.mapvest.app.widget) via@bacons/apple-targets'ExtensionStorage. The WidgetKit extension is a separate Swift process with zero JS/RN access;targets/widget/NearbyModels.swiftvalidates the version, card bounds, links, account/epoch match, and expiry before rendering.
A widget with no valid snapshot asks the user to open Mapvest and visit Map. Corrupt or account-mismatched snapshots fail closed. An expired, offline, or permission-denied snapshot may show the matching last-good companies with an explicit stale label; a valid empty result says no signals were resolved in that area and never invents a ticker.
Map/List synchronization reuses the existing TanStack Query caches for Finds, Dex, and quests, applies a 15-second deadline to the personal join, and limits successful per-account WidgetKit reloads to one every 30 seconds. A newer map area invalidates an older in-flight composition. Universe personalization uses the snapshot ID as a compare-and-swap token, so it cannot overwrite a newer proximity frame.
Account and notification lifecycle
WidgetKit location fixes are relayable only after the active account has a successful push registration. The app writes an opaque registration epoch to the App Group; the extension copies that account/epoch onto each captured fix, and the foreground relay rejects missing, mismatched, pre-registration, stale, or future fixes. The visual snapshot has its own account ID and authentication generation epoch. Sign-out invalidates the in-memory writer before serialized cleanup, and every read/write must match the exact supplied account. Account activation is two-phase: the persisted scope changes before the writer commits, so even a failed old-snapshot removal makes the previous frame scope-mismatched and hidden. A same-generation guest transition after boot cleanup remains valid, while late work from the removed account stays blocked. This prevents a guest or later account from seeing, relaying, or contaminating an old account's state.
Confirmed notification opt-out and sign-out stop visit monitoring and clear
the AsyncStorage origin plus the App Group widgetLocationFix, lastLocation,
and registration context. If cleanup cannot be verified, the session remains
in its truthful retry state rather than allowing another account to proceed.
Map and List context
Map and List share one app-side location context through the React Query
tab-state cache. Their precedence is: a linked map origin, the active
Map/device context, a cached map viewport, then the persisted widget origin.
With no usable origin, both screens enter an explicit loading state instead of
querying the demo viewport; neither screen queries it while permission or
origin resolution is pending. If permission is denied or a fix is unavailable
with no selected map/known device origin, the app intentionally enters a
visibly labeled Explore demo area state and may query the configured demo
viewport (currently the San Francisco demo area). A frozen tab adopts the
shared context when it regains focus, avoiding duplicate prompts. Only the
focused screen initiates the first-use permission request; an outgoing tab
invalidates its in-flight application request before another tab can take
over. The platform permission prompt remains system-owned and single; a
blurred tab's eventual application callback is ignored.
When no current foreground fix is available, the app labels visible data
“Explore demo area”, “Map area”, or “Last known location” and offers recovery.
A map/demo context uses Use my location; an unavailable fix uses Try
again; after iOS has denied the prompt, the action becomes Open Settings.
A current GPS fix is labeled “Nearby” / “Your location”; a user pan is labeled
“Map area”. List does not request an independent fallback or silently reset to
San Francisco — it consumes the same context and writes widget origins with the
matching device or map source.
iOS — WidgetKit (apps/ios/targets/widget/)
Built with @bacons/apple-targets
(4.0.7 — pinned below 5.x because that version pulls in an
@expo/prebuild-config version tied to Expo SDK 55, not our SDK 54). Its
config plugin (app.json → "@bacons/apple-targets") auto-discovers every
targets/*/expo-target.config.js and links the directory as a native
Xcode target at expo prebuild time — the Swift files below are hand
written, but the Xcode project wiring, Info.plist, and entitlements are
generated.
expo-target.config.js— target typewidget, App Group entitlement.NearbyModels.swift— native snapshot DTOs plus strict App Group decoding, account/epoch selection, freshness classification, and preview truth.NearbyProvider.swift— sharedTimelineProvider; requests a coarse heartbeat and rereads the app-authored snapshot every 30 minutes without making network requests or combining personalized records.NearbyListWidget.swift— Nearby Dex at small/medium/large sizes: one uncovered/caught target, exact company link, relevance and distance, then quest, Sector Dex, and additional signals as space permits.NearbyMapWidget.swift— Discovery Signals at medium/large sizes: an explicitly non-geographic distance field, closest target, quest, and Dex progress. It does not imply compass direction and never displays an independently fetched map image.DiscoveryWidgetComponents.swift— shared visual hierarchy, textual caught/uncovered status, source confidence, honest public-comparable labels, visible device/map/demo context, accessible labels, progress, and stale freshness.- Android applies the same setup/stale/map-area headers, adjusts visible rows to the widget's current height, and deep-links ticker rows to Detail.
MapvestWidgetBundle.swift—@mainWidgetBundleregistering both.ColorHex.swift— the RN app's palette (apps/ios/src/theme/tokens.ts) mirrored as plain hexColorvalues, so the widget target doesn't need its own Xcode asset-catalog color story.
Deployment target is pinned to iOS 16 in expo-target.config.js. The widgets
avoid an iOS 17-only SwiftUI map, but conditionally adopt
containerBackground(for: .widget) on iOS 17+ and retain the existing
background path on iOS 16. This keeps current WidgetKit rendering valid while
preserving the app's minimum iOS version.
Android — App Widget (apps/ios/src/widgets/)
Built with react-native-android-widget
(0.21.0) — the widget UI is JSX (FlexWidget/TextWidget primitives
rendered to native RemoteViews), not hand-written Kotlin. Android
RemoteViews can't embed a live map surface or an arbitrary bitmap easily
without extra plumbing, so the Android widget is list-only for now; the
"map" experience on Android is the iOS map widget's fallback list, reused.
app.json→["react-native-android-widget", { widgets: [...] }]registers theNearbyWidgetApp Widget provider (30 min update period, resizable) — this generates theAndroidManifest.xmlentry andres/xml/*_widget_info.xmlat prebuild time.apps/ios/index.jsreplaces the defaultexpo-router/entryas the app'smainso the widget's headless task can be registered alongside expo-router's own bootstrapping — the headless task lives outside file-based routes by design.src/widgets/widget-task-handler.tsx— handlesWIDGET_ADDED/WIDGET_UPDATE/WIDGET_RESIZEDby fetchingwidgetData.tsand callingrenderWidget().src/widgets/widgetData.ts— fetch + origin resolution (mirrorsNearbyModels.swift's Swift version).src/widgets/NearbyWidget.tsx— the JSX widget UI itself.
Activation checklist (widgets)
cd apps/ios && bun install(pulls@bacons/apple-targets,react-native-android-widget).- Set
ios.appleTeamIdinapp.jsonbefore an EAS/Xcode build — the plugin warns (does not hard-fail prebuild) without it, but the widget extension target won't code-sign. bunx expo prebuild --clean.- iOS: open
xed ios, confirm the "MapvestWidgets" target builds, add both widget kinds in every supported size, visit Map/List to author a real snapshot, and verify guest, signed-in, empty, stale/offline, denied, corrupt/setup, account-switch, failed-cleanup, and sign-out states plus every deep link. Confirm map/demo headers,≈$comparable labels, confidence/source copy, and lock-screen redaction for account and device/map guest snapshots. - Android:
expo run:android, long-press the home screen → Widgets → "Mapvest Nearby", confirm it renders and updates after visiting the Map or List tab (which seeds the last-known location). - Android/backwards compatibility: hit
GET {API_URL}/v1/widget/nearby?lat=37.7749&lng=-122.4194andGET {API_URL}/v1/widget/map-snapshot?lat=37.7749&lng=-122.4194directly to confirm the deployed API is serving them (the map snapshot 501s untilGOOGLE_MAPS_API_KEYis set on Railway — seedocs/SECRETS.md).
API surface
| Endpoint | Auth | Purpose |
|---|---|---|
GET /v1/widget/nearby | none | Trimmed nearby payload (≤12 items, quotes on top 6 tickers) |
GET /v1/widget/map-snapshot | none | Server-rendered static map PNG; 501 if GOOGLE_MAPS_API_KEY unset |
Both share apps/api/src/lib/nearby-resolve.ts with /v1/nearby — the
Google Places → Overpass → Photon cascade and brand→ticker join are
identical, just capped smaller. Schemas: WidgetNearbyItem /
WidgetNearbyResponse in packages/core/src/schemas. Regenerated into
openapi.yaml / postman.json via bun run openapi && bun run postman
per AGENTS.md §6 — do not hand-edit those files.