diff --git a/firestore.rules b/firestore.rules
index ca2b629..1d34d82 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -22,5 +22,27 @@ service cloud.firestore {
// nobody can delete messages through the client
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
+ // just that one field, same "only these fields can change" pattern as the
+ // echoCount update rule above, so this doc can't be hijacked to store
+ // arbitrary data.
+ match /meta/stats {
+ allow read: if true;
+
+ // first-ever write: only the counter field, and it must be a number
+ allow create: if
+ request.resource.data.keys().hasOnly(['totalMessagesEverPosted']) &&
+ request.resource.data.totalMessagesEverPosted is number;
+
+ // every later write (increment(1)) may only touch that same field
+ allow update: if
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly(['totalMessagesEverPosted']);
+
+ // nobody can delete the counter through the client
+ allow delete: if false;
+ }
}
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 2db436b..b51580c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,8 @@
"@googlemaps/js-api-loader": "^2.1.0",
"@mediapipe/tasks-vision": "^0.10.35",
"firebase": "^12.14.0",
- "ngeohash": "^0.6.3"
+ "ngeohash": "^0.6.3",
+ "qrcode": "^1.5.4"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
@@ -1318,6 +1319,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -1370,6 +1380,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -1397,6 +1416,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -1467,6 +1492,19 @@
}
}
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/firebase": {
"version": "12.14.0",
"resolved": "https://registry.npmjs.org/firebase/-/firebase-12.14.0.tgz",
@@ -1836,6 +1874,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
@@ -1907,6 +1957,51 @@
],
"license": "MIT"
},
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1927,6 +2022,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -1980,6 +2084,89 @@
"node": ">=12.0.0"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1989,6 +2176,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
@@ -2043,6 +2236,12 @@
],
"license": "MIT"
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
@@ -2295,6 +2494,12 @@
"node": ">=0.8.0"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
diff --git a/package.json b/package.json
index 9e83d5f..0248b7e 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"@googlemaps/js-api-loader": "^2.1.0",
"@mediapipe/tasks-vision": "^0.10.35",
"firebase": "^12.14.0",
- "ngeohash": "^0.6.3"
+ "ngeohash": "^0.6.3",
+ "qrcode": "^1.5.4"
}
}
diff --git a/src/lib/Firebase/messages.js b/src/lib/Firebase/messages.js
index 5d5a6b3..233ad55 100644
--- a/src/lib/Firebase/messages.js
+++ b/src/lib/Firebase/messages.js
@@ -1,9 +1,20 @@
-import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; // tools for building and running db queries
+import { collection, query, where, getDocs, addDoc, getCountFromServer } from 'firebase/firestore'; // tools for building and running db queries
import { db, auth } from './config'; // database connection + anonymous auth
-import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
-import { doc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
+import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
+import { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
import ngeohash from 'ngeohash';
+// --- global "memory counter" -----------------------------------------------
+// A single document (meta/stats) holds `totalMessagesEverPosted`: a running
+// total of every message ever created in the whole app, across all areas -
+// including ones that have since expired/decayed and dropped out of
+// getNearbyMessages's "active" results. It's deliberately a single shared
+// doc, separate from individual message documents, because it represents a
+// different thing: not "what's currently visible/active" (which shrinks as
+// messages decay) but "everything that has ever been said here" (which only
+// grows). See addMessage() and getTotalMessageCount() below.
+const statsRef = doc(db, 'meta', 'stats');
+
export async function getNearbyMessages(lat, lng) {
const prefix = getQueryPrefix(lat, lng);
@@ -31,6 +42,31 @@ export async function getNearbyMessages(lat, lng) {
return active;
}
+// --- "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
+// `active`. So `active.length === 0` alone can't tell apart two very
+// different situations: "nobody has EVER posted in this ~1.2km area" vs
+// "people posted here before, but everything since faded/expired".
+// Rather than re-fetching and re-filtering all those documents again, this
+// runs the SAME geohash range query through getCountFromServer - a
+// Firestore aggregation query that returns just a number without
+// transferring any document data, so it stays cheap even though it repeats
+// the query. Returns true if at least one message (active or expired) has
+// ever existed in this area.
+export async function hasAnyMessagesEverNearby(lat, lng) {
+ const prefix = getQueryPrefix(lat, lng);
+
+ const q = query(
+ collection(db, 'messages'),
+ where('geohash', '>=', prefix),
+ where('geohash', '<', prefix + 'z')
+ );
+
+ const snapshot = await getCountFromServer(q);
+ return snapshot.data().count > 0;
+}
+
// update the echo counter
export async function echoMessage(messageId) {
const ref = doc(db, 'messages', messageId);
@@ -40,8 +76,12 @@ export async function echoMessage(messageId) {
});
}
-// adding the message location
-export async function addMessage(lat, lng, text, imageUrl = ''){
+// adding the message location
+// moodColor (optional): the author's picked pastel swatch from ComposeSheet's
+// mood-color row, as an hsl(...) string - or null if they didn't pick one.
+// 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);
await addDoc(collection(db, 'messages'), {
@@ -50,12 +90,68 @@ export async function addMessage(lat, lng, text, imageUrl = ''){
lat,
lng,
geohash,
+ moodColor,
createdAt: serverTimestamp(),
lastEchoAt: serverTimestamp(),
echoCount: 0,
// links the message to this device's persistent anonymous Firebase UID
authorId: auth.currentUser?.uid ?? 'anon'
});
+
+ // --- global "memory counter": increment-only ----------------------------
+ // Same increment(1) pattern as echoCount above, but on the shared
+ // meta/stats doc instead of this message's own doc. setDoc with
+ // { merge: true } both creates meta/stats on the very first call (if it
+ // doesn't exist yet - increment() treats a missing field as starting from
+ // 0) and increments the existing field on every call after that. This
+ // counter never decrements: when a message later expires/decays out of
+ // getNearbyMessages's active results, nothing here is touched, so the
+ // total keeps representing everything ever posted, not just what's
+ // currently visible.
+ // Wrapped in try/catch so a failure here (e.g. security rules not yet
+ // deployed for meta/stats) never breaks the actual posting of the
+ // message above - the counter is a nice-to-have, not core functionality.
+ try {
+ await setDoc(statsRef, { totalMessagesEverPosted: increment(1) }, { merge: true });
+ } catch (err) {
+ console.error('[memory counter] failed to increment meta/stats:', err);
+ }
+}
+
+// fetch the current value of the global "memory counter" (meta/stats), for
+// the GlobalCountPill. Returns 0 if no message has ever been posted yet (the
+// doc won't exist until the very first addMessage() call above), or if the
+// read fails for any reason (e.g. security rules not yet deployed) - the pill
+// simply won't render in that case (see GlobalCountPill's $globalCount check).
+export async function getTotalMessageCount() {
+ try {
+ const snap = await getDoc(statsRef);
+ return snap.exists() ? (snap.data().totalMessagesEverPosted ?? 0) : 0;
+ } catch (err) {
+ console.error('[memory counter] failed to read meta/stats:', err);
+ return 0;
+ }
+}
+
+// --- direct by-ID lookup, for shared links/QR codes (SharePopover.svelte,
+// +page.svelte's auto-open-on-load logic) ----------------------------------
+// getNearbyMessages() above can only ever find messages inside the current
+// device's ~1.2km geohash prefix - a message shared as a link/QR code may
+// have been left anywhere in the world, so opening it can't go through that
+// geohash filter at all. getDoc-by-ID is a direct document lookup that
+// works regardless of geohash, at the cost of needing the exact message ID
+// (which is exactly what the shared link/QR encodes). Returns null (rather
+// than throwing) if the doc doesn't exist or the read fails, so callers can
+// show a graceful "this one has already faded" message instead of an error.
+export async function getMessageById(id) {
+ try {
+ const ref = doc(db, 'messages', id);
+ const snap = await getDoc(ref);
+ return snap.exists() ? { id: snap.id, ...snap.data() } : null;
+ } catch (err) {
+ console.error('[share link] failed to fetch message by id:', err);
+ return null;
+ }
}
// fetch every message this device has authored, for the archive page
diff --git a/src/lib/components/BottomSheet.svelte b/src/lib/components/BottomSheet.svelte
index 0d329dd..d0f7606 100644
--- a/src/lib/components/BottomSheet.svelte
+++ b/src/lib/components/BottomSheet.svelte
@@ -3,6 +3,14 @@
import { getDecayInfo } from '$lib/utils/time.js'
import { echoMessage } from '$lib/firebase/messages.js'
import { playEchoTone } from '$lib/utils/sound.js';
+ import SharePopover from '$lib/components/SharePopover.svelte';
+ import {
+ incrementRead,
+ incrementEchoed,
+ incrementLetGo,
+ hasSeenLastChancePrompt,
+ markLastChancePromptSeen
+ } from '$lib/utils/stats.js';
let { message } = $props();
@@ -10,15 +18,74 @@
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
);
+ // --- "last chance" state ----------------------------------------------
+ // A message is on its genuinely LAST day if it has 1 (or 0) days left
+ // AND isn't already expired/faded - daysLeft <= 1 && !isExpired.
+ // getDecayInfo() derives daysLeft from lastEchoAt, so a message that was
+ // recently echoed already has a much higher daysLeft and won't trigger
+ // this - "hasn't been echoed recently enough to have more time" is
+ // automatically satisfied whenever this condition is true.
+ let isLastChance = $derived(
+ decay ? (decay.daysLeft <= 1 && !decay.isExpired) : false
+ );
+
let echoed = $state(false);
+ // one-time "last chance" prompt for the currently-open message
+ let showLastChancePrompt = $state(false);
+
+ // tracks which message was open last time this effect ran, so the
+ // read-count + prompt logic below only fires once per "open" (not on
+ // every reactive re-run while the same message stays selected)
+ let previousMessageId = null;
+
+ $effect(() => {
+ const currentId = message?.id ?? null;
+
+ if (currentId && currentId !== previousMessageId) {
+ // --- engagement stat: a detail view was just opened ----------
+ incrementRead();
+
+ // --- one-time "last chance" prompt ----------------------------
+ // only show it if this message is CURRENTLY in its last-chance
+ // state AND this device hasn't already seen the prompt for this
+ // message ID. Marking it seen immediately (rather than waiting
+ // for an explicit dismiss) means it won't reappear even if the
+ // user navigates away without closing it.
+ if (isLastChance && !hasSeenLastChancePrompt(currentId)) {
+ showLastChancePrompt = true;
+ markLastChancePromptSeen(currentId);
+ } else {
+ showLastChancePrompt = false;
+ }
+ }
+
+ if (!currentId) {
+ showLastChancePrompt = false; // closed - reset for the next open
+ }
+
+ previousMessageId = currentId;
+ });
+
async function handleEcho() {
await echoMessage(message.id);
echoed = true;
+ // engagement stat: Echo pressed
+ incrementEchoed();
+ // taking action dismisses the last-chance prompt for this message
+ showLastChancePrompt = false;
// gentle ascending "thank you" tone confirming the echo went through
playEchoTone();
}
+ // Let go's behavior is unchanged (just closes the sheet) - this only
+ // adds the engagement-stat increment and prompt dismissal alongside it
+ function handleLetGo() {
+ incrementLetGo();
+ showLastChancePrompt = false;
+ mapStore.set({ selectedMessage: null, composing: false });
+ }
+
let startY = 0; // where the swipe started
function startDrag(e) {
@@ -49,25 +116,43 @@
>
-
+
{#if message.imageUrl}
{/if}
{message.text}
{#if decay}
-
left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.
+ {#if isLastChance}
+
+
this is the last chance to keep this alive
+ {:else}
+
left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.
+ {/if}
{/if}
+
+ {#if showLastChancePrompt}
+
+
+
if this one means something, an echo will keep it here a little longer.
+
+
+ {/if}
+
-
-
{/if}
@@ -81,7 +166,12 @@
right: 0;
background: white;
border-radius: 20px 20px 0 0;
- padding: 1rem 1.5rem 2rem;
+ /* extra 64px of bottom padding = the height of .bottom-nav in
+ +page.svelte (z-index 200, above this sheet's 100), which sits on
+ top of the sheet's bottom edge and was covering the Echo/Let go
+ buttons. pushing the buttons up by that much keeps them clear of
+ the nav bar instead of hidden behind it. */
+ padding: 1rem 1.5rem calc(2rem + 64px);
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
@@ -121,6 +211,58 @@
margin-bottom: 1.2rem;
}
+ /* "last chance" treatment - a soft warm peach tint + border, distinct
+ from the normal white card but staying within the existing pastel/
+ parchment palette. A faint glow (box-shadow) adds a touch of warmth
+ without being a jarring "alert" color like red. */
+ .content.last-chance {
+ background: #fff6ea;
+ border: 1px solid #f3dcb8;
+ border-radius: 14px;
+ padding: 0.9rem;
+ margin: -0.25rem -0.25rem 0;
+ box-shadow: 0 0 0 4px rgba(243, 184, 92, 0.08);
+ }
+
+ /* replaces the normal "fading in X days" meta line when isLastChance */
+ .last-chance-text {
+ color: #b08a5a;
+ font-weight: 500;
+ }
+
+ /* one-time prompt shown above the action buttons - same warm palette as
+ .content.last-chance, slightly more saturated so it reads as the
+ "active" element within the card */
+ .last-chance-prompt {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ background: #fdf0db;
+ border: 1px solid #f3dcb8;
+ border-radius: 10px;
+ padding: 0.6rem 0.75rem;
+ margin-bottom: 0.9rem;
+ font-size: 0.8rem;
+ color: #9c7240;
+ line-height: 1.5;
+ }
+
+ .last-chance-prompt p {
+ margin: 0;
+ }
+
+ .dismiss-prompt {
+ background: none;
+ border: none;
+ color: #c4a37a;
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0;
+ flex-shrink: 0;
+ }
+
.actions {
display: flex;
gap: 0.75rem;
diff --git a/src/lib/components/ComposeSheet.svelte b/src/lib/components/ComposeSheet.svelte
index b531846..b08d4c9 100644
--- a/src/lib/components/ComposeSheet.svelte
+++ b/src/lib/components/ComposeSheet.svelte
@@ -1,6 +1,7 @@
+
+
+{#if $globalCount !== null}
+
+ {$globalCount.toLocaleString()} messages have been left worldwide
+
+
+
+ while not all of them are visible to you or have been echoed long enough to reach you, places remember and memories live on in intangible ways.
+
+
+
+
+ while not all of them are visible to you or have been echoed long enough to reach you, places remember and memories live on in intangible ways.
+
+
+{/if}
+
+
diff --git a/src/lib/components/MapView.Svelte b/src/lib/components/MapView.Svelte
index b0b3245..94e981c 100644
--- a/src/lib/components/MapView.Svelte
+++ b/src/lib/components/MapView.Svelte
@@ -1,36 +1,109 @@
-
+
+{/if}
+
+
diff --git a/src/lib/components/SidePanel.svelte b/src/lib/components/SidePanel.svelte
index 21bfbe1..9772c33 100644
--- a/src/lib/components/SidePanel.svelte
+++ b/src/lib/components/SidePanel.svelte
@@ -9,6 +9,15 @@
import { getDecayInfo } from '$lib/utils/time.js';
import { echoMessage } from '$lib/firebase/messages.js';
import { playEchoTone } from '$lib/utils/sound.js';
+ import { getPresenceText } from '$lib/utils/presence.js';
+ import SharePopover from '$lib/components/SharePopover.svelte';
+ import {
+ incrementRead,
+ incrementEchoed,
+ incrementLetGo,
+ hasSeenLastChancePrompt,
+ markLastChancePromptSeen
+ } from '$lib/utils/stats.js';
// currently-selected message; when set, the detail view is shown instead of the list
let { message } = $props();
@@ -18,6 +27,53 @@
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
);
+ // --- "last chance" state -------------------------------------------------
+ // A message is on its genuinely LAST day if it has 1 (or 0) days left AND
+ // isn't already expired/faded - daysLeft <= 1 && !isExpired. getDecayInfo()
+ // derives daysLeft from lastEchoAt, so a message that was recently echoed
+ // already has a much higher daysLeft and won't trigger this - "hasn't been
+ // echoed recently enough to have more time" is automatically satisfied
+ // whenever this condition is true.
+ let isLastChance = $derived(
+ decay ? (decay.daysLeft <= 1 && !decay.isExpired) : false
+ );
+
+ // one-time "last chance" prompt for the currently-open message
+ let showLastChancePrompt = $state(false);
+
+ // tracks which message was open last time this effect ran, so the
+ // read-count + prompt logic below only fires once per "open" (not on
+ // every reactive re-run while the same message stays selected)
+ let previousMessageId = null;
+
+ $effect(() => {
+ const currentId = message?.id ?? null;
+
+ if (currentId && currentId !== previousMessageId) {
+ // --- engagement stat: a detail view was just opened -----------------
+ incrementRead();
+
+ // --- one-time "last chance" prompt -----------------------------------
+ // only show it if this message is CURRENTLY in its last-chance state
+ // AND this device hasn't already seen the prompt for this message ID.
+ // Marking it seen immediately (rather than waiting for an explicit
+ // dismiss) means it won't reappear even if the user navigates away
+ // without closing it.
+ if (isLastChance && !hasSeenLastChancePrompt(currentId)) {
+ showLastChancePrompt = true;
+ markLastChancePromptSeen(currentId);
+ } else {
+ showLastChancePrompt = false;
+ }
+ }
+
+ if (!currentId) {
+ showLastChancePrompt = false; // closed - reset for the next open
+ }
+
+ previousMessageId = currentId;
+ });
+
// only show the 3 most recent nearby messages, so the list view fits
// without scrolling and the hint card stays visible underneath it
let topMessages = $derived(
@@ -26,6 +82,11 @@
.slice(0, 3)
);
+ // ambient presence indicator ("X people have been here today"), recomputed
+ // whenever the nearby-messages store updates (i.e. whenever
+ // getNearbyMessages refreshes the data)
+ let presenceText = $derived(getPresenceText($messagesStore));
+
// back to the list view
function close() {
mapStore.set({ selectedMessage: null, composing: false });
@@ -33,10 +94,20 @@
async function handleEcho() {
await echoMessage(message.id);
+ // engagement stat: Echo pressed
+ incrementEchoed();
// gentle ascending "thank you" tone confirming the echo went through
playEchoTone();
close(); // back to the list once echoed
}
+
+ // close()'s behavior is unchanged (back to the list) - this only adds the
+ // engagement-stat increment and prompt dismissal alongside it
+ function handleLetGo() {
+ incrementLetGo();
+ showLastChancePrompt = false;
+ close();
+ }