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 @@ + + +
+ {stamp.city} + {stamp.country} +
+Your stamp book is empty.
+Leave a message somewhere new, and a piece of that place will stay here with you.
+