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} message attachment {/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} + +{/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 firstHereMounted} + + {/if} + + + {#if sensingMode && arrowRotation !== null && !$mapStore.selectedMessage} + + {/if} +
\ No newline at end of file diff --git a/src/lib/components/SensingToggle.svelte b/src/lib/components/SensingToggle.svelte new file mode 100644 index 0000000..b7f56cf --- /dev/null +++ b/src/lib/components/SensingToggle.svelte @@ -0,0 +1,63 @@ + + + + + + diff --git a/src/lib/components/SharePopover.svelte b/src/lib/components/SharePopover.svelte new file mode 100644 index 0000000..5627a8f --- /dev/null +++ b/src/lib/components/SharePopover.svelte @@ -0,0 +1,230 @@ + + + + + +{#if open} + +
+ + + + +
+{/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(); + }
@@ -44,29 +115,50 @@ - {#if message.imageUrl} - message attachment - {/if} +
+ {#if message.imageUrl} + message attachment + {/if} -

{message.text}

+

{message.text}

- {#if decay} -

left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.

- {/if} + {#if decay} + {#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} +
- + +
{:else}

Overheard: Shared Secrets

-

Messages near you

+ +

{presenceText}


@@ -115,6 +207,13 @@

Messages appear as you explore

Tap the confetti on the map to read them

+ + + + Your archive + {/if}
@@ -183,6 +282,58 @@ color: #999; } + /* "last chance" treatment - a soft warm peach tint + border, distinct from + the normal panel background 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. */ + .detail-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 + .detail-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-top: 0.75rem; + 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; @@ -248,6 +399,30 @@ background: #b090e8; } + /* same pill-button look as .compose-btn, but on an tag, so it needs + its own block/text-align/text-decoration resets to read as a button + rather than an inline link */ + .archive-btn { + width: 100%; + box-sizing: border-box; + display: block; + text-align: center; + text-decoration: none; + padding: 0.75rem; + background: #c4a8f5; + color: white; + border-radius: 50px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + /* flat 2D style for now - shadow removed, paper-style shadows may be added later */ + } + + .archive-btn:hover { + background: #b090e8; + } + .section-label { font-size: 0.7rem; font-weight: 600; diff --git a/src/lib/stores/globalCountStore.js b/src/lib/stores/globalCountStore.js new file mode 100644 index 0000000..367a1e9 --- /dev/null +++ b/src/lib/stores/globalCountStore.js @@ -0,0 +1,17 @@ +import { writable } from 'svelte/store'; +import { getTotalMessageCount } from '$lib/firebase/messages.js'; + +// Shared, app-wide value for the "memory counter" pill (GlobalCountPill.svelte) - +// the live total from meta/stats (see messages.js for the increment side). +// Starts at `null` (not yet loaded) rather than 0, so the pill can wait to +// render until the first real value arrives instead of flashing +// "0 messages have been left worldwide" before the fetch resolves. +export const globalCount = writable(null); + +// Re-fetches the counter from Firestore and updates the shared store. Called +// once on initial load (GlobalCountPill's onMount) and again after this +// device successfully posts a message (ComposeSheet), so the number "feels +// alive" without needing a full page reload. +export async function refreshGlobalCount() { + globalCount.set(await getTotalMessageCount()); +} diff --git a/src/lib/utils/geo.js b/src/lib/utils/geo.js new file mode 100644 index 0000000..d22ee37 --- /dev/null +++ b/src/lib/utils/geo.js @@ -0,0 +1,43 @@ +// --- geo utilities for "sensing mode" (compass arrow + proximity vibration) - +// Both formulas are standard great-circle formulas. They're approximations +// (Earth isn't a perfect sphere), but for the distances this app cares about +// - "is this pin within ~50m" and "which direction is it in" within a single +// ~1.2km neighborhood - that error is negligible. + +const EARTH_RADIUS_M = 6371000; // mean Earth radius, in meters + +function toRad(deg) { + return (deg * Math.PI) / 180; +} + +function toDeg(rad) { + return (rad * 180) / Math.PI; +} + +// Haversine formula: great-circle distance between two lat/lng points, in +// meters. Used by the proximity-vibration check to find pins within ~50m. +export function distanceMeters(lat1, lng1, lat2, lng2) { + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + + return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +// Initial bearing (compass direction in degrees, 0-360, 0 = true north, +// clockwise) from point 1 to point 2. Used by the compass arrow to figure +// out which way the nearest off-screen pin is, before adjusting for the +// device's own heading. +export function bearingDegrees(lat1, lng1, lat2, lng2) { + const phi1 = toRad(lat1); + const phi2 = toRad(lat2); + const dLng = toRad(lng2 - lng1); + + const y = Math.sin(dLng) * Math.cos(phi2); + const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLng); + + return (toDeg(Math.atan2(y, x)) + 360) % 360; +} diff --git a/src/lib/utils/geohash.js b/src/lib/utils/geohash.js index 6230e5c..477162d 100644 --- a/src/lib/utils/geohash.js +++ b/src/lib/utils/geohash.js @@ -6,7 +6,7 @@ import ngeohash from 'ngeohash'; // library that does the geohasing encoding/dec // are shorter than this prefix and the >= / < range query in getNearbyMessages // never matches anything (this is why pins disappeared) export function encode(lat, lng) { - return ngeohash.encode(lat, lng, 6); + 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 @@ -16,5 +16,5 @@ export function encode(lat, lng) { // reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes // stored on existing messages (see encode() above) export function getQueryPrefix(lat, lng) { - return ngeohash.encode(lat, lng, 4); + return ngeohash.encode(lat, lng, 6); } \ No newline at end of file diff --git a/src/lib/utils/mapStyles.js b/src/lib/utils/mapStyles.js new file mode 100644 index 0000000..e7b9daa --- /dev/null +++ b/src/lib/utils/mapStyles.js @@ -0,0 +1,98 @@ +// Custom Google Maps style array. Desaturates the base map to near- +// grayscale so the only color on screen comes from the user-left pastel/ +// mood-color pins - reinforcing the app's "memory and time" theme: the +// physical world fades to monochrome, and the messages people left behind +// are what still carry color. +// +// IMPORTANT: rules are applied in array order, and later rules override +// earlier ones for the same featureType/elementType. Several rules below +// rely on this - a broad rule (e.g. "hide all POIs") comes first, then a +// more specific rule for a subtype (e.g. "poi.park") comes after to +// override it just for that subtype. Reordering these can change the result. +export const mapStyles = [ + // 1. Desaturate EVERYTHING by default - not just geometry (roads, land, + // water, parks, admin areas), but also labels and icons. This is what + // turns Google's default colored highway-shield icons (blue/yellow + // route badges) and green park icons/text grayscale too. Per-feature + // rules below layer on top of this - e.g. water gets an explicit + // color later, which overrides this for water specifically. + { + elementType: 'all', + stylers: [{ saturation: -100 }] + }, + + // 2. Hide ALL points-of-interest - icons AND labels - for generic + // businesses (restaurants, shops, etc). This is the "declutter" + // baseline; specific notable categories are turned back on below. + { + featureType: 'poi', + elementType: 'all', + stylers: [{ visibility: 'off' }] + }, + + // 3. Re-enable icons + labels for parks and schools specifically - these + // are "prominent"/notable landmarks that help with orientation, unlike + // generic businesses. Comes after rule 2 so it overrides it for just + // these two subtypes. + { + featureType: 'poi.park', + elementType: 'all', + stylers: [{ visibility: 'on' }] + }, + { + featureType: 'poi.school', + elementType: 'all', + stylers: [{ visibility: 'on' }] + }, + + // 4. Hide transit (bus/subway/rail) icons and labels entirely - transit + // info isn't relevant to a map about messages left at a location. + { + featureType: 'transit', + elementType: 'all', + stylers: [{ visibility: 'off' }] + }, + + // 5. Hide labels on minor/local streets - at the app's default zoom + // level these cover the map in small text. Arterial/highway road + // labels are left at their default (visible) so major roads still + // read for orientation - no rule needed for those, default = visible. + { + featureType: 'road.local', + elementType: 'labels', + stylers: [{ visibility: 'off' }] + }, + + // 6. Water gets a soft, muted blue-grey instead of plain desaturated + // grayscale - just enough color to read as "water" against the land, + // without breaking the overall desaturated look or competing with + // the pastel pins. This explicit `color` overrides rule 1's + // saturation tweak for water's geometry specifically. + { + featureType: 'water', + elementType: 'geometry', + stylers: [{ color: '#cfd8dc' }] + }, + + // 7. Hide administrative boundary lines (country/province/county) - + // mostly visual clutter for a hyperlocal "messages near you" map. + { + featureType: 'administrative', + elementType: 'geometry', + stylers: [{ visibility: 'off' }] + }, + // 8. Hide administrative labels generally... + { + featureType: 'administrative', + elementType: 'labels', + stylers: [{ visibility: 'off' }] + }, + // ...but bring back locality (city/town) labels specifically - knowing + // what city/area you're in is basic orientation worth keeping, even + // though the surrounding boundary lines are hidden by rule 7. + { + featureType: 'administrative.locality', + elementType: 'labels', + stylers: [{ visibility: 'on' }] + } +]; diff --git a/src/lib/utils/pins.js b/src/lib/utils/pins.js index ef5e453..9bec14a 100644 --- a/src/lib/utils/pins.js +++ b/src/lib/utils/pins.js @@ -8,64 +8,49 @@ function randomPastel() { }; } -// turns an SVG string into a DOM element sized to `size` x `size` px. -// AdvancedMarkerElement (the marker API this app uses) takes a DOM node -// via its `content` option, not an icon/url like the older google.maps.Marker, -// so we inline the SVG as a data URL on an instead of returning an icon object. -function svgToElement(svg, size) { - const img = document.createElement('img'); - img.src = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg); - img.style.width = `${size}px`; - img.style.height = `${size}px`; - // AdvancedMarkerElement anchors content by its bottom-center by default - // (made for teardrop pins). Shift up by half the height so the *center* - // of our shapes lands on the actual coordinate instead. - img.style.transform = 'translateY(-50%)'; - // flat 2D style for now (no drop-shadow) - paper-style shadows may be added later - return img; +// picks the fill color for a pin: if the message has a moodColor (an +// optional pastel color the author picked in ComposeSheet's swatch row, +// stored as an hsl(...) string), use that directly so the pin matches what +// they chose. otherwise fall back to the existing random pastel exactly as +// before. `glow` isn't currently rendered by any pin shape below, but is +// still returned for shape consistency with randomPastel(). +function pinColor(moodColor) { + if (moodColor) { + return { solid: moodColor, glow: moodColor }; + } + return randomPastel(); } -// star pin for unread messages: solid pastel star, no glow halo, flat fill -export function unreadPin() { - const { solid } = randomPastel(); - const svg = ` - - - `; - // sized to match locationPin (32px) per latest request, so message pins - // are no longer larger than the current-location marker - return svgToElement(svg, 46); +// turns an SVG string into a { url, size } pair for google.maps.Marker's +// `icon` option. Markers (the legacy marker API this app now uses, switched +// back from AdvancedMarkerElement so a JS map `styles` array can take +// effect - AdvancedMarkerElement requires a mapId, which causes Google to +// ignore any `styles` array) take an Icon object - { url, scaledSize, anchor } +// - rather than a DOM node, so we just return the data-URL + size here and +// let MapView build the google.maps.Size/Point objects (those classes only +// exist once the Maps JS API has loaded). +// flat 2D style for now (no drop-shadow) - paper-style shadows may be added later +function svgToIcon(svg, size) { + return { + url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg), + size + }; } -// circle pin for read messages: solid pastel perfect circle, no glow halo -export function readPin() { - const { solid } = randomPastel(); +// pin for every message: solid pastel perfect circle, no glow halo. +// previously there were 3 shapes (star/circle/heart) for unread/read/echoed +// messages, swapped on click - now every message pin looks the same, so +// clicking a pin no longer needs to change its appearance. +// moodColor (optional): the author's picked swatch color, if any - see pinColor() above +export function messagePin(moodColor) { + const { solid } = pinColor(moodColor); const svg = ` `; // sized to match locationPin (32px) per latest request, so message pins // are no longer larger than the current-location marker - return svgToElement(svg, 28); -} - -// heart pin for echoed messages: solid pastel heart, no glow halo -export function echoedPin() { - const { solid } = randomPastel(); - // replaced the old single-point teardrop path with an actual two-lobed - // heart: a notch/cleft at the top center (28,17) and a point at the - // bottom (28,46) - // dropped the white inner-highlight overlay - it gave the heart a - // ring/outline look instead of a solid fill, which the user didn't want - const svg = ` - - - `; - // sized to match locationPin (32px) per latest request, so message pins - // are no longer larger than the current-location marker - return svgToElement(svg, 32); + return svgToIcon(svg, 28); } // fixed dark dot used for the user's own location (not randomized, no glow) @@ -75,5 +60,5 @@ export function locationPin() { `; - return svgToElement(svg, 32); + return svgToIcon(svg, 32); } diff --git a/src/lib/utils/presence.js b/src/lib/utils/presence.js new file mode 100644 index 0000000..50d650e --- /dev/null +++ b/src/lib/utils/presence.js @@ -0,0 +1,69 @@ +// Ambient "X people have been here today" presence indicator. +// +// This is intentionally derived entirely from data getNearbyMessages() +// already returns (authorId, createdAt, lastEchoAt, echoCount) - no extra +// Firestore queries or schema changes are needed. The result is meant to +// feel like ambient atmosphere, not a precise analytic, so a reasonable +// approximation (explained below) is fine. + +const DAY_MS = 24 * 60 * 60 * 1000; + +// Counts "unique presences" in the last 24 hours across two activity types: +// +// 1. POSTS - any message whose `createdAt` is within the last 24h counts +// its `authorId` toward a Set, so one person posting several messages +// today still only counts as one presence. +// +// 2. ECHOES - the data model has no per-echo log (no list of who echoed or +// when each individual echo happened), only a running `echoCount` and a +// single `lastEchoAt` timestamp per message. We can't recover *who* +// echoed or *how many distinct people* echoed a given message from that. +// +// APPROXIMATION: a message whose `lastEchoAt` falls within the last 24h +// (and that wasn't itself posted in the last 24h - see below) is counted +// as ONE additional presence, representing "someone engaged with this +// message today". This undercounts cases where multiple different people +// echoed the same message today (they'd only add 1, not N), but avoids +// needing a new subcollection/security rules just for an ambient number. +// Given the feature is explicitly "a feeling, not a precise count", this +// tradeoff is acceptable. +// +// Double-counting guard: addMessage() sets `lastEchoAt = createdAt` on a +// brand new message, so a message posted today would otherwise satisfy both +// the "posted today" and "echoed today" checks. The `!postedToday` guard +// below ensures a fresh post is only counted once (as a post). +export function getPresenceCount(messages) { + const now = Date.now(); + + const postedTodayAuthorIds = new Set(); + let echoedTodayCount = 0; + + for (const message of messages) { + const createdAtMs = message.createdAt?.toMillis() ?? 0; + const lastEchoAtMs = message.lastEchoAt?.toMillis() ?? createdAtMs; + + const postedToday = now - createdAtMs < DAY_MS; + const echoedToday = now - lastEchoAtMs < DAY_MS; + + if (postedToday) { + // dedupe posters by authorId - same person posting multiple + // messages today is still just one "presence" + postedTodayAuthorIds.add(message.authorId ?? message.id); + } else if (echoedToday) { + // see APPROXIMATION above: counts as one presence regardless of + // how many times this message was echoed or by how many people + echoedTodayCount += 1; + } + } + + return postedTodayAuthorIds.size + echoedTodayCount; +} + +// Formats the count into the subtitle copy, handling the singular/zero cases. +export function getPresenceText(messages) { + const count = getPresenceCount(messages); + + if (count === 0) return 'No one has been here today'; + if (count === 1) return '1 person has been here today'; + return `${count} people have been here today`; +} diff --git a/src/lib/utils/stats.js b/src/lib/utils/stats.js new file mode 100644 index 0000000..4009767 --- /dev/null +++ b/src/lib/utils/stats.js @@ -0,0 +1,91 @@ +// --- Personal engagement stats -------------------------------------------- +// Three simple action counters - messages read (detail view opened), echoed, +// and let go - stored only in localStorage on this device. Like trail.js, +// this is purely local: never sent to Firestore, never attached to a +// message, never shared. It's a private mirror of how THIS device has moved +// through the app (read vs. acted), shown as a summary line on the archive +// page. + +const STATS_KEY = 'overheard_stats'; + +const DEFAULT_STATS = { read: 0, echoed: 0, letGo: 0 }; + +// Reads the stats object from localStorage, merged over the all-zero +// defaults so older/partial stored objects (or a first-ever read with +// nothing stored yet) still have all three keys. Falls back to defaults +// entirely if localStorage is unavailable (SSR) or the value can't be parsed. +export function getStats() { + if (typeof localStorage === 'undefined') return { ...DEFAULT_STATS }; + + try { + const raw = localStorage.getItem(STATS_KEY); + return raw ? { ...DEFAULT_STATS, ...JSON.parse(raw) } : { ...DEFAULT_STATS }; + } catch { + return { ...DEFAULT_STATS }; + } +} + +// Increments one counter by 1 and persists the whole stats object back to +// localStorage. Internal helper - see the three named exports below. +function incrementStat(key) { + const stats = getStats(); + stats[key] = (stats[key] ?? 0) + 1; + + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(STATS_KEY, JSON.stringify(stats)); + } catch { + // localStorage unavailable/full - nothing more we can do here + } + } + + return stats; +} + +// called when a message detail view is opened (BottomSheet/SidePanel) +export const incrementRead = () => incrementStat('read'); +// called when the Echo button is pressed +export const incrementEchoed = () => incrementStat('echoed'); +// called when the Let go button is pressed +export const incrementLetGo = () => incrementStat('letGo'); + +// --- "last chance" one-time prompt tracking -------------------------------- +// Tracks which message IDs have already shown the "last chance" prompt (see +// BottomSheet.svelte / SidePanel.svelte) so it appears at most once per +// message, per device - reopening the same message later (while it's still +// on its last day) won't show it again. + +const LAST_CHANCE_SEEN_KEY = 'overheard_last_chance_seen'; + +function getSeenLastChanceIds() { + if (typeof localStorage === 'undefined') return []; + + try { + const raw = localStorage.getItem(LAST_CHANCE_SEEN_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +// has this message ID already shown the "last chance" prompt? +export function hasSeenLastChancePrompt(messageId) { + return getSeenLastChanceIds().includes(messageId); +} + +// marks a message ID as having shown the prompt, so it won't show again +export function markLastChancePromptSeen(messageId) { + const seen = getSeenLastChanceIds(); + if (seen.includes(messageId)) return; + + seen.push(messageId); + + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(LAST_CHANCE_SEEN_KEY, JSON.stringify(seen)); + } catch { + // localStorage unavailable/full - worst case the prompt could + // reappear once more for this message, which is harmless + } + } +} diff --git a/src/lib/utils/trail.js b/src/lib/utils/trail.js new file mode 100644 index 0000000..3fac326 --- /dev/null +++ b/src/lib/utils/trail.js @@ -0,0 +1,47 @@ +// --- Personal location trail --------------------------------------------- +// A private, on-device-only record of every place this device has ever +// opened Overheard. Stored solely in localStorage - this never gets sent to +// Firestore, attached to a message, or shared with anyone else in any way. +// Unlike the public messages (which decay after 30 days, see messages.js), +// this history is kept forever with no pruning or size limit - it's a +// permanent personal map of presence, for this device/browser only. + +const STORAGE_KEY = 'overheard_location_trail'; + +// Reads the full trail array ([{ lat, lng, timestamp }, ...], oldest first) +// from localStorage. Returns [] if nothing has been recorded yet, if +// localStorage isn't available (e.g. during SSR), or if the stored value +// can't be parsed - any of those just means "no trail to draw" rather than +// an error worth surfacing. +export function getTrail() { + if (typeof localStorage === 'undefined') return []; + + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +// Appends a new { lat, lng, timestamp } point to the trail and writes the +// whole array back to localStorage, then returns the updated array so the +// caller can render it immediately without a second read. Intentionally +// has no size cap/pruning - every visit is kept. Called unconditionally on +// every successful geolocation fetch, regardless of whether the trail is +// currently shown - logging the visit and displaying it are independent. +export function addTrailPoint(lat, lng) { + const trail = getTrail(); + trail.push({ lat, lng, timestamp: Date.now() }); + + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(trail)); + } catch { + // localStorage unavailable/full - still return the in-memory + // array below so the trail renders for this session at least + } + } + + return trail; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a81e2e3..536f05e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,8 @@ + +{#if fadedNoticeMounted} + +{/if} + {#if error}

{error}

{:else if !(lat && lng)} @@ -67,10 +270,27 @@ on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav --> {#if lat && lng}
- + +
{/if} + +{#if isMobile && !$mapStore.selectedMessage} + +{/if} + {#if windowWidth < 768} @@ -80,57 +300,6 @@ {/if} - -{#if !isMobile} -
- -
- - - - - - - You are here -
-
- - - - - - Unread -
-
- - - - - - Read -
-
- - - - - - Echoed -
-
-{/if} - {#if !$mapStore.composing && !$mapStore.selectedMessage}