diff --git a/.firebase/hosting.YnVpbGQ.cache b/.firebase/hosting.YnVpbGQ.cache index 2c188a7..03a2429 100644 --- a/.firebase/hosting.YnVpbGQ.cache +++ b/.firebase/hosting.YnVpbGQ.cache @@ -1,21 +1,25 @@ robots.txt,1780149196204,c42e958aa717ecf11e70c927ebe348b7cfdeb317d164463dde44a6245edc25bf -index.html,1781458582579,c238f8f4335a07ba3975ee476ef3d9dc7029dbb4da2414bebea79539edb43ad8 -_app/env.js,1781458582001,3ad3b33040ca005034c918e78bb90257fafb951a2ac1dcb9063c8b3c8af2f638 -_app/immutable/nodes/1.DRGycYkH.js,1781458581476,ddc216c00274522f0163f6e17d7a61a5f83e3e464cc4db9a443d84028c562c60 -_app/version.json,1781458581482,cbb552342fea01555352de88ca2249dc1c7967fe5631dc49cd83e85f37f1574b -_app/immutable/nodes/3.DuWWIOOm.js,1781458581477,26d857c375138b43445caa866442182f628215754f510953baf51b687ecc4262 -_app/immutable/entry/start.BqO-PbzI.js,1781458581475,d82dbd922861c4a44043e136625387f1e0c3abe17c39221d0f5d2f704c27dc75 -_app/immutable/chunks/xihTtKlq.js,1781458581481,6dc2927621fce4ab1f29651cb0fc092849d26bcf1b4e7c03ca68912347351a5e -_app/immutable/chunks/kNaey6uv.js,1781458581480,b2e326f189779bca5f499cc8b1019822fec2ee950002f2208b82d7574d863610 -_app/immutable/entry/app.BKmqOLpp.js,1781458581474,8f657f3df0b7da5e2f354e4d0daaa527dd9154560deb2debb22be3a6d449cc1a -_app/immutable/chunks/dYw4s8FK.js,1781458581480,92bce86c6a19bb7e0519d637325d6b82f8da811e6574b4255feabff9bcdb90cb -_app/immutable/chunks/BqvGMMFS.js,1781458581477,2c51af2452bfec64b3f17ba6d670db004801349cf42953bd1f81c8b40f258999 -_app/immutable/assets/2.B2G-eVjW.css,1781458581481,1c67ba19797be9f8fde6c067517d129820a6cf0d03c38e440bb6d9f9c83568a7 -_app/immutable/assets/3.RINX6wbk.css,1781458581481,8f9a6eebf728dfc5907cb76bd37c871d2d1f334aca214eee962e83896b47578d -_app/immutable/assets/0.DB_w-Orf.css,1781458581481,65816fda749a1c0cca0e0d336ab5ce2e8072226d1011391e0fa3577e8f8d7cd2 -_app/immutable/nodes/0.OUbaqqFA.js,1781458581475,d211915e16706bad6122ec7403c04f7e81e2db36ef347c39cc5dc9d54d67e6da -_app/immutable/chunks/DDp4mPSJ.js,1781458581478,02d46f2be4b0586a43d9f3a29ea25c0c44c22e444da9943f92b1fbb3f0ef4105 -_app/immutable/chunks/Gly99FG6.js,1781458581480,b075ba40b4d6f384b98ffa0ab35b245b03b81877288e357fa3bfb31b24a41e54 -_app/immutable/chunks/D4ShdbHl.js,1781458581478,a5cf19027054fbc801c5e3c64857c30ba4a549222e534629580b035eb730efad -_app/immutable/nodes/2.BnMN9e9a.js,1781458581476,4c5aeb7ba20a00990a6147dfa493edbe74409f93da27b40a312c423455c26013 -_app/immutable/chunks/Dub02Wc7.js,1781458581478,b8f2be198d8c6cab67697e1420f2a9ed824cde281d9c998f1ce056daadd84bf1 +index.html,1781534986659,c4e04f5484da1c956597345e2f98d096d8ae250aaac041cb1579ab8eddd2dad0 +_app/env.js,1781534985969,3ad3b33040ca005034c918e78bb90257fafb951a2ac1dcb9063c8b3c8af2f638 +_app/version.json,1781534985254,51f3700601405b891bb6acf7e8f8713fa82a5f942794780bbc05872e3ae795a9 +_app/immutable/nodes/4.GA9nhugj.js,1781534985239,c7f1ca87a6c0ebdf966bf3c6f725599436d9f99fcafa37281a918d93ca846347 +_app/immutable/nodes/1.lvAGEFLt.js,1781534985237,306b95c5c05905777dcae6eb606e86bb1aabd7f15978e89c11f697ea28e32aee +_app/immutable/nodes/3.Br_mUUSD.js,1781534985239,5d583a20725b7a1325d927c5303dd574b23e358fe897c91522320acc86aa6a8d +_app/immutable/nodes/0.ro2cMSuj.js,1781534985236,3eeddd736b4b5e5ef39c08228d107cb65e24bbc160df89788bc5190ee05c886a +_app/immutable/entry/start.BstLhiV9.js,1781534985236,b8a32b87469c068bd61d051f8a8ea67ae3074460be50dbd7ac7dd1feb7f579fc +_app/immutable/entry/app.DTPvPvz-.js,1781534985234,5250ef8be22692c66cb99eee468949620bcd962566596dcd753d007d909a02db +_app/immutable/chunks/dYw4s8FK.js,1781534985248,92bce86c6a19bb7e0519d637325d6b82f8da811e6574b4255feabff9bcdb90cb +_app/immutable/chunks/xihTtKlq.js,1781534985249,6dc2927621fce4ab1f29651cb0fc092849d26bcf1b4e7c03ca68912347351a5e +_app/immutable/chunks/Clt4wA-V.js,1781534985243,c7aa951803761d5d9911ddea6cc7b4043eee049800a8fae1dca2eb75b7ecf54f +_app/immutable/chunks/BmXl2Dnh.js,1781534985241,acee0daed1317076016cfe5cdefa61436f9f4e6138eb63980898acf623f19243 +_app/immutable/chunks/kNaey6uv.js,1781534985249,b2e326f189779bca5f499cc8b1019822fec2ee950002f2208b82d7574d863610 +_app/immutable/chunks/DOAwxdoU.js,1781534985244,58e7771df8a303c0ec3ad5bf6ccb0c1e5c16447516820a1d2e05873fc4bb63dc +_app/immutable/assets/3.RINX6wbk.css,1781534985253,8f9a6eebf728dfc5907cb76bd37c871d2d1f334aca214eee962e83896b47578d +_app/immutable/chunks/BXRdFrf6.js,1781534985240,b5ad6763a83bf8db5fa75951cfef48c9bd596a3b411a24b624cf1a4ffddd2a7a +_app/immutable/assets/4.DNmkdUsl.css,1781534985254,bf20a1cecff78d2c2566cb760593b117110eeacc701b4c79ee9b897bbdcc2ea5 +_app/immutable/chunks/BqvGMMFS.js,1781534985242,2c51af2452bfec64b3f17ba6d670db004801349cf42953bd1f81c8b40f258999 +_app/immutable/assets/0.DB_w-Orf.css,1781534985251,65816fda749a1c0cca0e0d336ab5ce2e8072226d1011391e0fa3577e8f8d7cd2 +_app/immutable/assets/2.BAt0MrfH.css,1781534985252,7b585e41d74917eac70caacaddcfe19f9760dba8b270cb860b0ca31e485ebb11 +_app/immutable/chunks/z5owE3AB.js,1781534985250,a3cbca6624018231c584f76781735d69f12b229ce8858cbcd219da801737e265 +_app/immutable/nodes/2.g303636_.js,1781534985237,4ab58e678b87cbac73cf937dc61fe562a45db4f74a9ca74a687746d4371a4166 +_app/immutable/chunks/DdlKGuOI.js,1781534985246,03899c735f5705ef92c0abb1351af5ff6a86455552bda7df4907b3cfc3b87228 diff --git a/firestore.rules b/firestore.rules index 1d34d82..86cb06e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -12,7 +12,7 @@ service cloud.firestore { request.resource.data.text is string && request.resource.data.text.size() <= 240; - // the only update allowed is an echo + // the only update allowed is an echo // echoCount and lastEchoAt fields can be changed // this prevents anyone from editing the text of someone else's message allow update: if @@ -23,6 +23,27 @@ service cloud.firestore { allow delete: if false; } + // --- "passport stamps" -------------------------------------------------- + // each anonymous-auth user has their own users/{uid}/stamps/{geohash4} + // subcollection (see firebase/stamps.js) - request.auth.uid == userId + // means a device can only ever read/write its OWN stamps, never anyone + // else's. Stamps are permanent once earned: no update/delete from the + // client, and create is validated the same way the messages create rule + // above validates its fields. + match /users/{userId}/stamps/{stampId} { + allow read: if request.auth != null && request.auth.uid == userId; + + allow create: if + request.auth != null && request.auth.uid == userId && + request.resource.data.keys().hasAll(['geohash4', 'city', 'country', 'iconId', 'color', 'earnedAt']) && + request.resource.data.geohash4 is string && + request.resource.data.city is string && + request.resource.data.country is string; + + allow update: if false; + allow delete: if false; + } + // --- global "memory counter" (GlobalCountPill) ------------------------- // a single shared doc, meta/stats, holding totalMessagesEverPosted. // anyone can read it (it's shown to every visitor); writes are limited to diff --git a/scripts/migrate-geohash-precision.js b/scripts/migrate-geohash-precision.js new file mode 100644 index 0000000..8266438 --- /dev/null +++ b/scripts/migrate-geohash-precision.js @@ -0,0 +1,85 @@ +// scripts/migrate-geohash-precision.js +// +// One-time migration: re-encodes every existing message's `geohash` field at +// precision 9 (previously 6), matching the precision addMessage() now writes +// for new messages (see src/lib/firebase/messages.js). lat/lng/text/etc are +// read from each doc but never written back - updateDoc() below is only ever +// called with { geohash: newGeohash }, so this cannot touch any other field. +// +// Firestore's security rules normally only allow `echoCount`/`lastEchoAt` to +// change via update() (see firestore.rules). Before running this script, +// temporarily add 'geohash' to that allowlist and deploy: +// +// firebase deploy --only firestore:rules +// +// Then run this script: +// +// node scripts/migrate-geohash-precision.js +// +// Then revert firestore.rules back to its original allowlist and redeploy. + +import { initializeApp } from 'firebase/app'; +import { getFirestore, collection, getDocs, doc, updateDoc } from 'firebase/firestore'; +import ngeohash from 'ngeohash'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +// --- load PUBLIC_FIREBASE_* values from .env (same file SvelteKit reads via +// $env/static/public, which only works inside SvelteKit - this script is a +// plain Node script, so .env is parsed by hand here instead) -------------- +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envPath = join(__dirname, '..', '.env'); +const env = {}; +for (const line of readFileSync(envPath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); +} + +const firebaseConfig = { + apiKey: env.PUBLIC_FIREBASE_API_KEY, + authDomain: env.PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: env.PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: env.PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: env.PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: env.PUBLIC_FIREBASE_APP_ID +}; + +const NEW_PRECISION = 9; // matches addMessage() in src/lib/firebase/messages.js + +const app = initializeApp(firebaseConfig); +const db = getFirestore(app); + +const snapshot = await getDocs(collection(db, 'messages')); +console.log(`found ${snapshot.docs.length} message(s)`); + +let updated = 0; +let skipped = 0; + +for (const docSnap of snapshot.docs) { + const data = docSnap.data(); + + if (typeof data.lat !== 'number' || typeof data.lng !== 'number') { + console.warn(`skipping ${docSnap.id}: missing lat/lng`); + skipped++; + continue; + } + + const newGeohash = ngeohash.encode(data.lat, data.lng, NEW_PRECISION); + + if (data.geohash === newGeohash) { + skipped++; + continue; + } + + // ONLY the geohash field is written - everything else on the doc is + // left exactly as it was. + await updateDoc(doc(db, 'messages', docSnap.id), { geohash: newGeohash }); + console.log(`${docSnap.id}: ${data.geohash} -> ${newGeohash}`); + updated++; +} + +console.log(`done. updated ${updated}, skipped ${skipped}`); diff --git a/src/lib/Firebase/messages.js b/src/lib/Firebase/messages.js index 256f1a4..3849822 100644 --- a/src/lib/Firebase/messages.js +++ b/src/lib/Firebase/messages.js @@ -1,8 +1,9 @@ -import { collection, query, where, getDocs, addDoc, getCountFromServer } from 'firebase/firestore'; // tools for building and running db queries +import { collection, query, where, getDocs, addDoc, getCountFromServer, onSnapshot } from 'firebase/firestore'; // tools for building and running db queries import { db, auth } from './config'; // database connection + anonymous auth import { getNearbyGeohashCells } from '$lib/utils/geohash'; // convert coordinates into a 3x3 grid of geohash cell prefixes (handles cell-boundary edge cases - see geohash.js) import { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore'; import ngeohash from 'ngeohash'; +import { checkAndAwardStamp } from './stamps.js'; // "passport stamps" - see stamps.js // --- global "memory counter" ----------------------------------------------- // A single document (meta/stats) holds `totalMessagesEverPosted`: a running @@ -25,7 +26,7 @@ export async function getNearbyMessages(lat, lng) { cells.map(prefix => getDocs(query( collection(db, 'messages'), where('geohash', '>=', prefix), - where('geohash', '<', prefix + 'z') + where('geohash', '<', prefix + '') ))) ); @@ -48,14 +49,71 @@ export async function getNearbyMessages(lat, lng) { const echoTime = message.lastEchoAt?.toMillis() ?? message.createdAt.toMillis(); const daysSinceEcho = (now - echoTime) / (1000 * 60 * 60 * 24); - //console.log(message.text.slice(0, 20), '→ days since echo:', daysSinceEcho); - return daysSinceEcho < 30; // less than 30 means it lives / is active }); return active; } +// --- live "pins pop into existence" listener -------------------------- +// Real-time counterpart to getNearbyMessages() above. Instead of one-time +// getDocs() calls, this opens 9 onSnapshot listeners (the same 3x3 geohash +// cell grid) that keep firing for as long as the subscription is open - +// whenever a message in this area is created, echoed, or ages past the +// 30-day "active" window, onChange() is called again with the freshly +// merged + re-filtered list. +page.svelte uses this to detect messages +// that arrive WHILE the user is actively looking at the map (as opposed +// to the one-time initial load from getNearbyMessages above), so it can +// play the "pop" sound and bounce-animate just those new pins. +// +// The active/expired filter below intentionally mirrors getNearbyMessages +// - kept as its own copy (rather than a shared helper) so this listener's +// merge-on-every-snapshot logic stays self-contained. +// +// Returns an unsubscribe function that tears down all 9 listeners - call +// it when the map/page unmounts. +export function subscribeToNearbyMessages(lat, lng, onChange) { + const cells = getNearbyGeohashCells(lat, lng); + + // each cell's listener writes its latest batch of docs into its own + // slot here; every listener re-merges all 9 slots whenever ANY of them + // fires, so onChange always receives the full up-to-date "nearby" set. + const cellDocs = cells.map(() => []); + + const unsubscribes = cells.map((prefix, i) => + onSnapshot( + query( + collection(db, 'messages'), + where('geohash', '>=', prefix), + where('geohash', '<', prefix + '\uF8FF') + ), + (snapshot) => { + cellDocs[i] = snapshot.docs.map(d => ({ id: d.id, ...d.data() })); + + // merge the 9 cells the same way getNearbyMessages does - + // neighboring cells never overlap, dedupe by id defensively + const seen = new Map(); + for (const docs of cellDocs) { + for (const message of docs) { + seen.set(message.id, message); + } + } + + const now = Date.now(); + const active = [...seen.values()].filter(message => { + const echoTime = message.lastEchoAt?.toMillis() ?? message.createdAt.toMillis(); + const daysSinceEcho = (now - echoTime) / (1000 * 60 * 60 * 24); + return daysSinceEcho < 30; + }); + + onChange(active); + } + ) + ); + + return () => unsubscribes.forEach(unsub => unsub()); +} + // --- "you're the first here" check (+page.svelte) ------------------------- // getNearbyMessages() above already fetches every doc matching the geohash // prefix into `all`, then filters out anything past its 30-day decay into @@ -77,7 +135,7 @@ export async function hasAnyMessagesEverNearby(lat, lng) { cells.map(prefix => getCountFromServer(query( collection(db, 'messages'), where('geohash', '>=', prefix), - where('geohash', '<', prefix + 'z') + where('geohash', '<', prefix + '') ))) ); @@ -99,7 +157,11 @@ export async function echoMessage(messageId) { // stored as-is on the message doc; pins.js falls back to a random pastel // when this is null (see pinColor() in pins.js). export async function addMessage(lat, lng, text, imageUrl = '', moodColor = null){ - const geohash = ngeohash.encode(lat, lng, 6); + // stored at max precision (9, ~5m cells) so getQueryPrefix() in + // geohash.js can use any precision up to 9 without breaking the + // >= / < prefix-range query in getNearbyMessages (a shorter query + // prefix can match a longer stored geohash, but not vice versa). + const geohash = ngeohash.encode(lat, lng, 9); await addDoc(collection(db, 'messages'), { text, @@ -133,6 +195,18 @@ export async function addMessage(lat, lng, text, imageUrl = '', moodColor = null } catch (err) { console.error('[memory counter] failed to increment meta/stats:', err); } + + // --- "passport stamps": award a stamp if this is a new geohash-4 region - + // checkAndAwardStamp (stamps.js) reverse-geocodes lat/lng and writes a + // new stamp doc only if this user has never posted from this ~city-sized + // region before. Wrapped in try/catch like the memory counter above - a + // failed geocode or permission error here should never prevent the + // message itself from posting. + try { + await checkAndAwardStamp(lat, lng); + } catch (err) { + console.error('[stamps] failed to check/award stamp:', err); + } } // fetch the current value of the global "memory counter" (meta/stats), for diff --git a/src/lib/Firebase/stamps.js b/src/lib/Firebase/stamps.js new file mode 100644 index 0000000..877cf52 --- /dev/null +++ b/src/lib/Firebase/stamps.js @@ -0,0 +1,79 @@ +import { db, auth } from './config'; +import { doc, getDoc, setDoc, getDocs, collection, serverTimestamp } from 'firebase/firestore'; +import { getRegionPrefix } from '$lib/utils/geohash.js'; +import { reverseGeocode } from '$lib/utils/geocode.js'; +import { pickStampIcon } from '$lib/utils/stampIcons.js'; +import { stampsStore } from '$lib/stores/stampsStore.js'; + +// same pastel formula as messagePin()'s randomPastel() in pins.js, so stamp +// colors feel consistent with the pin palette - random per stamp (doesn't +// need to be deterministic, unlike the icon below) +function randomStampColor() { + const hue = Math.floor(Math.random() * 360); + return `hsl(${hue}, 60%, 72%)`; +} + +// --- "passport stamps" ------------------------------------------------------ +// Each user/device (anonymous Firebase UID, see userStore.js) earns one +// stamp per distinct geohash-4 region (~20-40km, roughly city-sized - see +// getRegionPrefix in geohash.js) they've ever posted a message from. Stamps +// live at users/{uid}/stamps/{geohash4} - using the geohash-4 prefix itself +// as the document ID means "does this user already have a stamp for this +// region" is a single getDoc by ID, and re-posting from the same region can +// never create a duplicate stamp. +// +// Called from addMessage() (messages.js) after a message successfully posts. +export async function checkAndAwardStamp(lat, lng) { + const uid = auth.currentUser?.uid; + if (!uid) return; // not signed in yet - shouldn't happen since initAuth() runs on app load, but bail safely + + const geohash4 = getRegionPrefix(lat, lng); + const ref = doc(db, 'users', uid, 'stamps', geohash4); + + const existing = await getDoc(ref); + if (existing.exists()) return; // already have a stamp for this region + + // --- reverse geocode: turn these coordinates into a place name --------- + // reverseGeocode() (geocode.js) calls the Google Geocoding API and + // returns { city, country } - falling back to 'Unknown'/'Unknown' if the + // lookup fails, so a failed geocode still earns a (generically-labeled) + // stamp rather than blocking. + const { city, country } = await reverseGeocode(lat, lng); + + // --- deterministic icon, random color ----------------------------------- + // pickStampIcon hashes "City, Country" so the same place always gets the + // same icon; the color is a fresh random pastel per stamp. + const icon = pickStampIcon(city, country); + + const stamp = { + geohash4, + city, + country, + iconId: icon.id, + color: randomStampColor(), + earnedAt: serverTimestamp() + }; + + await setDoc(ref, stamp); + + // keep stampsStore (drives the bottom-nav/desktop stamp-book icons) in + // sync immediately, without a full getStamps() refetch + stampsStore.update(stamps => [...stamps, stamp]); +} + +// fetch every stamp this user has earned, for the stamp book page - also +// refreshes stampsStore so the nav icons reflect the latest count +export async function getStamps() { + const uid = auth.currentUser?.uid; + if (!uid) return []; + + try { + const snapshot = await getDocs(collection(db, 'users', uid, 'stamps')); + const stamps = snapshot.docs.map(d => d.data()); + stampsStore.set(stamps); + return stamps; + } catch (err) { + console.error('[stamps] failed to fetch stamps:', err); + return []; + } +} diff --git a/src/lib/components/MapView.Svelte b/src/lib/components/MapView.Svelte index 7e395e7..c759970 100644 --- a/src/lib/components/MapView.Svelte +++ b/src/lib/components/MapView.Svelte @@ -1,7 +1,8 @@ + + +
+ + {@html icon.paths} + +

+ {stamp.city} + {stamp.country} +

+
+ + diff --git a/src/lib/stores/messagesStore.js b/src/lib/stores/messagesStore.js index ddb36aa..87e136f 100644 --- a/src/lib/stores/messagesStore.js +++ b/src/lib/stores/messagesStore.js @@ -3,6 +3,16 @@ import { playNewPinChime } from '$lib/utils/sound.js'; export const messagesStore = writable([]); // the store will fill up when the page lloads and queries firestore +// --- "pins pop into existence" live-arrival tracking ----------------------- +// Holds the message IDs of pins that just arrived via the real-time listener +// (subscribeToNearbyMessages, messages.js) WHILE the user is actively viewing +// the map - as opposed to pins from the initial getNearbyMessages load/refresh +// (handled by setMessages below, which plays the softer ambient chime +// instead). MapView's renderPins checks this set: any message id in it gets +// the "pop" bounce animation instead of appearing instantly, then clears the +// set so the same pin doesn't re-bounce on the next render. +export const livePinIdsStore = writable(new Set()); + // Replaces the store's contents with a fresh list of nearby messages, and // plays a soft chime if any pin in the new list wasn't present before - // covering both "someone else posted nearby" and "this device's own query diff --git a/src/lib/stores/stampsStore.js b/src/lib/stores/stampsStore.js new file mode 100644 index 0000000..01ed48a --- /dev/null +++ b/src/lib/stores/stampsStore.js @@ -0,0 +1,9 @@ +import { writable } from 'svelte/store'; + +// Holds this device/user's earned "passport stamps" (see firebase/stamps.js). +// Populated by getStamps() on load and appended to live by +// checkAndAwardStamp() whenever a new stamp is earned, so the bottom-nav +// (mobile) and stamp-book button (desktop) in +page.svelte can react to +// $stampsStore.length to switch between their empty/filled icon states +// without needing a refetch. +export const stampsStore = writable([]); diff --git a/src/lib/utils/faceDetection.js b/src/lib/utils/faceDetection.js index 3ca3afc..fb54efc 100644 --- a/src/lib/utils/faceDetection.js +++ b/src/lib/utils/faceDetection.js @@ -5,7 +5,6 @@ let faceDetector = null; export async function loadFaceDetector() { if (faceDetector) return; - console.log('[faceDetection] loading model...'); const vision = await FilesetResolver.forVisionTasks( 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm' ); @@ -16,7 +15,6 @@ export async function loadFaceDetector() { }, runningMode: 'IMAGE' }); - console.log('[faceDetection] model ready'); } // fixed: parameter was named 'File' (capital F) but used as 'file' below — caused ReferenceError @@ -39,6 +37,5 @@ export async function hasFace(file) { const result = faceDetector.detect(img); URL.revokeObjectURL(objectUrl); - console.log('[faceDetection] detections:', result.detections); return result.detections.length > 0; } diff --git a/src/lib/utils/geocode.js b/src/lib/utils/geocode.js new file mode 100644 index 0000000..927cea6 --- /dev/null +++ b/src/lib/utils/geocode.js @@ -0,0 +1,33 @@ +import { env } from '$env/dynamic/public'; + +// --- "passport stamps" reverse geocoding ----------------------------------- +// Reverse-geocodes a lat/lng pair into a { city, country } pair using the +// Google Maps Geocoding API (REST), reusing the same PUBLIC_MAPS_KEY the +// Maps JavaScript API loader already uses (see MapView.svelte) - the +// Geocoding API must be enabled for this key in the Google Cloud Console for +// this to work. +// +// Walks the first result's address_components looking for the 'locality' +// type for the city name (falling back to 'administrative_area_level_1', +// e.g. a state/province, for rural areas with no locality) and the 'country' +// type for the country name. Returns { city: 'Unknown', country: 'Unknown' } +// if the request fails or no results come back, so a failed lookup never +// blocks stamp creation - it just produces a generically-labeled stamp. +export async function reverseGeocode(lat, lng) { + try { + const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${env.PUBLIC_MAPS_KEY}`; + const res = await fetch(url); + const data = await res.json(); + + const components = data.results?.[0]?.address_components ?? []; + const findType = (type) => components.find(c => c.types.includes(type))?.long_name; + + return { + city: findType('locality') ?? findType('administrative_area_level_1') ?? 'Unknown', + country: findType('country') ?? 'Unknown' + }; + } catch (err) { + console.error('[stamps] reverse geocode failed:', err); + return { city: 'Unknown', country: 'Unknown' }; + } +} diff --git a/src/lib/utils/geohash.js b/src/lib/utils/geohash.js index c6557f3..63d664f 100644 --- a/src/lib/utils/geohash.js +++ b/src/lib/utils/geohash.js @@ -1,20 +1,20 @@ import ngeohash from 'ngeohash'; // library that does the geohasing encoding/decoding yippee -// encodes the latitude/longitude pair to a 6 character string geohash (~1.2km radius) -// reverted from 9 -> 6: must match the precision addMessage() writes to Firestore -// (messages.js uses ngeohash.encode(lat, lng, 6)), otherwise stored geohashes -// are shorter than this prefix and the >= / < range query in getNearbyMessages -// never matches anything (this is why pins disappeared) +// encodes the latitude/longitude pair to a 9 character string geohash +// (~5m precision) - this matches the precision addMessage() now stores on +// every message (see messages.js), which is the maximum precision this app +// ever needs, so getQueryPrefix() below always has room to use any shorter +// prefix without breaking the range query. export function encode(lat, lng) { return ngeohash.encode(lat, lng, 9); } -// encodes a lat/lng pair to a 4 character string geohash (~40km radius) used as a Firestore query prefix -// basically like looking for all geohashes that start with this 4 characters -// it will include all geohashes in a ~40km radius of the given lat/lng pair - -// reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes -// stored on existing messages (see encode() above) +// encodes a lat/lng pair to a string geohash used as a Firestore query +// prefix - the "nearby" area shrinks as this precision increases. Safe to +// tune anywhere from 1-9: a shorter prefix can always match a longer stored +// geohash via the >= / < range query in getNearbyMessages, just not the +// other way around (a prefix LONGER than the stored geohash's 9 characters +// would never match anything - same failure mode as before). export function getQueryPrefix(lat, lng) { return ngeohash.encode(lat, lng, 6); } @@ -36,7 +36,27 @@ export function getQueryPrefix(lat, lng) { // other (which covers normal GPS/WiFi error), their 3x3 grids overlap enough // to cover the same messages - so both devices see the same "nearby" set // even if they're each centered in a different cell. +// +// IMPORTANT for callers (messages.js): each cell returned here is used as a +// Firestore "starts with" prefix range, i.e. +// where('geohash', '>=', cell), where('geohash', '<', cell + '\uF8FF') +// '\uF8FF' (not 'z') must be the upper-bound suffix - 'z' is itself a valid +// geohash character (the highest one), so a stored geohash whose very next +// character is also 'z' (e.g. cell "xn774bg" + stored "xn774bgzf") would be +// >= cell + 'z' and get wrongly excluded. '\uF8FF' is far above any geohash +// character, so cell + '\uF8FF' always matches every geohash starting with cell. export function getNearbyGeohashCells(lat, lng) { const center = getQueryPrefix(lat, lng); return [center, ...ngeohash.neighbors(center)]; +} + +// --- "passport stamps" region key ------------------------------------------ +// encodes a lat/lng pair to a 4 character geohash prefix (~20-40km, +// roughly city-sized). Used by stamps.js to group messages into distinct +// "regions" a user has posted from - much coarser than getQueryPrefix's +// "nearby" cells above, which is the point: someone shouldn't earn a new +// stamp just for moving a few blocks, only for posting from a genuinely +// different area. +export function getRegionPrefix(lat, lng) { + return ngeohash.encode(lat, lng, 4); } \ No newline at end of file diff --git a/src/lib/utils/sound.js b/src/lib/utils/sound.js index b528a94..c603f85 100644 --- a/src/lib/utils/sound.js +++ b/src/lib/utils/sound.js @@ -163,3 +163,62 @@ export function playEchoTone() { playTone(ctx, frequency, startTime, noteDuration, peakGain); }); } + +// --------------------------------------------------------------------- +// Feature 3: live "pin pop" sound +// --------------------------------------------------------------------- +// A short, bright, whimsical "boop-beep" played when a pin appears in real +// time WHILE the user is actively looking at the map (see +// subscribeToNearbyMessages in messages.js + livePinIdsStore in +// messagesStore.js) - distinct from playNewPinChime()'s single soft ambient +// "ding" (page loads/refreshes/moving) and playEchoTone()'s slower 3-note +// rising arpeggio (echoing a message). +export function playPinPopSound() { + const ctx = getAudioContext(); + if (!ctx) return; // SSR or AudioContext unavailable - do nothing + + const now = ctx.currentTime; + + // Two quick ascending notes (E6 -> G6, a rising minor third) read as a + // playful little "boop-beep" chime rather than a flat pop - the rising + // pitch is what gives it a cute/tactile "ta-da!" character. + const notes = [1318.51, 1567.98]; // E6, G6 in Hz + + // The second note starts before the first has fully decayed (50ms apart, + // each lasting 100ms) - that overlap is what makes the pair feel like one + // light, bouncy gesture instead of two separate beeps. Total ~0.15s, well + // under playEchoTone's 0.53s arpeggio. + const noteSpacing = 0.05; + const noteDuration = 0.1; + + // Near-instant attack (5ms) keeps each note feeling crisp/percussive + // rather than ringing, like a tiny bell tap. + const attackTime = 0.005; + + // Slightly louder than the ambient chime's peak (0.06) so this registers + // as a distinct little "reward", but still short enough to never feel + // jarring even with two overlapping notes. + const peakGain = 0.07; + + // 'triangle' has richer harmonics than playTone's 'sine', giving this a + // brighter, more sparkly/bell-like timbre than the pure ambient chime. + notes.forEach((frequency, index) => { + const startTime = now + index * noteSpacing; + + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.type = 'triangle'; + oscillator.frequency.value = frequency; + + gainNode.gain.setValueAtTime(0.0001, startTime); + gainNode.gain.exponentialRampToValueAtTime(peakGain, startTime + attackTime); + gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + noteDuration); + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + oscillator.start(startTime); + oscillator.stop(startTime + noteDuration); + }); +} diff --git a/src/lib/utils/stampIcons.js b/src/lib/utils/stampIcons.js new file mode 100644 index 0000000..4c34c1e --- /dev/null +++ b/src/lib/utils/stampIcons.js @@ -0,0 +1,37 @@ +// --- "passport stamps" icon set --------------------------------------------- +// Curated set of simple outline icons (lucide-style: 24x24 viewBox, +// stroke-based, no fill) representing generic "places" - the centerpiece of +// each passport stamp badge (see Stamp.svelte). `paths` is the inner SVG +// markup, dropped directly into Stamp.svelte's wrapper via {@html}. +export const STAMP_ICONS = [ + { id: 'mountain', paths: '' }, + { id: 'building', paths: '' }, + { id: 'tree', paths: '' }, + { id: 'wave', paths: '' }, + { id: 'star', paths: '' }, + { id: 'leaf', paths: '' }, + { id: 'sun', paths: '' }, + { id: 'moon', paths: '' }, + { id: 'landmark', paths: '' }, + { id: 'compass', paths: '' } +]; + +// Deterministic string hash (DJB2 variant) - the same input string always +// produces the same number, which is what lets the same place always pick +// the same icon (see pickStampIcon below). +function hashString(str) { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return Math.abs(hash); +} + +// Picks an icon for a place deterministically: "City, Country" always maps +// to the same icon, so earning a stamp for the same city twice (hypothetically) +// would always show the same icon - without needing to store any extra +// city->icon mapping. +export function pickStampIcon(city, country) { + const index = hashString(`${city}, ${country}`) % STAMP_ICONS.length; + return STAMP_ICONS[index]; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 536f05e..cb4830d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -78,11 +78,11 @@ block or delay anything underneath. --> {@render children()} - -{#if $page.url.pathname !== '/archive'} + +{#if $page.url.pathname !== '/archive' && $page.url.pathname !== '/stampbook'} {/if} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 77ced9e..ae747b7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,11 +2,14 @@ import { onMount } from 'svelte'; import MapView from '$lib/components/MapView.svelte'; - import { getNearbyMessages, hasAnyMessagesEverNearby, getMessageById } from '$lib/firebase/messages.js'; + import { getNearbyMessages, hasAnyMessagesEverNearby, getMessageById, subscribeToNearbyMessages } from '$lib/firebase/messages.js'; import { getDecayInfo } from '$lib/utils/time.js'; // checks whether a shared message has expired - import { messagesStore, setMessages } from '$lib/stores/messagesStore.js'; + import { messagesStore, setMessages, livePinIdsStore } from '$lib/stores/messagesStore.js'; + import { playPinPopSound } from '$lib/utils/sound.js'; // "pin pop into existence" live-arrival sound import { mapStore } from '$lib/stores/mapStore.js'; - import { initAuth } from '$lib/stores/userStore.js'; + import { initAuth, userStore } from '$lib/stores/userStore.js'; + import { getStamps } from '$lib/firebase/stamps.js'; // "passport stamps" - see stamps.js + import { stampsStore } from '$lib/stores/stampsStore.js'; // drives the empty/filled stamp-book icon below import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab import { replaceState } from '$app/navigation'; // clears the ?message= param after handling a shared link @@ -19,6 +22,13 @@ let lng = $state(); let error = $state(); + // handle for the real-time "pins pop into existence" listener + // (subscribeToNearbyMessages, messages.js) - assigned once the initial + // load finishes (see onMount below) and called on unmount to tear down + // its 9 onSnapshot listeners. Plain variable, not a rune: it's only ever + // read in the onMount cleanup function, never rendered. + let unsubscribeLive; + let windowWidth = $state(0); let isMobile = $derived(windowWidth < 768); @@ -65,7 +75,6 @@ // setMessages (instead of messagesStore.set) compares against the // previous list and plays a soft chime for any newly-appeared pins setMessages(messages); - console.log('messages loaded:', $messagesStore); // --- "you're the first here" zero-results check -------- // getNearbyMessages() already filters out anything past its @@ -86,16 +95,64 @@ firstHereMounted = true; } } + + // --- "pins pop into existence": live listener ----------- + // Now that the initial set has loaded, open a real-time + // listener for the same area. `knownIds` is every message id + // already on the map from the load above - it's the + // boundary between "already here" and "arrived while I was + // watching". Only ids NOT in `knownIds` get the pop sound + + // bounce animation (see livePinIdsStore, MapView.svelte's + // renderPins); `knownIds` then grows to include them, so + // they don't "arrive" a second time on the next snapshot. + const knownIds = new Set(messages.map((m) => m.id)); + + unsubscribeLive = subscribeToNearbyMessages( + position.coords.latitude, + position.coords.longitude, + (liveMessages) => { + const newIds = liveMessages + .filter((m) => !knownIds.has(m.id)) + .map((m) => m.id); + + if (newIds.length > 0) { + newIds.forEach((id) => knownIds.add(id)); + playPinPopSound(); + livePinIdsStore.set(new Set(newIds)); + } + + // replace wholesale (not setMessages) - this also + // picks up live updates to existing messages (e.g. + // echoCount from someone else echoing) without + // re-triggering the ambient "new pin" chime, which + // setMessages would do for the same ids + messagesStore.set(liveMessages); + } + ); } ); // cleanup: undo the body overflow lock when this page unmounts - // (e.g. navigating to /archive), so other routes can scroll normally. + // (e.g. navigating to /archive), so other routes can scroll normally, + // and tear down the live listener above if it was ever set up. return () => { document.body.style.overflow = ''; + unsubscribeLive?.(); }; }); + // --- "passport stamps": load this user's earned stamps once signed in -- + // initAuth() (called above) signs this device in anonymously and updates + // userStore asynchronously - this effect waits for userStore.ready and + // then fetches this user's stamps, populating stampsStore so the + // bottom-nav/desktop stamp-book icons below know whether to show their + // empty or filled state. + $effect(() => { + if ($userStore.ready) { + getStamps(); + } + }); + // Fade-in + auto-dismiss timing for the "first here" message - mirrors // the opening ritual overlay's pattern in +layout.svelte: wait a frame so // the element first paints at opacity 0, then fade in; after a short @@ -255,8 +312,29 @@ {/if} - -{#if !$mapStore.composing && !$mapStore.selectedMessage} + +{#if windowWidth >= 768} + 0} aria-label="Your stamp book" title="Your stamp book"> + + + 0 ? 0.15 : 0}/> + + + + + +{/if} + + +{#if !$mapStore.composing && (!$mapStore.selectedMessage || !isMobile)} Archive + + + + 0 ? 0.15 : 0}/> + + + + + Stamps + {/if} @@ -371,6 +461,35 @@ } } +/* desktop-only "passport stamps" entry point, stacked directly above the FAB + (56px tall + the FAB's 1.5rem bottom offset + a small gap) - smaller and + more muted than the FAB since it's a secondary action */ +.stamp-book-btn { + position: fixed; + right: 1.5rem; + bottom: calc(1.5rem + 56px + 0.75rem); + width: 44px; + height: 44px; + border-radius: 50%; + background: white; + border: 1px solid rgba(0, 0, 0, 0.08); + color: #bbb; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + text-decoration: none; + z-index: 150; + transition: color 0.15s, border-color 0.15s; +} + +/* once at least one stamp is earned, tint the button the same purple used + for .nav-item.active / .echo-button elsewhere in the app */ +.stamp-book-btn.has-stamps { + color: #c4a8f5; + border-color: rgba(196, 168, 245, 0.4); +} + /* mobile-only bottom nav bar with Map/Archive links */ .bottom-nav { position: fixed; diff --git a/src/routes/stampbook/+page.svelte b/src/routes/stampbook/+page.svelte new file mode 100644 index 0000000..4e6b4e4 --- /dev/null +++ b/src/routes/stampbook/+page.svelte @@ -0,0 +1,131 @@ + + +
+
+ ← Back to map +

Your stamp book

+

A passport of everywhere you've left something behind

+
+ + {#if loading} +
Loading your stamps...
+ {:else if stamps.length === 0} + +
+

Your stamp book is empty.

+

Leave a message somewhere new, and a piece of that place will stay here with you.

+
+ {:else} +
+ {#each stamps as stamp} +
+ +
+ {/each} +
+ {/if} +
+ +