Cooler features and many many fixes later

This commit is contained in:
2026-06-14 01:34:26 +09:00
parent 5f8d224d84
commit 8a8c0888c3
22 changed files with 2544 additions and 237 deletions

View File

@@ -22,5 +22,27 @@ service cloud.firestore {
// nobody can delete messages through the client // nobody can delete messages through the client
allow delete: 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
// 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;
}
} }
} }

207
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"@googlemaps/js-api-loader": "^2.1.0", "@googlemaps/js-api-loader": "^2.1.0",
"@mediapipe/tasks-vision": "^0.10.35", "@mediapipe/tasks-vision": "^0.10.35",
"firebase": "^12.14.0", "firebase": "^12.14.0",
"ngeohash": "^0.6.3" "ngeohash": "^0.6.3",
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^7.0.1",
@@ -1318,6 +1319,15 @@
"node": ">= 0.4" "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": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -1370,6 +1380,15 @@
"node": ">= 0.6" "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": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -1397,6 +1416,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "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": { "node_modules/firebase": {
"version": "12.14.0", "version": "12.14.0",
"resolved": "https://registry.npmjs.org/firebase/-/firebase-12.14.0.tgz", "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.14.0.tgz",
@@ -1836,6 +1874,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lodash.camelcase": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
@@ -1907,6 +1957,51 @@
], ],
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1927,6 +2022,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -1980,6 +2084,89 @@
"node": ">=12.0.0" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1989,6 +2176,12 @@
"node": ">=0.10.0" "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": { "node_modules/rolldown": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
@@ -2043,6 +2236,12 @@
], ],
"license": "MIT" "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": { "node_modules/set-cookie-parser": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
@@ -2295,6 +2494,12 @@
"node": ">=0.8.0" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -20,6 +20,7 @@
"@googlemaps/js-api-loader": "^2.1.0", "@googlemaps/js-api-loader": "^2.1.0",
"@mediapipe/tasks-vision": "^0.10.35", "@mediapipe/tasks-vision": "^0.10.35",
"firebase": "^12.14.0", "firebase": "^12.14.0",
"ngeohash": "^0.6.3" "ngeohash": "^0.6.3",
"qrcode": "^1.5.4"
} }
} }

View File

@@ -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 { db, auth } from './config'; // database connection + anonymous auth
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
import { doc, updateDoc, increment, serverTimestamp } from 'firebase/firestore'; import { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
import ngeohash from 'ngeohash'; 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) { export async function getNearbyMessages(lat, lng) {
const prefix = getQueryPrefix(lat, lng); const prefix = getQueryPrefix(lat, lng);
@@ -31,6 +42,31 @@ export async function getNearbyMessages(lat, lng) {
return active; 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 // update the echo counter
export async function echoMessage(messageId) { export async function echoMessage(messageId) {
const ref = doc(db, 'messages', messageId); const ref = doc(db, 'messages', messageId);
@@ -41,7 +77,11 @@ export async function echoMessage(messageId) {
} }
// adding the message location // adding the message location
export async function addMessage(lat, lng, text, imageUrl = ''){ // 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); const geohash = ngeohash.encode(lat, lng, 6);
await addDoc(collection(db, 'messages'), { await addDoc(collection(db, 'messages'), {
@@ -50,12 +90,68 @@ export async function addMessage(lat, lng, text, imageUrl = ''){
lat, lat,
lng, lng,
geohash, geohash,
moodColor,
createdAt: serverTimestamp(), createdAt: serverTimestamp(),
lastEchoAt: serverTimestamp(), lastEchoAt: serverTimestamp(),
echoCount: 0, echoCount: 0,
// links the message to this device's persistent anonymous Firebase UID // links the message to this device's persistent anonymous Firebase UID
authorId: auth.currentUser?.uid ?? 'anon' 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 // fetch every message this device has authored, for the archive page

View File

@@ -3,6 +3,14 @@
import { getDecayInfo } from '$lib/utils/time.js' import { getDecayInfo } from '$lib/utils/time.js'
import { echoMessage } from '$lib/firebase/messages.js' import { echoMessage } from '$lib/firebase/messages.js'
import { playEchoTone } from '$lib/utils/sound.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(); let { message } = $props();
@@ -10,15 +18,74 @@
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null 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); 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() { async function handleEcho() {
await echoMessage(message.id); await echoMessage(message.id);
echoed = true; 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 // gentle ascending "thank you" tone confirming the echo went through
playEchoTone(); 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 let startY = 0; // where the swipe started
function startDrag(e) { function startDrag(e) {
@@ -49,14 +116,31 @@
> >
<div class="handle"></div> <div class="handle"></div>
</div> </div>
<div class="content"> <div class="content" class:last-chance={isLastChance}>
{#if message.imageUrl} {#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" /> <img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if} {/if}
<p class="message-text">{message.text}</p> <p class="message-text">{message.text}</p>
{#if decay} {#if decay}
{#if isLastChance}
<!-- "last chance" framing replaces the normal countdown text -->
<p class="meta last-chance-text">this is the last chance to keep this alive</p>
{:else}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p> <p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
{/if} {/if}
{/if}
{#if showLastChancePrompt}
<!-- one-time prompt (see stats.js) - gently frames Echo as
the way to give this message more time, without being
pushy. Dismissible on its own, or implicitly dismissed
by pressing Echo/Let go below. -->
<div class="last-chance-prompt">
<p>if this one means something, an echo will keep it here a little longer.</p>
<button class="dismiss-prompt" onclick={() => showLastChancePrompt = false} aria-label="dismiss">×</button>
</div>
{/if}
<div class="actions"> <div class="actions">
<button class="echo-button" <button class="echo-button"
class:echoed={echoed} class:echoed={echoed}
@@ -64,10 +148,11 @@
disabled={echoed}> disabled={echoed}>
{echoed ? 'Echoed' : 'Echo'} {echoed ? 'Echoed' : 'Echo'}
</button> </button>
<button class="letgo-button" onclick={() => mapStore.set( <button class="letgo-button" onclick={handleLetGo}>
{ selectedMessage: null, composing: false })}>
let go let go
</button> </button>
<!-- share link + QR code popover (see SharePopover.svelte) -->
<SharePopover {message} />
</div> </div>
</div> </div>
{/if} {/if}
@@ -81,7 +166,12 @@
right: 0; right: 0;
background: white; background: white;
border-radius: 20px 20px 0 0; 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 */ /* flat 2D style for now - shadow removed, paper-style shadows may be added later */
transform: translateY(100%); transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1); transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
@@ -121,6 +211,58 @@
margin-bottom: 1.2rem; 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 { .actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;

View File

@@ -1,6 +1,7 @@
<script> <script>
import { mapStore } from '$lib/stores/mapStore.js'; import { mapStore } from '$lib/stores/mapStore.js';
import { setMessages } from '$lib/stores/messagesStore.js'; import { setMessages } from '$lib/stores/messagesStore.js';
import { refreshGlobalCount } from '$lib/stores/globalCountStore.js';
import { addMessage } from '$lib/firebase/messages.js' import { addMessage } from '$lib/firebase/messages.js'
import { hasFace } from '$lib/utils/faceDetection.js'; import { hasFace } from '$lib/utils/faceDetection.js';
import { uploadImage } from '$lib/Firebase/storage.js'; import { uploadImage } from '$lib/Firebase/storage.js';
@@ -20,11 +21,109 @@
let submitting = $state(false); let submitting = $state(false);
let remaining = $derived(240-text.length); let remaining = $derived(240-text.length);
// --- rotating placeholder prompts -------------------------------------
// A small set of incomplete, contemplative fragments that cycle through
// as a placeholder for the textarea while it's empty - a gentle nudge
// toward a certain kind of writing, without dictating what gets written.
const placeholderPrompts = [
"today I noticed...",
"if you're reading this...",
"this place...",
"I wish someone knew...",
"the air smells like...",
"before I forget..."
];
// index of the prompt currently shown, and its crossfade opacity
let promptIndex = $state(0);
let promptOpacity = $state(1);
// whether the textarea currently has focus - tracked via onfocus/onblur
// on the textarea below, since this overlay's visibility needs to react
// to focus in JS, not just CSS.
let textareaFocused = $state(false);
// The overlay (see markup below) only exists while the textarea is both
// empty and unfocused. The moment either condition flips - the user
// starts typing, or just taps/clicks into the field - the prompts get
// out of the way entirely, per the feature spec.
let showPromptOverlay = $derived(text.length === 0 && !textareaFocused);
// Cycles through placeholderPrompts on a timer, crossfading between them
// via promptOpacity. This effect only runs while showPromptOverlay is
// true; the moment it becomes false (typing starts, or the field gains
// focus), the cleanup below clears any pending timers and the cycle
// simply stops - clearing the textarea (and leaving it unfocused) makes
// showPromptOverlay true again, re-running this effect and starting a
// fresh cycle.
$effect(() => {
if (!showPromptOverlay) return;
// Timing mirrors the fade conventions used elsewhere in the app
// (the opening ritual / "first here" bubble): a multi-second hold
// per prompt, then a ~1s crossfade into the next one.
const holdMs = 3000;
const fadeMs = 1000;
let fadeOutTimer;
let advanceTimer;
// Schedules one hold -> fade-out -> advance -> fade-in step, then
// schedules itself again. An $effect only re-runs when the values it
// READS change, and this step only WRITES promptIndex/promptOpacity,
// so the cycle has to keep itself going via setTimeout rather than
// relying on the effect re-running on its own.
function scheduleStep() {
fadeOutTimer = setTimeout(() => {
promptOpacity = 0; // begin crossfade out
advanceTimer = setTimeout(() => {
promptIndex = (promptIndex + 1) % placeholderPrompts.length;
promptOpacity = 1; // crossfade the new prompt in
scheduleStep();
}, fadeMs);
}, holdMs);
}
scheduleStep();
// cleanup: runs when showPromptOverlay flips to false (typing starts
// or the field is focused) and on component unmount - clears
// whichever of the two timers is currently pending so the cycle
// stops cleanly with no stray callbacks.
return () => {
clearTimeout(fadeOutTimer);
clearTimeout(advanceTimer);
};
});
let selectedFile = $state(null); let selectedFile = $state(null);
let imagePreview = $state(null); // what <img> will read let imagePreview = $state(null); // what <img> will read
let imageError = $state(null); let imageError = $state(null);
let checkingFace = $state(false); // this will show the checking image... message let checkingFace = $state(false); // this will show the checking image... message
// --- mood color (intentionally unexplained feature) -------------------
// 5 preset pastel swatches, same hue/saturation/lightness formula as
// pins.js's randomPastel() (hsl(hue, 60%, 72%)) so a picked color sits
// naturally alongside the existing pin palette. Picking one is optional:
// selectedMoodColor stays null until tapped, and tapping the already-
// selected swatch again clears it back to null. null flows through to
// addMessage() -> Firestore as moodColor: null, and pins.js falls back to
// its existing random pastel for any pin with no moodColor.
const moodColors = [
'hsl(340, 60%, 72%)', // soft pink
'hsl(210, 60%, 72%)', // soft blue
'hsl(140, 60%, 72%)', // soft green
'hsl(265, 60%, 72%)', // soft purple
'hsl(40, 60%, 72%)' // soft peach/yellow
];
let selectedMoodColor = $state(null);
function toggleMoodColor(color) {
// tapping the selected swatch again deselects it (back to "no opinion")
selectedMoodColor = selectedMoodColor === color ? null : color;
}
async function handleImageSelect(event) { async function handleImageSelect(event) {
const file = event.target.files[0]; // only one file will be allowed so always index 0 const file = event.target.files[0]; // only one file will be allowed so always index 0
@@ -76,7 +175,12 @@
imageUrl = await uploadImage(selectedFile); imageUrl = await uploadImage(selectedFile);
} }
await addMessage(lat, lng, text.trim(), imageUrl); await addMessage(lat, lng, text.trim(), imageUrl, selectedMoodColor);
// the global "memory counter" (meta/stats) just incremented inside
// addMessage() above - refetch it so the pill's count updates
// immediately for this device, without waiting on a page reload
refreshGlobalCount();
// reload pins so that they show up with new message // reload pins so that they show up with new message
const { getNearbyMessages } = await import ('$lib/firebase/messages.js'); const { getNearbyMessages } = await import ('$lib/firebase/messages.js');
@@ -89,6 +193,7 @@
text = ''; text = '';
selectedFile = null; selectedFile = null;
imagePreview = null; imagePreview = null;
selectedMoodColor = null;
submitting = false; submitting = false;
// close the sheet // close the sheet
@@ -117,12 +222,48 @@
<span class="counter" class:over={remaining < 0}>{remaining}</span> <span class="counter" class:over={remaining < 0}>{remaining}</span>
</div> </div>
<!-- wrapper gives the rotating-placeholder overlay below a positioning context -->
<div class="textarea-wrap">
<textarea <textarea
bind:value={text} bind:value={text}
placeholder="Share a moment for someone else to overhear" aria-label="Leave a message"
onfocus={() => textareaFocused = true}
onblur={() => textareaFocused = false}
rows="5" rows="5"
></textarea> ></textarea>
<!-- Rotating placeholder overlay: the native placeholder attribute
can't crossfade between values, so this is a plain <p>
absolutely positioned over the textarea instead, styled to look
like placeholder text. It only exists in the DOM while the
field is empty and unfocused (showPromptOverlay above) -
aria-hidden since it's an ambient visual cue rather than real
content, and pointer-events: none so it never blocks clicks
into the textarea underneath. -->
{#if showPromptOverlay}
<p class="prompt-overlay" style="opacity: {promptOpacity}" aria-hidden="true">
{placeholderPrompts[promptIndex]}
</p>
{/if}
</div>
<!-- mood color swatches: 5 preset pastel colors, no labels or explanation
by design (see project brief / pins.js). selecting one is optional -
a subtle ring + scale-up marks the selected swatch; tapping it again
deselects. -->
<div class="mood-row">
{#each moodColors as color}
<button
type="button"
class="mood-swatch"
class:selected={selectedMoodColor === color}
style="background-color: {color}"
aria-label="color"
onclick={() => toggleMoodColor(color)}
></button>
{/each}
</div>
<div class = "image-section"> <div class = "image-section">
{#if checkingFace} {#if checkingFace}
<p class="checking">Checking image...</p> <p class="checking">Checking image...</p>
@@ -265,6 +406,27 @@
font-weight: 600; font-weight: 600;
} }
/* positioning context for the rotating-placeholder overlay (see
.prompt-overlay below). Plain block on mobile, sized by the
textarea's natural height; .compose.desktop below gives it the
flex-growing role textarea itself used to have. */
.textarea-wrap {
position: relative;
}
/* on desktop, .textarea-wrap takes over the flex-growing role that
textarea used to have on its own, so the wrapper - and the textarea
inside it - still fill the remaining square space. Scoped to desktop
only: on mobile .compose isn't a flex container, and an unscoped
display: flex here would make .textarea-wrap a flex column with no
defined height, collapsing the textarea inside it to 0 height. */
.compose.desktop .textarea-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
textarea { textarea {
width: 100%; width: 100%;
border: 1.5px solid #eee; border: 1.5px solid #eee;
@@ -288,6 +450,28 @@
border-color: #111; border-color: #111;
} }
/* visually mimics the textarea's placeholder text - same font, size,
line-height and padding as the textarea (the extra 1.5px on padding
accounts for the textarea's border under box-sizing: border-box, so
the overlay text lines up with where typed text would actually
start), but lighter in color. Absolutely positioned over the textarea
so promptOpacity (set in the $effect above) can crossfade it between
prompts - something a native placeholder attribute can't do. */
.prompt-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
margin: 0;
padding: calc(0.9rem + 1.5px);
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 0.95rem;
line-height: 1.6;
color: #aaa;
pointer-events: none;
transition: opacity 1s ease;
}
.submit { .submit {
width: 100%; width: 100%;
margin-top: 0.75rem; margin-top: 0.75rem;
@@ -307,6 +491,35 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* mood color swatch row - small circles, no text/labels by design */
.mood-row {
display: flex;
gap: 0.6rem;
margin-top: 0.75rem;
}
.mood-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
/* outline (not box-shadow, for the flat 2D look) starts transparent
so the ring only appears once .selected adds a visible color -
transitioning it avoids a layout jump */
outline: 2px solid transparent;
outline-offset: 2px;
transition: outline-color 0.15s, transform 0.15s;
}
/* selected swatch: dark ring + slight scale-up, the only visual cue
that a color is picked */
.mood-swatch.selected {
outline-color: #111;
transform: scale(1.15);
}
.image-section { .image-section {
margin-top: 0.75rem; margin-top: 0.75rem;
} }

View File

@@ -0,0 +1,137 @@
<script>
import { onMount } from 'svelte';
import { globalCount, refreshGlobalCount } from '$lib/stores/globalCountStore.js';
// --- mobile tap/fade easter egg ----------------------------------------
// On desktop, the secondary "places remember..." line is always visible
// (see .secondary-desktop in the stylesheet below, shown via the
// min-width media query). On mobile it's hidden by default and only
// fades in when the pill is tapped, holds for a few seconds, then fades
// back out on its own - an easter egg rather than a persistent UI
// element. `secondaryVisible` is local Svelte 5 rune state driving the
// opacity/max-height transition on .secondary-mobile; .secondary-desktop
// is unaffected by it.
let secondaryVisible = $state(false);
let fadeOutTimer;
onMount(() => {
refreshGlobalCount();
});
function handleTap() {
secondaryVisible = true;
// restart the hold timer on every tap, so a second tap while the
// text is already visible just extends how long it stays up rather
// than racing with the previous fade-out
clearTimeout(fadeOutTimer);
fadeOutTimer = setTimeout(() => {
secondaryVisible = false;
}, 3000);
}
</script>
<!-- only render once the first fetch resolves, so the pill never briefly
shows a misleading "0 messages have been left worldwide" before the
real count arrives -->
{#if $globalCount !== null}
<button type="button" class="count-pill" onclick={handleTap}>
<span class="count-main">{$globalCount.toLocaleString()} messages have been left worldwide</span>
<!-- desktop: always-visible secondary line (hidden on mobile via the
same media query that reveals it - see <style> below) -->
<span class="count-secondary secondary-desktop">
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.
</span>
<!-- mobile: same text, but only fades in on tap (handleTap above)
and is hidden entirely on desktop -->
<span class="count-secondary secondary-mobile" class:visible={secondaryVisible}>
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.
</span>
</button>
{/if}
<style>
/* small rounded pill, top-right of the app on every page - cream
background + subtle shadow + muted text, matching the "first here"
speech bubble and opening ritual overlay's palette */
.count-pill {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 150;
max-width: 60vw;
background: #f9f7f4;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px;
padding: 0.5rem 0.9rem;
text-align: left;
font-family: Georgia, 'Times New Roman', Times, serif;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
cursor: pointer;
}
.count-main {
display: block;
font-size: 0.7rem;
color: #888;
line-height: 1.4;
letter-spacing: 0.02em;
}
.count-secondary {
display: block;
font-size: 0.65rem;
color: #aaa;
line-height: 1.5;
}
/* desktop-only secondary line: hidden on mobile by default */
.secondary-desktop {
display: none;
}
/* mobile-only secondary line: hidden + collapsed until tapped, then
fades in/out via .visible (toggled by handleTap above). max-height
(rather than display) is what allows the opacity transition to
actually animate instead of snapping. */
.secondary-mobile {
opacity: 0;
max-height: 0;
overflow: hidden;
margin-top: 0;
transition: opacity 0.6s ease, max-height 0.6s ease;
}
.secondary-mobile.visible {
opacity: 1;
max-height: 6rem;
margin-top: 0.35rem;
}
@media (min-width: 768px) {
.count-pill {
max-width: 320px;
padding: 0.65rem 1rem;
/* tap-to-reveal is mobile-only; desktop already shows everything,
so the pointer cursor would be misleading here */
cursor: default;
}
.count-main {
font-size: 0.8rem;
}
.secondary-desktop {
display: block;
margin-top: 0.35rem;
}
/* desktop already shows .secondary-desktop, so the mobile
tap-to-reveal copy is never shown here regardless of state */
.secondary-mobile {
display: none;
}
}
</style>

View File

@@ -1,36 +1,109 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { messagesStore } from '$lib/stores/messagesStore.js'; // pass the messages store here import { messagesStore } from '$lib/stores/messagesStore.js'; // pass the messages store here
import { mapStore } from '$lib/stores/mapStore.js'; // use this to track interactions with da map import { mapStore } from '$lib/stores/mapStore.js'; // use this to track interactions with da map
import { unreadPin, readPin, echoedPin, locationPin } from '$lib/utils/pins.js'; // custom SVG pin generators import { messagePin, locationPin } from '$lib/utils/pins.js'; // custom SVG pin generators
import { mapStyles } from '$lib/utils/mapStyles.js'; // desaturated/atmospheric map styling - only works now that mapId is gone
import { getTrail, addTrailPoint } from '$lib/utils/trail.js'; // private, on-device-only location history
import { distanceMeters, bearingDegrees } from '$lib/utils/geo.js'; // great-circle distance/bearing for "sensing mode"
// export let latitude; // export let latitude;
// export let longitude; // export let longitude;
// ^ this didn't work for some reason, so instead we get the props like this: (based on internet research this is the fix) // ^ this didn't work for some reason, so instead we get the props like this: (based on internet research this is the fix)
let { lat, lng } = $props(); // firstHereMounted/firstHereVisible: "you're the first here" bubble state,
// computed in +page.svelte (zero-results check + fade timing) - rendered
// here so it can be positioned near the user's location marker below.
// sensingMode: combined compass-arrow + proximity-vibration mode, toggled
// by SensingToggle in +page.svelte (mobile-only - see the effect below).
let { lat, lng, firstHereMounted = false, firstHereVisible = false, sensingMode = false } = $props();
let mapDiv; let mapDiv;
let map = $state(null); let map = $state(null);
let markers = []; // keep track of pins on map let markers = []; // keep track of pins on map
let userMarker;
let AdvancedMarkerElement;
// ids of messages the user has opened, for this session only (resets on reload) // Marker/Size/Point/Polyline are only available once their libraries
let readIds = new SvelteSet(); // have loaded (assigned in onMount below) - declared here so
// toMarkerIcon, addUserLocationMarker, renderPins, and the trail effect
// can all use them.
let Marker, Size, Point, Polyline;
// --- personal location trail (private, on-device only) ----------------
// `trailPoints` holds this device's full visit history (see trail.js),
// read/appended once in onMount. `trailPolyline` is the Polyline
// instance once created (lazily, inside the $effect below) - plain
// variables, not runes, since they're written once and don't need to
// trigger re-renders themselves.
let trailPoints = [];
let trailPolyline;
// `showTrail` is the only piece of UI state for this feature - toggled
// by the small button in the markup below. Defaults to ON: the trail is
// styled to be faint enough (see the $effect below) that it doesn't
// compete with message pins, so showing it by default lets people
// notice this quiet "personal map of presence" without it being loud -
// the toggle just lets them hide it if they'd rather not see it.
let showTrail = $state(true);
// --- "sensing mode": compass arrow + proximity vibration ---------------
// `heading` is the device's current compass heading in degrees (0 = true
// north, clockwise), updated by the deviceorientation listener below.
// Defaults to 0 ("facing north") rather than null: devices/browsers
// without an orientation sensor (e.g. desktop) never fire that event, so
// heading would otherwise stay null forever and the arrow would never
// appear at all. With a 0 default, the arrow instead falls back to a
// "north-up" compass - pointing at the absolute bearing toward the
// target - and starts rotating relative to the device's actual facing
// direction the moment real orientation data arrives.
// `nearestOffScreenBearing` is the compass bearing (also 0-360, 0 = true
// north) from the user's current position to the nearest message pin -
// recomputed on the interval below. `null` only when there's no nearby
// message at all (or the map isn't ready yet), in which case the arrow
// stays hidden - there's nothing to point at.
let heading = $state(0);
let nearestOffScreenBearing = $state(null);
// The arrow needs to point at `nearestOffScreenBearing` RELATIVE TO the
// device's current facing direction - e.g. if the pin is due north
// (bearing 0) and the device is currently facing east (heading 90), the
// arrow should point 90deg counter-clockwise (-90 / 270) from "up" on
// the screen, since "up" on the screen = wherever the device is facing.
// Subtracting heading from the target bearing gives exactly that (with
// heading's default of 0, "up" simply means "true north" until real
// orientation data arrives). `null` (nothing to point at) hides the
// arrow entirely.
let arrowRotation = $derived(
nearestOffScreenBearing !== null
? (nearestOffScreenBearing - heading + 360) % 360
: null
);
// converts a { url, size } pin (from pins.js) into an Icon object for
// the legacy Marker API - centers the icon on its coordinate (anchor at
// size/2, size/2) the same way the old AdvancedMarkerElement content's
// `translateY(-50%)` did, but for both axes since Marker's default
// anchor is bottom-center rather than center-center.
function toMarkerIcon({ url, size }) {
return {
url,
scaledSize: new Size(size, size),
anchor: new Point(size / 2, size / 2)
};
}
let userMarker;
/** Jisu Legacy - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */ /** Jisu Legacy - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
function addUserLocationMarker(centerLat, centerLng) { function addUserLocationMarker(centerLat, centerLng) {
// use the shared locationPin() so the user's marker matches the // use the shared locationPin() so the user's marker matches the
// style of the message pins, instead of building a one-off dot here // style of the message pins, instead of building a one-off dot here
userMarker = new AdvancedMarkerElement({ userMarker = new Marker({
position: { lat: centerLat, lng: centerLng }, position: { lat: centerLat, lng: centerLng },
map, map,
title: 'Your location', title: 'Your location',
zIndex: 1000, zIndex: 1000,
content: locationPin() icon: toMarkerIcon(locationPin())
}); });
} }
@@ -44,20 +117,39 @@
version: 'weekly', version: 'weekly',
}); });
const { Map } = await importLibrary('maps'); // switched from AdvancedMarkerElement back to the legacy Marker API
({ AdvancedMarkerElement } = await importLibrary('marker')); // (with SVG data-URL icons from pins.js) so a JS `styles` array can
// take effect on this map again - Google ignores any `styles` array
// on maps that have a mapId set, and AdvancedMarkerElement requires
// one. NOTE: the legacy `Marker` class (plus `Size`/`Point`) live in
// the 'marker'/'core' libraries, NOT 'maps' - both are still needed.
// Polyline (used by the location trail below) lives in 'maps'
// alongside Map.
const { Map, Polyline: PolylineCtor } = await importLibrary('maps');
Polyline = PolylineCtor;
({ Marker } = await importLibrary('marker'));
({ Size, Point } = await importLibrary('core'));
// --- personal location trail: log this visit ----------------------
// addTrailPoint() writes { lat, lng, timestamp: Date.now() } to
// localStorage and returns the FULL updated history (oldest first),
// including this visit - so the line drawn below is always
// up to date. This runs unconditionally on every successful
// geolocation fetch (MapView only mounts once lat/lng are known),
// regardless of `showTrail` - the history keeps growing even while
// the trail is hidden. Nothing here touches Firestore; it's purely
// local to this device/browser (see trail.js).
trailPoints = addTrailPoint(centerLat, centerLng);
// NOTE: a JS `styles` array was tried here for the soft white look, but
// Google Maps ignores (and errors on) inline `styles` when `mapId` is set,
// and mapId is required for AdvancedMarkerElement (our pins). Removed so
// pins render again; the soft style would need to be a Cloud-based Map
// Style configured for this mapId in the Google Cloud Console instead.
map = new Map(mapDiv, { map = new Map(mapDiv, {
center: { lat: centerLat, lng: centerLng }, center: { lat: centerLat, lng: centerLng },
zoom: 15, zoom: 15,
disableDefaultUI: true, disableDefaultUI: true,
gestureHandling: 'greedy', gestureHandling: 'greedy',
mapId: 'DEMO_MAP_ID' // mapId removed - legacy Markers don't need it, and removing it
// is what lets this `styles` array be respected (see comment above) -
// desaturated/atmospheric look defined in mapStyles.js
styles: mapStyles
}); });
addUserLocationMarker(centerLat, centerLng); addUserLocationMarker(centerLat, centerLng);
@@ -66,33 +158,30 @@
// function to render pins // function to render pins
function renderPins(messages) { function renderPins(messages) {
// clear current pins // clear current pins
markers.forEach(marker => (marker.map = null)); markers.forEach(marker => marker.setMap(null));
markers = []; markers = [];
messages.forEach(message => { messages.forEach(message => {
// pick a pin shape based on message state: // every message gets the same plain circle pin (previously this
// - messages the user has opened this session get the read circle // varied - star/circle/heart - based on read/unread/echoed state,
// (takes priority - once you've read it, it's a circle even if echoed) // swapped on click; that's gone now, so clicking a pin never
// - otherwise, echoed messages (echoCount > 0) get the heart pin // changes its appearance, only opens the detail view below).
// - everything else gets the unread star // message.moodColor (optional, set by ComposeSheet's swatch row and
let icon; // stored in Firestore via addMessage) overrides the random pastel
if (readIds.has(message.id)) { // color inside messagePin() when present - see pins.js's pinColor()
icon = readPin(); const icon = messagePin(message.moodColor);
} else if (message.echoCount > 0) {
icon = echoedPin();
} else {
icon = unreadPin();
}
const marker = new AdvancedMarkerElement({ const marker = new Marker({
position: { lat: message.lat, lng: message.lng }, position: { lat: message.lat, lng: message.lng },
map, map,
title: message.text, title: message.text,
content: icon // AdvancedMarkerElement takes a DOM node via `content`, not an `icon` option icon: toMarkerIcon(icon)
}); });
marker.addEventListener('click', () => { // legacy Marker uses the Maps JS event system (addListener), not
readIds.add(message.id); // mark as read; SvelteSet triggers the $effect below to re-render pins // DOM events - this also avoids the AdvancedMarkerElement
// gmp-click deprecation warning entirely
marker.addListener('click', () => {
mapStore.set({ selectedMessage: message, composing: false }); mapStore.set({ selectedMessage: message, composing: false });
}); });
@@ -106,11 +195,275 @@
renderPins($messagesStore); // we put pins on the map renderPins($messagesStore); // we put pins on the map
} }
}) })
// --- personal location trail: draw + toggle ---------------------------
// Re-runs whenever `map` (becomes available once, in onMount) or
// `showTrail` (toggled by the button below) changes. `trailPoints` and
// `Polyline` are plain variables, not runes - they're both assigned in
// onMount BEFORE `map` is, in the same synchronous block, so by the time
// `map` becomes non-null and triggers this effect, they're already set
// (same pattern the renderPins effect above relies on for Marker/etc).
$effect(() => {
if (!map || !Polyline) return;
// lazily create the Polyline once, the first time the map is ready.
// Edge case: a single point (first-ever visit) has nothing to
// connect, so we simply don't create a line - trailPolyline stays
// undefined and the toggle below has nothing to show/hide.
if (!trailPolyline && trailPoints.length >= 2) {
const path = trailPoints.map(p => ({ lat: p.lat, lng: p.lng }));
trailPolyline = new Polyline({
path,
clickable: false, // purely decorative - shouldn't intercept clicks meant for the map/pins
zIndex: 1, // sit beneath message/location pins (which default to higher stacking)
// the solid stroke is fully transparent - only the dash
// `icons` below are actually drawn, which is how dashed
// lines are done with google.maps.Polyline (it has no
// native dash-pattern option)
strokeOpacity: 0,
icons: [{
icon: {
path: 'M 0,-1 0,1', // a short vertical dash
strokeOpacity: 0.35, // faint - shouldn't compete visually with message pins
strokeColor: '#999', // neutral grey, fits the desaturated/parchment map palette
scale: 3
},
offset: '0',
repeat: '14px' // gap between dashes
}]
});
}
// show/hide based on the toggle - setMap(null) removes it from the
// map without destroying the instance, so toggling back on doesn't
// need to rebuild the path.
if (trailPolyline) {
trailPolyline.setMap(showTrail ? map : null);
}
})
// --- sensing mode: nearest off-screen pin ------------------------------
// Looks at every currently-loaded nearby message and finds both (a) the
// closest one that's currently outside the map's visible bounds, and (b)
// the closest one overall. Sets nearestOffScreenBearing to the bearing
// toward (a) if one exists, otherwise falls back to (b).
// The fallback matters because getNearbyMessages's ~1.2km geohash area is
// roughly the same size as the map's default viewport - in the common
// case EVERY nearby message is already on-screen, so "off-screen only"
// would mean nearestOffScreenBearing (and therefore the arrow) is null
// almost all the time. Preferring an off-screen pin (pointing toward
// something not already visible is more useful) but falling back to the
// nearest pin overall means the arrow still has something to point at,
// instead of never appearing. Stays null only if there are no nearby
// messages at all (or the map/bounds aren't ready yet) - either way, the
// arrow hides itself via arrowRotation above.
function updateNearestOffScreenPin(userLat, userLng) {
const bounds = map?.getBounds();
let nearestOffScreen = null;
let nearestOffScreenDist = Infinity;
let nearestOverall = null;
let nearestOverallDist = Infinity;
for (const message of $messagesStore) {
const dist = distanceMeters(userLat, userLng, message.lat, message.lng);
if (dist < nearestOverallDist) {
nearestOverallDist = dist;
nearestOverall = message;
}
const onScreen = bounds?.contains({ lat: message.lat, lng: message.lng }) ?? false;
if (!onScreen && dist < nearestOffScreenDist) {
nearestOffScreenDist = dist;
nearestOffScreen = message;
}
}
const target = nearestOffScreen ?? nearestOverall;
nearestOffScreenBearing = target
? bearingDegrees(userLat, userLng, target.lat, target.lng)
: null;
}
// --- sensing mode: proximity vibration ----------------------------------
// Checks every nearby message against (userLat, userLng) and, for any pin
// within PROXIMITY_METERS that this session hasn't already buzzed for,
// fires a single soft pulse. `vibratedIds` is a plain (non-reactive) Set -
// it only needs to track membership for this check, not drive any UI -
// and is recreated each time sensing mode is turned on (see the effect
// below), so re-enabling the toggle lets the same pin buzz again.
const PROXIMITY_METERS = 50;
let vibratedIds = new Set();
function checkProximityVibration(userLat, userLng) {
// gracefully do nothing on browsers without the Vibration API
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') return;
for (const message of $messagesStore) {
if (vibratedIds.has(message.id)) continue;
const dist = distanceMeters(userLat, userLng, message.lat, message.lng);
if (dist <= PROXIMITY_METERS) {
navigator.vibrate(100); // single soft pulse
vibratedIds.add(message.id);
}
}
}
// --- sensing mode: lifecycle --------------------------------------------
// Runs whenever `sensingMode` changes (toggled by SensingToggle in
// +page.svelte). While ON, this sets up everything the two features need
// and tears it all down again the moment it's turned OFF or this
// component unmounts - no listeners/timers/watchers should outlive the
// toggle being on.
$effect(() => {
if (!sensingMode) return;
// fresh "already buzzed" set each time sensing mode is (re-)enabled
vibratedIds = new Set();
// Live position for sensing: separate from the `lat`/`lng` props
// (which only reflect the one-time fix used to center the map) -
// watchPosition keeps these updated as the user actually walks
// around, which is what makes "proximity" and "nearest off-screen
// pin" meaningful. Starts from the map's initial fix so there's a
// usable value immediately, before the first watch update arrives.
let userLat = Number(lat);
let userLng = Number(lng);
// --- DeviceOrientationEvent: device compass heading -----------------
// iOS exposes a ready-made compass heading via webkitCompassHeading
// (0 = true north, increases clockwise). Other browsers only provide
// `alpha` (rotation around the z-axis, increasing COUNTER-clockwise
// from north when the device is lying flat) - `360 - alpha` converts
// that into the same "clockwise from north" convention. This is an
// approximation (it assumes the device is held flat/upright facing
// the user) but is good enough for a rough directional arrow.
function handleOrientation(event) {
if (event.webkitCompassHeading != null) {
heading = event.webkitCompassHeading;
} else if (event.alpha != null) {
heading = (360 - event.alpha) % 360;
}
}
if (typeof window !== 'undefined' && typeof DeviceOrientationEvent !== 'undefined') {
window.addEventListener('deviceorientation', handleOrientation);
// Chrome on Android only reports a true compass heading via
// 'deviceorientationabsolute' - plain 'deviceorientation' events
// there have `absolute: false` and `alpha` relative to whatever
// direction the device happened to be facing when it booted, not
// true north, which made the arrow point in an arbitrary (and
// sometimes never-updating) direction. iOS doesn't dispatch this
// event at all, so it just never fires there and
// webkitCompassHeading (above) continues to be used.
window.addEventListener('deviceorientationabsolute', handleOrientation);
}
// if DeviceOrientationEvent doesn't exist at all, heading just stays
// null forever - arrowRotation stays null and the arrow stays hidden,
// which is the "handle unavailable API gracefully" behavior.
// --- periodic sensing check ------------------------------------------
// every few seconds, recompute the nearest off-screen pin (for the
// arrow) and run the proximity-vibration check, using the latest
// watched position.
function runSensingCheck() {
updateNearestOffScreenPin(userLat, userLng);
checkProximityVibration(userLat, userLng);
}
runSensingCheck();
const intervalId = setInterval(runSensingCheck, 3000);
// keep userLat/userLng current as the device moves
let watchId = null;
if (typeof navigator !== 'undefined' && navigator.geolocation?.watchPosition) {
watchId = navigator.geolocation.watchPosition(
(position) => {
userLat = position.coords.latitude;
userLng = position.coords.longitude;
},
() => {}, // ignore watch errors - sensing just keeps using the last-known position
{ enableHighAccuracy: true, maximumAge: 5000 }
);
}
// cleanup: tear everything down the moment sensing mode is turned off
// (or this component is destroyed) - no lingering listeners/timers.
return () => {
window.removeEventListener('deviceorientation', handleOrientation);
window.removeEventListener('deviceorientationabsolute', handleOrientation);
clearInterval(intervalId);
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
}
heading = 0; // back to the "facing north" default (see declaration above)
nearestOffScreenBearing = null;
};
});
</script> </script>
<div class="map" bind:this={mapDiv}></div> <div class="map-wrapper">
<div class="map" bind:this={mapDiv}></div>
<!-- small, unobtrusive toggle for the personal location trail (private,
on-device only - see trail.js). Placed top-left so it's discoverable
without competing with the FAB (bottom-right) or top bars elsewhere. -->
<button
class="trail-toggle"
class:active={showTrail}
onclick={() => showTrail = !showTrail}
aria-label={showTrail ? 'Hide your location trail' : 'Show your location trail'}
title={showTrail ? 'Hide your location trail' : 'Show your location trail'}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M1.5 8 H5 M7.5 8 H9.5 M12 8 H14.5" stroke-dasharray="2 2"/>
</svg>
</button>
<!-- "you're the first here" speech bubble. The map is created with
center: {lat, lng} (see onMount) and the user-location marker is
drawn at that same center point, so positioning this bubble at
50%/50% of .map-wrapper places it directly above the marker. -->
{#if firstHereMounted}
<p class="first-here" class:visible={firstHereVisible} aria-hidden="true">
no one has been here before — or at least, no one who said so.
</p>
{/if}
<!-- "sensing mode" compass arrow: only rendered while sensing mode is on
AND there's a heading + a nearby message to point at (arrowRotation
is null otherwise - see the $derived above and
updateNearestOffScreenPin's fallback). Bottom-left placement
keeps it clear of .trail-toggle (top-left) and the FAB/SensingToggle
(bottom-right, in +page.svelte). The inner <svg> is what actually
rotates, via arrowRotation.
Also hidden while a message is open (BottomSheet/SidePanel showing
selectedMessage) - the arrow's bottom-left position overlaps that
reading view, and pointing toward another pin isn't useful while
you're in the middle of reading one. -->
{#if sensingMode && arrowRotation !== null && !$mapStore.selectedMessage}
<div class="compass-arrow" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 20 20" style="transform: rotate({arrowRotation}deg)">
<path d="M10 1 L16 15 L10 11.5 L4 15 Z" fill="#9c8ad6" />
</svg>
</div>
{/if}
</div>
<style> <style>
/* positioning context for .trail-toggle, which is absolutely placed
within the map area regardless of the desktop/mobile layout in
+page.svelte */
.map-wrapper {
position: relative;
width: 100%;
height: 100%;
}
/* fill the .map-container parent (height: 100% instead of min-height: 100vh) /* fill the .map-container parent (height: 100% instead of min-height: 100vh)
so that the parent's padding-bottom on mobile actually shrinks the so that the parent's padding-bottom on mobile actually shrinks the
visible map instead of the map overflowing past it */ visible map instead of the map overflowing past it */
@@ -118,4 +471,107 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
/* "sensing mode" compass arrow - small round pastel "puck", bottom-left
of the map (clear of .trail-toggle at top-left and the FAB/
SensingToggle at bottom-right in +page.svelte). bottom offset clears
the mobile bottom nav (64px) the same way .fab's 5rem does. The arrow
<svg> inside is rotated via arrowRotation (see <script>); the puck
itself never rotates. */
.compass-arrow {
position: absolute;
bottom: 5rem;
left: 1rem;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
z-index: 120;
}
.compass-arrow svg {
transition: transform 0.15s linear;
}
/* small circular toggle, top-left of the map - flat 2D style (no
shadow) matching the rest of the app's current look. Muted/translucent
so it doesn't draw attention away from the map itself; .active just
darkens it slightly to show the trail is currently on. */
.trail-toggle {
position: absolute;
top: 1rem;
left: 1rem;
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.7);
color: #aaa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 120;
transition: color 0.15s, border-color 0.15s;
}
.trail-toggle.active {
color: #666;
border-color: rgba(0, 0, 0, 0.15);
}
/* "you're the first here" speech bubble - "cute speech bubble" look:
soft cream background (same #f9f7f4 as the opening ritual overlay in
+layout.svelte) with rounded corners, so the text reads clearly
against the desaturated map. Positioned at the center of .map-wrapper
(= the user-location marker's position, see comment in the markup
above) and shifted up + left by its own size/2 plus the marker's
16px radius, so the bubble sits just above the marker with its tail
(::after below) pointing down at it. Starts invisible; .visible fades
it in/out over 1.2s (timing controlled by the $effects in +page.svelte). */
.first-here {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, calc(-100% - 16px));
margin: 0;
padding: 0.85rem 1.4rem;
max-width: 80%;
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 0.95rem;
color: #666;
letter-spacing: 0.05em;
text-align: center;
line-height: 1.6;
opacity: 0;
transition: opacity 1.2s ease;
pointer-events: none;
z-index: 50;
background: #f9f7f4;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px;
}
.first-here.visible {
opacity: 1;
}
/* small triangular "tail" pointing down from the bubble toward the
location marker beneath it */
.first-here::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #f9f7f4;
}
</style> </style>

View File

@@ -0,0 +1,63 @@
<script>
// On/off control for "sensing mode" (compass arrow + proximity vibration,
// see MapView.svelte). `checked` is owned by the parent (+page.svelte) -
// this component is just the button and reports taps via `onchange`,
// rather than holding its own state, so the parent can do the iOS
// permission request (which must happen inside this same click handler -
// see +page.svelte) before actually flipping the value.
let { checked = false, onchange } = $props();
</script>
<!-- same small round icon-button style as MapView's .trail-toggle - .active
just shifts it from muted grey to the app's darker text color, matching
how .trail-toggle.active works -->
<button
type="button"
class="sensing-toggle"
class:active={checked}
aria-label={checked ? 'Turn off sensing mode' : 'Turn on sensing mode'}
title={checked ? 'Turn off sensing mode' : 'Turn on sensing mode'}
onclick={() => onchange?.(!checked)}
>
<!-- compass icon: outer ring + a needle pointing toward an off-screen
pin, echoing the compass-arrow feature this toggle controls -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6.5"/>
<path d="M10.3 5.7 L8.6 8.6 L5.7 10.3 L7.4 7.4 Z" fill="currentColor" stroke-width="0"/>
</svg>
</button>
<style>
/* directly above the FAB (mobile-only - this component is only rendered
when isMobile, see +page.svelte) - same fixed-position math as
.sensing-toggle-wrap used previously: FAB's own bottom offset (5rem) +
FAB's height (56px) + a small gap. */
.sensing-toggle {
position: fixed;
bottom: calc(5rem + 56px + 0.75rem);
right: 1.5rem;
z-index: 150;
/* same small circular icon-button look as MapView's .trail-toggle -
flat 2D style (no shadow), muted/translucent so it doesn't compete
with the FAB below it. */
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.7);
color: #aaa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: color 0.15s, border-color 0.15s;
}
/* "on" state: same darken-on-active treatment as .trail-toggle.active */
.sensing-toggle.active {
color: #666;
border-color: rgba(0, 0, 0, 0.15);
}
</style>

View File

@@ -0,0 +1,230 @@
<script>
// Share button + popover for the message detail view (BottomSheet on
// mobile, SidePanel on desktop) - shared between both so the link/QR
// logic and styling only live in one place.
import QRCode from 'qrcode';
// --- portal action --------------------------------------------------------
// On mobile, this component is rendered inside BottomSheet's `.sheet`,
// which has a `transform` (its slide-up/down animation). A `transform` on
// an ancestor creates its own containing block for `position: fixed`
// descendants, so the popover's `top: 50%`/`left: 50%` were being measured
// against `.sheet`'s box (anchored to the bottom of the screen) instead of
// the actual viewport - centering it too low and cutting it off at the
// bottom. Moving the popover/backdrop to a direct child of <body> (which
// has no transform) restores normal viewport-relative `position: fixed`
// centering on both mobile and desktop.
function portal(node) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
}
};
}
let { message } = $props();
// popover open/closed, the generated QR code image, and "copied" feedback
let open = $state(false);
let qrDataUrl = $state(null);
let copied = $state(false);
// --- share URL ----------------------------------------------------------
// `?message=<id>` on the root route, e.g. https://yourapp.web.app/?message=abc123.
// A query param on `/` (rather than a new /message/[id] route) was chosen
// because `/` already does everything opening a shared message needs - it
// loads the map, gets the user's location, etc. +page.svelte's auto-open
// logic just reads this param once the map has loaded (see its onMount/
// $effect) and sets mapStore.selectedMessage, then clears the param.
// window.location.origin is only available in the browser, but this is
// only ever read after a click (openShare below), so it's never evaluated
// during SSR.
let shareUrl = $derived(
message ? `${window.location.origin}/?message=${message.id}` : ''
);
// Generates the QR code as soon as the popover opens, rather than eagerly
// for every message (most messages are never shared, so there's no point
// paying the encoding cost up front).
// QRCode.toDataURL renders the code as a PNG and returns it as a base64
// data: URL - the simplest way to get it into an <img src>, with no
// <canvas> element or ref to manage. `qrcode` is a small, dependency-free
// client-side library, so this works fully offline and never calls out to
// a third-party QR-generation API.
async function openShare() {
open = true;
copied = false;
qrDataUrl = await QRCode.toDataURL(shareUrl, { width: 180, margin: 1 });
}
function closeShare() {
open = false;
}
async function copyLink() {
await navigator.clipboard.writeText(shareUrl);
copied = true;
setTimeout(() => { copied = false; }, 2000);
}
</script>
<!-- icon-only button, sized to sit alongside Echo/Let go in .actions -->
<button
type="button"
class="share-button"
onclick={openShare}
aria-label="Share this message"
title="Share this message"
>
<!-- "export"-style share icon: arrow up out of a tray -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 16V4" />
<path d="M7 8 L12 3 L17 8" />
<path d="M5 12v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6" />
</svg>
</button>
{#if open}
<!-- use:portal moves this whole group to a direct child of <body>, so the
fixed-position backdrop/popover below center on the real viewport
instead of BottomSheet's transformed `.sheet` (see the portal action
above) -->
<div use:portal>
<!-- full-screen button (not a div) so it's keyboard-focusable, same
click-outside-to-close pattern as ComposeSheet's .backdrop -->
<button class="share-backdrop" onclick={closeShare} aria-label="Close share popover"></button>
<div class="share-popover">
<button class="share-close" onclick={closeShare} aria-label="Close">×</button>
<p class="share-title">Share this message</p>
{#if qrDataUrl}
<img class="share-qr" src={qrDataUrl} alt="QR code linking to this message" />
{/if}
<div class="share-link-row">
<!-- readonly input rather than plain text so the URL is easy to
select/copy manually too, as a fallback to the button below -->
<input
class="share-link-input"
type="text"
readonly
value={shareUrl}
onclick={(e) => e.target.select()}
/>
<button class="share-copy" onclick={copyLink}>
{copied ? 'Copied!' : 'Copy link'}
</button>
</div>
</div>
</div>
{/if}
<style>
/* small square icon button, matching the flat 2D style of
.echo-button/.letgo-button but not flex: 1 - it's a secondary action
alongside the two main ones */
.share-button {
flex: 0 0 auto;
width: 44px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: #111;
border: 1.5px solid #ddd;
border-radius: 10px;
cursor: pointer;
}
/* dimmed backdrop behind the popover, click to close - same approach as
ComposeSheet's .backdrop, raised above it (z-index 350) since the
popover can be opened from inside the compose-less message view but
should still sit above any other overlay */
.share-backdrop {
border: none;
cursor: default;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 400;
}
/* small centered card, styled consistently with the app's pastel/
parchment palette (white card, soft rounded corners, muted text) */
.share-popover {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 401;
background: #fdfaf5;
border-radius: 16px;
padding: 1.25rem;
width: 260px;
max-width: 90vw;
text-align: center;
font-family: Georgia, 'Times New Roman', Times, serif;
}
.share-close {
position: absolute;
top: 0.5rem;
right: 0.75rem;
background: none;
border: none;
font-size: 1.2rem;
line-height: 1;
color: #bbb;
cursor: pointer;
padding: 0.25rem;
}
.share-title {
margin: 0 0 0.9rem;
font-size: 0.95rem;
color: #555;
}
/* QR code rendered at a modest, readable size - not the full card width */
.share-qr {
width: 160px;
height: 160px;
border-radius: 8px;
margin: 0 auto 0.9rem;
display: block;
}
.share-link-row {
display: flex;
gap: 0.4rem;
}
.share-link-input {
flex: 1;
min-width: 0;
font-family: sans-serif;
font-size: 0.75rem;
color: #888;
background: #f5f1ea;
border: 1px solid #eee;
border-radius: 8px;
padding: 0.4rem 0.5rem;
}
.share-copy {
flex-shrink: 0;
font-family: sans-serif;
font-size: 0.75rem;
font-weight: 500;
background: #c4a8f5;
color: white;
border: none;
border-radius: 8px;
padding: 0.4rem 0.6rem;
cursor: pointer;
}
</style>

View File

@@ -9,6 +9,15 @@
import { getDecayInfo } from '$lib/utils/time.js'; import { getDecayInfo } from '$lib/utils/time.js';
import { echoMessage } from '$lib/firebase/messages.js'; import { echoMessage } from '$lib/firebase/messages.js';
import { playEchoTone } from '$lib/utils/sound.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 // currently-selected message; when set, the detail view is shown instead of the list
let { message } = $props(); let { message } = $props();
@@ -18,6 +27,53 @@
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null 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 // only show the 3 most recent nearby messages, so the list view fits
// without scrolling and the hint card stays visible underneath it // without scrolling and the hint card stays visible underneath it
let topMessages = $derived( let topMessages = $derived(
@@ -26,6 +82,11 @@
.slice(0, 3) .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 // back to the list view
function close() { function close() {
mapStore.set({ selectedMessage: null, composing: false }); mapStore.set({ selectedMessage: null, composing: false });
@@ -33,10 +94,20 @@
async function handleEcho() { async function handleEcho() {
await echoMessage(message.id); await echoMessage(message.id);
// engagement stat: Echo pressed
incrementEchoed();
// gentle ascending "thank you" tone confirming the echo went through // gentle ascending "thank you" tone confirming the echo went through
playEchoTone(); playEchoTone();
close(); // back to the list once echoed 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();
}
</script> </script>
<div class="panel"> <div class="panel">
@@ -44,6 +115,7 @@
<!-- detail view: shown for the selected message --> <!-- detail view: shown for the selected message -->
<button class="back-btn" onclick={close}> Back</button> <button class="back-btn" onclick={close}> Back</button>
<div class="detail-content" class:last-chance={isLastChance}>
{#if message.imageUrl} {#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" /> <img class="message-img" src={message.imageUrl} alt="message attachment" />
{/if} {/if}
@@ -51,22 +123,42 @@
<p class="message-text">{message.text}</p> <p class="message-text">{message.text}</p>
{#if decay} {#if decay}
{#if isLastChance}
<!-- "last chance" framing replaces the normal countdown text -->
<p class="meta last-chance-text">this is the last chance to keep this alive</p>
{:else}
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p> <p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
{/if} {/if}
{/if}
{#if showLastChancePrompt}
<!-- one-time prompt (see stats.js) - gently frames Echo as the way
to give this message more time, without being pushy. Dismissible
on its own, or implicitly dismissed by pressing Echo/Let go below. -->
<div class="last-chance-prompt">
<p>if this one means something, an echo will keep it here a little longer.</p>
<button class="dismiss-prompt" onclick={() => showLastChancePrompt = false} aria-label="dismiss">×</button>
</div>
{/if}
</div>
<div class="actions"> <div class="actions">
<button class="echo-button" onclick={handleEcho}> <button class="echo-button" onclick={handleEcho}>
Echo Echo
</button> </button>
<button class="letgo-button" onclick={close}> <button class="letgo-button" onclick={handleLetGo}>
Let go Let go
</button> </button>
<!-- share link + QR code popover (see SharePopover.svelte) -->
<SharePopover {message} />
</div> </div>
{:else} {:else}
<!-- list view: header, compose button, nearby messages --> <!-- list view: header, compose button, nearby messages -->
<div class="panel-header"> <div class="panel-header">
<h1>Overheard: Shared Secrets</h1> <h1>Overheard: Shared Secrets</h1>
<p class="subtitle">Messages near you</p> <!-- ambient presence indicator replacing the old static "Messages near
you" text - styling/font-size unchanged, only the copy is dynamic -->
<p class="subtitle">{presenceText}</p>
</div> </div>
<hr /> <hr />
@@ -115,6 +207,13 @@
<p>Messages appear as you explore</p> <p>Messages appear as you explore</p>
<p class="hint-sub">Tap the confetti on the map to read them</p> <p class="hint-sub">Tap the confetti on the map to read them</p>
</div> </div>
<!-- replaces the old top-right floating archive button (desktop only -
mobile already has an Archive tab in the bottom nav). styled to
match .compose-btn so the two pill buttons feel like a pair -->
<a href="/archive" class="archive-btn">
Your archive
</a>
{/if} {/if}
</div> </div>
@@ -183,6 +282,58 @@
color: #999; 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 { .actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -248,6 +399,30 @@
background: #b090e8; background: #b090e8;
} }
/* same pill-button look as .compose-btn, but on an <a> 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 { .section-label {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;

View File

@@ -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());
}

43
src/lib/utils/geo.js Normal file
View File

@@ -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;
}

View File

@@ -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 // are shorter than this prefix and the >= / < range query in getNearbyMessages
// never matches anything (this is why pins disappeared) // never matches anything (this is why pins disappeared)
export function encode(lat, lng) { 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 // 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 // reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes
// stored on existing messages (see encode() above) // stored on existing messages (see encode() above)
export function getQueryPrefix(lat, lng) { export function getQueryPrefix(lat, lng) {
return ngeohash.encode(lat, lng, 4); return ngeohash.encode(lat, lng, 6);
} }

View File

@@ -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' }]
}
];

View File

@@ -8,64 +8,49 @@ function randomPastel() {
}; };
} }
// turns an SVG string into a DOM element sized to `size` x `size` px. // picks the fill color for a pin: if the message has a moodColor (an
// AdvancedMarkerElement (the marker API this app uses) takes a DOM node // optional pastel color the author picked in ComposeSheet's swatch row,
// via its `content` option, not an icon/url like the older google.maps.Marker, // stored as an hsl(...) string), use that directly so the pin matches what
// so we inline the SVG as a data URL on an <img> instead of returning an icon object. // they chose. otherwise fall back to the existing random pastel exactly as
function svgToElement(svg, size) { // before. `glow` isn't currently rendered by any pin shape below, but is
const img = document.createElement('img'); // still returned for shape consistency with randomPastel().
img.src = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg); function pinColor(moodColor) {
img.style.width = `${size}px`; if (moodColor) {
img.style.height = `${size}px`; return { solid: moodColor, glow: moodColor };
// AdvancedMarkerElement anchors content by its bottom-center by default }
// (made for teardrop pins). Shift up by half the height so the *center* return randomPastel();
// 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;
} }
// star pin for unread messages: solid pastel star, no glow halo, flat fill // turns an SVG string into a { url, size } pair for google.maps.Marker's
export function unreadPin() { // `icon` option. Markers (the legacy marker API this app now uses, switched
const { solid } = randomPastel(); // back from AdvancedMarkerElement so a JS map `styles` array can take
const svg = ` // effect - AdvancedMarkerElement requires a mapId, which causes Google to
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60"> // ignore any `styles` array) take an Icon object - { url, scaledSize, anchor }
<polygon points="30,12 34,24 47,24 37,32 41,44 30,36 19,44 23,32 13,24 26,24" // - rather than a DOM node, so we just return the data-URL + size here and
fill="${solid}"/> // let MapView build the google.maps.Size/Point objects (those classes only
</svg>`; // exist once the Maps JS API has loaded).
// sized to match locationPin (32px) per latest request, so message pins // flat 2D style for now (no drop-shadow) - paper-style shadows may be added later
// are no longer larger than the current-location marker function svgToIcon(svg, size) {
return svgToElement(svg, 46); return {
url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg),
size
};
} }
// circle pin for read messages: solid pastel perfect circle, no glow halo // pin for every message: solid pastel perfect circle, no glow halo.
export function readPin() { // previously there were 3 shapes (star/circle/heart) for unread/read/echoed
const { solid } = randomPastel(); // 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 = ` const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50"> <svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20" fill="${solid}"/> <circle cx="25" cy="25" r="20" fill="${solid}"/>
</svg>`; </svg>`;
// sized to match locationPin (32px) per latest request, so message pins // sized to match locationPin (32px) per latest request, so message pins
// are no longer larger than the current-location marker // are no longer larger than the current-location marker
return svgToElement(svg, 28); return svgToIcon(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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 56 56">
<path d="M28,46 C28,46 10,33 10,20 C10,13 15,9 21,9 C25,9 28,12 28,17 C28,12 31,9 35,9 C41,9 46,13 46,20 C46,33 28,46 28,46 Z"
fill="${solid}"/>
</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);
} }
// fixed dark dot used for the user's own location (not randomized, no glow) // fixed dark dot used for the user's own location (not randomized, no glow)
@@ -75,5 +60,5 @@ export function locationPin() {
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/> <circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
<circle cx="16" cy="16" r="5" fill="white"/> <circle cx="16" cy="16" r="5" fill="white"/>
</svg>`; </svg>`;
return svgToElement(svg, 32); return svgToIcon(svg, 32);
} }

69
src/lib/utils/presence.js Normal file
View File

@@ -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`;
}

91
src/lib/utils/stats.js Normal file
View File

@@ -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
}
}
}

47
src/lib/utils/trail.js Normal file
View File

@@ -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;
}

View File

@@ -1,7 +1,8 @@
<script> <script>
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/stores';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { page } from '$app/stores'; // current route, used to hide the count pill on /archive
import GlobalCountPill from '$lib/components/GlobalCountPill.svelte';
let { children } = $props(); let { children } = $props();
@@ -77,6 +78,14 @@
block or delay anything underneath. --> block or delay anything underneath. -->
{@render children()} {@render children()}
<!-- global "memory counter" pill, top-right on every page EXCEPT /archive -
the archive is the user's own private list of messages, so the global
"everyone, everywhere" count would feel out of place there (see
GlobalCountPill.svelte for the pill itself). -->
{#if $page.url.pathname !== '/archive'}
<GlobalCountPill />
{/if}
<!-- one-time opening ritual overlay, first-ever visit only --> <!-- one-time opening ritual overlay, first-ever visit only -->
{#if ritualMounted} {#if ritualMounted}
<div class="ritual-overlay" class:visible={ritualVisible} aria-hidden="true"> <div class="ritual-overlay" class:visible={ritualVisible} aria-hidden="true">
@@ -84,15 +93,20 @@
</div> </div>
{/if} {/if}
<!-- floating link to the archive page, hidden while already on it --> <!-- ===================================================================
{#if $page.url.pathname !== '/archive'} GLOBAL TEXTURE OVERLAY (start)
<a href="/archive" class="archive-link">📁</a> A subtle parchment/paper grain over the ENTIRE app (was previously
{/if} just over the map - moved here per request). pointer-events: none
so it never blocks clicks/taps anywhere in the UI.
TO REMOVE: delete this <div> and the ".global-texture-overlay" CSS
block below - nothing else depends on either.
=================================================================== -->
<div class="global-texture-overlay" aria-hidden="true"></div>
<!-- GLOBAL TEXTURE OVERLAY (end) -->
<style> <style>
/* full-screen opening ritual overlay - soft cream background matching /* full-screen opening ritual overlay - soft cream background matching
the app's pastel palette, sits above everything (including the the app's pastel palette, sits above everything while it's visible */
.archive-link below at z-index 300) while it's visible */
.ritual-overlay { .ritual-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -131,20 +145,39 @@
} }
} }
.archive-link { /* =================================================================
GLOBAL TEXTURE OVERLAY (start) - see matching comment in the
markup above for removal instructions.
- position: fixed + inset: 0 covers the full viewport, regardless
of scroll position, on every page.
- z-index: 2000 sits above the opening ritual (1000) and every
other UI element (all of which use z-index 200 or less) - this
is intentional, since the texture is purely decorative and
pointer-events: none means it never intercepts input even when
it's the topmost element.
- mix-blend-mode: multiply + opacity: 0.08 + the warm #ecdfc4
background-color gently "ages" whatever's underneath (map,
white panels, sheets) without hiding any content. opacity
back down to 0.08 - the stronger feColorMatrix/tile-size
values below (tuned while testing at 0.10) keep the grain
from disappearing entirely at this lower opacity.
- background-image is the same inline SVG <feTurbulence> +
<feColorMatrix> grain used previously on just the map.
feColorMatrix scale 3.5 / offset -1.6 and background-size
110px give the grain a faint but present texture even at
0.08 opacity. */
.global-texture-overlay {
position: fixed; position: fixed;
top: 1rem; inset: 0;
right: 1rem; z-index: 2000;
background: white; pointer-events: none;
border-radius: 50%; opacity: 0.08;
width: 40px; mix-blend-mode: multiply;
height: 40px; background-color: #ecdfc4;
display: flex; background-image:
align-items: center; url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch' result='noise'/%3E%3CfeColorMatrix in='noise' type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3.5 -1.6'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
justify-content: center; background-size: 110px 110px;
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */ background-repeat: repeat;
text-decoration: none;
font-size: 1.1rem;
z-index: 300;
} }
/* GLOBAL TEXTURE OVERLAY (end) */
</style> </style>

View File

@@ -1,12 +1,15 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import MapView from '$lib/components/MapView.svelte'; import MapView from '$lib/components/MapView.svelte';
import SensingToggle from '$lib/components/SensingToggle.svelte';
import { getNearbyMessages } from '$lib/firebase/messages.js'; import { getNearbyMessages, hasAnyMessagesEverNearby, getMessageById } from '$lib/firebase/messages.js';
import { getDecayInfo } from '$lib/utils/time.js'; // checks whether a shared message has expired
import { messagesStore, setMessages } from '$lib/stores/messagesStore.js'; import { messagesStore, setMessages } from '$lib/stores/messagesStore.js';
import { mapStore } from '$lib/stores/mapStore.js'; import { mapStore } from '$lib/stores/mapStore.js';
import { initAuth } from '$lib/stores/userStore.js'; import { initAuth } from '$lib/stores/userStore.js';
import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab
import { replaceState } from '$app/navigation'; // clears the ?message= param after handling a shared link
import BottomSheet from '$lib/components/BottomSheet.svelte'; import BottomSheet from '$lib/components/BottomSheet.svelte';
import SidePanel from '$lib/components/SidePanel.svelte'; import SidePanel from '$lib/components/SidePanel.svelte';
@@ -21,10 +24,59 @@
let isMobile = $derived(windowWidth < 768); let isMobile = $derived(windowWidth < 768);
// --- "you're the first here" ambient message --------------------------
// `firstHereMounted` controls whether the message exists in the DOM at
// all - it's set true at most once, right after the initial
// getNearbyMessages() call resolves (see onMount below), and never
// re-checked again for the rest of this page load ("once per page load").
// `firstHereVisible` drives the CSS opacity fade transition, the same
// mount/visible split used by the opening ritual overlay in +layout.svelte.
let firstHereMounted = $state(false);
let firstHereVisible = $state(false);
// --- "sensing mode" (compass arrow + proximity vibration) ---------------
// Single combined toggle for both device-sensor features (see
// SensingToggle + MapView.svelte). Defaults to OFF: these features use
// device sensors (orientation, vibration, continuous location) more
// actively than the rest of the app, so they're an opt-in "mode" rather
// than always-on.
let sensingMode = $state(false);
// Turning the toggle ON is itself a user-interaction "click", which is
// exactly the context iOS Safari requires for
// DeviceOrientationEvent.requestPermission() - it must be called directly
// from a user gesture, so it has to happen here (in the toggle's click
// handler) rather than later inside MapView's effect. If the API doesn't
// exist at all (non-iOS), there's nothing to request - sensingMode just
// flips on and MapView's deviceorientation listener works without a
// permission prompt. If the user denies the prompt, sensingMode still
// turns on (proximity vibration doesn't need orientation), but no
// deviceorientation events will arrive, so the compass arrow simply never
// appears (arrowRotation stays null) - handled gracefully, no error shown.
async function handleSensingToggle(newValue) {
if (newValue && typeof DeviceOrientationEvent !== 'undefined'
&& typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
await DeviceOrientationEvent.requestPermission();
} catch {
// ignore - arrow just won't appear without orientation data
}
}
sensingMode = newValue;
}
onMount(() => { onMount(() => {
// sign this device in anonymously so messages can be linked to a persistent UID // sign this device in anonymously so messages can be linked to a persistent UID
initAuth(); initAuth();
// The map fills 100vh and all its controls (FAB, bottom nav, sheets)
// are position:fixed, so this page itself never needs to scroll.
// This used to be an `overflow: hidden` rule on :global(body) in the
// stylesheet below, but that leaked across client-side navigation and
// broke scrolling on /archive too - applying/removing it here scopes
// it to exactly this page's lifetime.
document.body.style.overflow = 'hidden';
if (!navigator.geolocation) { if (!navigator.geolocation) {
error = "Your browser doesn't support geolocation :("; error = "Your browser doesn't support geolocation :(";
return; // do nothing return; // do nothing
@@ -46,15 +98,166 @@
// previous list and plays a soft chime for any newly-appeared pins // previous list and plays a soft chime for any newly-appeared pins
setMessages(messages); setMessages(messages);
console.log('messages loaded:', $messagesStore); console.log('messages loaded:', $messagesStore);
// --- "you're the first here" zero-results check --------
// getNearbyMessages() already filters out anything past its
// 30-day decay, so messages.length === 0 alone just means
// "nothing ACTIVE nearby right now" - it doesn't distinguish
// "no one has ever posted here" from "people posted here
// before, but it's all since faded". To make that
// distinction (per the feature spec, "truly never posted"
// interpretation), we only fall back to
// hasAnyMessagesEverNearby() - a cheap count-only query over
// the same geohash area, including expired messages - when
// the active list comes back empty. The ambient message only
// appears if THAT also comes back empty, i.e. literally
// nothing has ever been left in this ~1.2km area.
if (messages.length === 0) {
const everPosted = await hasAnyMessagesEverNearby(position.coords.latitude, position.coords.longitude);
if (!everPosted) {
firstHereMounted = true;
}
}
} }
); );
// cleanup: undo the body overflow lock when this page unmounts
// (e.g. navigating to /archive), so other routes can scroll normally.
return () => {
document.body.style.overflow = '';
};
}); });
// Fade-in + auto-dismiss timing for the "first here" message - mirrors
// the opening ritual overlay's pattern in +layout.svelte: wait a frame so
// the element first paints at opacity 0, then fade in; after a short
// hold, fade out; then unmount entirely so it can't linger over the map.
$effect(() => {
if (!firstHereMounted) return;
const fadeInFrame = requestAnimationFrame(() => {
firstHereVisible = true;
});
const fadeInMs = 1200;
const holdMs = 4000; // "a few seconds" before it fades on its own
const fadeOutMs = 1200;
const fadeOutTimer = setTimeout(() => {
firstHereVisible = false;
}, fadeInMs + holdMs);
const removeTimer = setTimeout(() => {
firstHereMounted = false;
}, fadeInMs + holdMs + fadeOutMs);
return () => {
cancelAnimationFrame(fadeInFrame);
clearTimeout(fadeOutTimer);
clearTimeout(removeTimer);
};
});
// Early dismissal: as soon as the user starts interacting (opening the
// compose sheet), fade the message out immediately rather than waiting
// for the timers above - "disappear once the user starts interacting".
$effect(() => {
if (firstHereMounted && $mapStore.composing) {
firstHereVisible = false;
setTimeout(() => { firstHereMounted = false; }, 1200);
}
});
// --- auto-open a message shared via link/QR code (SharePopover.svelte) --
// `sharedLinkHandled` makes this run at most once per page load: the
// first time lat/lng are set (map has loaded) AND the URL has a
// `?message=<id>` param, it's flipped to true immediately, so later
// re-runs of this effect (e.g. lat/lng updating again) are no-ops.
let sharedLinkHandled = $state(false);
// "this one has already faded" ambient notice - same mount/visible split
// and fade timing as the "first here" message above, shown when a shared
// link points at a message that no longer exists or has expired.
let fadedNoticeMounted = $state(false);
let fadedNoticeVisible = $state(false);
$effect(() => {
if (sharedLinkHandled) return;
if (!(lat && lng)) return; // wait until the map has a location to center on
const sharedId = $page.url.searchParams.get('message');
sharedLinkHandled = true; // one-time entry behavior, regardless of outcome
if (!sharedId) return; // normal load, nothing to auto-open
(async () => {
// Direct Firestore lookup by document ID - bypasses
// getNearbyMessages's geohash filter entirely, since a shared
// message could have been left anywhere in the world, not just
// this device's ~1.2km area.
const shared = await getMessageById(sharedId);
const decay = shared ? getDecayInfo(shared.createdAt, shared.lastEchoAt) : null;
if (shared && decay && !decay.isExpired) {
mapStore.set({ selectedMessage: shared, composing: false });
} else {
// doesn't exist, or past its 30-day decay - graceful
// ambient notice instead of an error
fadedNoticeMounted = true;
}
// Clear the `message` param so this is a one-time entry
// behavior, not persistent state - reloading or sharing the
// current URL afterward won't re-trigger the auto-open.
// replaceState (rather than goto) swaps the URL without a
// navigation/history entry.
const url = new URL(window.location.href);
url.searchParams.delete('message');
replaceState(url, {});
})();
});
// fade-in + auto-dismiss timing for the "already faded" notice - mirrors
// the "first here" message's $effect above
$effect(() => {
if (!fadedNoticeMounted) return;
const fadeInFrame = requestAnimationFrame(() => {
fadedNoticeVisible = true;
});
const fadeInMs = 1200;
const holdMs = 4000;
const fadeOutMs = 1200;
const fadeOutTimer = setTimeout(() => {
fadedNoticeVisible = false;
}, fadeInMs + holdMs);
const removeTimer = setTimeout(() => {
fadedNoticeMounted = false;
}, fadeInMs + holdMs + fadeOutMs);
return () => {
cancelAnimationFrame(fadeInFrame);
clearTimeout(fadeOutTimer);
clearTimeout(removeTimer);
};
});
</script> </script>
<svelte:window bind:innerWidth={windowWidth} /> <!--this sends the windowWidth to our mobile checker --> <svelte:window bind:innerWidth={windowWidth} /> <!--this sends the windowWidth to our mobile checker -->
<!-- "this one has already faded" notice for a shared link/QR code pointing
at a message that no longer exists or has expired (see the
sharedLinkHandled $effect above) - ambient and auto-dismissing, same
fade pattern as the "first here" bubble, rather than an error page -->
{#if fadedNoticeMounted}
<p class="faded-notice" class:visible={fadedNoticeVisible} aria-hidden="true">
this one has already faded.
</p>
{/if}
{#if error} {#if error}
<p class="error">{error}</p> <p class="error">{error}</p>
{:else if !(lat && lng)} {:else if !(lat && lng)}
@@ -67,10 +270,27 @@
on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav --> on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav -->
{#if lat && lng} {#if lat && lng}
<div class="map-container" style="padding-bottom: {windowWidth < 768 ? '64px' : '0'}"> <div class="map-container" style="padding-bottom: {windowWidth < 768 ? '64px' : '0'}">
<MapView {lat} {lng} /> <!-- firstHereMounted/firstHereVisible (state + fade timing computed
in onMount/$effects above) are passed down so MapView can render
the "you're the first here" bubble positioned near the user's
location marker, rather than floating independently of the map.
sensingMode (toggled by SensingToggle below) drives MapView's
compass arrow + proximity vibration. -->
<MapView {lat} {lng} {firstHereMounted} {firstHereVisible} {sensingMode} />
</div> </div>
{/if} {/if}
<!-- "sensing mode" toggle: mobile-only (physical-world features don't make
sense on desktop), placed directly above the FAB so the two read as a
stack of floating controls in the bottom-right corner (positioning is
handled inside SensingToggle.svelte itself, like .trail-toggle in
MapView.svelte). Hidden whenever a message is open, same as the FAB
below - it sits right above the FAB, so it overlapped the open message
view (BottomSheet/SidePanel) the same way the FAB would have. -->
{#if isMobile && !$mapStore.selectedMessage}
<SensingToggle checked={sensingMode} onchange={handleSensingToggle} />
{/if}
<!-- show the right panel based on mobile or desktop--> <!-- show the right panel based on mobile or desktop-->
{#if windowWidth < 768} {#if windowWidth < 768}
<BottomSheet message={$mapStore.selectedMessage} /> <BottomSheet message={$mapStore.selectedMessage} />
@@ -80,57 +300,6 @@
<SidePanel message={$mapStore.selectedMessage} /> <SidePanel message={$mapStore.selectedMessage} />
{/if} {/if}
<!-- pin legend, desktop only.
icons are inline copies of the exact shapes/viewBoxes from pins.js
(unreadPin/readPin/echoedPin/locationPin) instead of unrelated emoji,
so the key visually matches what's on the map. message-pin icons use
one fixed pastel fill here (rather than pins.js's randomPastel()) since
a legend needs a single representative swatch, not a random one. -->
{#if !isMobile}
<div class="legend">
<!-- each icon sits in a fixed 36x36 .legend-icon-wrap so the text
column stays aligned, even though the svgs below are sized
differently from each other. the star/heart shapes occupy a
smaller fraction of their own viewBox than the two circles do
of theirs, so they need a bigger svg size to LOOK the same size
on screen - sizes below were picked by eye, not by matching
pixel dimensions -->
<div class="legend-item">
<span class="legend-icon-wrap">
<svg class="legend-icon" viewBox="0 0 32 32" width="23" height="23">
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
<circle cx="16" cy="16" r="5" fill="white"/>
</svg>
</span>
<span>You are here</span>
</div>
<div class="legend-item">
<span class="legend-icon-wrap">
<svg class="legend-icon" viewBox="0 0 60 60" width="36" height="36">
<polygon points="30,12 34,24 47,24 37,32 41,44 30,36 19,44 23,32 13,24 26,24" fill="hsl(265, 60%, 80%)"/>
</svg>
</span>
<span>Unread</span>
</div>
<div class="legend-item">
<span class="legend-icon-wrap">
<svg class="legend-icon" viewBox="0 0 50 50" width="25" height="25">
<circle cx="25" cy="25" r="20" fill="hsl(265, 60%, 80%)"/>
</svg>
</span>
<span>Read</span>
</div>
<div class="legend-item">
<span class="legend-icon-wrap">
<svg class="legend-icon" viewBox="0 0 56 56" width="30" height="30">
<path d="M28,46 C28,46 10,33 10,20 C10,13 15,9 21,9 C25,9 28,12 28,17 C28,12 31,9 35,9 C41,9 46,13 46,20 C46,33 28,46 28,46 Z" fill="hsl(265, 60%, 80%)"/>
</svg>
</span>
<span>Echoed</span>
</div>
</div>
{/if}
<!--floating action button for adding a message; hidden while a message is open--> <!--floating action button for adding a message; hidden while a message is open-->
{#if !$mapStore.composing && !$mapStore.selectedMessage} {#if !$mapStore.composing && !$mapStore.selectedMessage}
<button <button
@@ -177,7 +346,8 @@
:global(body) { :global(body) {
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: hidden; /* overflow: hidden moved into onMount above, scoped to this page's
lifetime - removed here so it doesn't leak into /archive. */
} }
.error, .loading { .error, .loading {
@@ -190,6 +360,31 @@
} }
/* "this one has already faded" notice - small pastel pill, top-center,
fades in/out via .visible (same opacity transition as .ritual-overlay in
+layout.svelte and .first-here in MapView.svelte) */
.faded-notice {
position: fixed;
top: 1.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 250;
margin: 0;
background: #fdfaf5;
color: #999;
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 0.85rem;
padding: 0.6rem 1.2rem;
border-radius: 50px;
opacity: 0;
transition: opacity 1.2s ease;
pointer-events: none;
}
.faded-notice.visible {
opacity: 1;
}
/* purple FAB; only rendered when nothing is composing/selected, so no /* purple FAB; only rendered when nothing is composing/selected, so no
"shifted" state is needed anymore (that rule has been removed) */ "shifted" state is needed anymore (that rule has been removed) */
.fab { .fab {
@@ -212,6 +407,15 @@
line-height: 1; line-height: 1;
} }
/* desktop has no bottom nav bar, so the 5rem bottom offset above (sized to
clear the mobile nav) leaves the FAB looking awkwardly high - bring it
down closer to the corner. mobile keeps the 5rem value from .fab above. */
@media (min-width: 768px) {
.fab {
bottom: 1.5rem;
}
}
/* mobile-only bottom nav bar with Map/Archive links */ /* mobile-only bottom nav bar with Map/Archive links */
.bottom-nav { .bottom-nav {
position: fixed; position: fixed;
@@ -261,46 +465,4 @@
} }
} }
/* floating legend explaining the map pin shapes/colors, desktop only.
anchored bottom-left, just to the right of the 340px side panel.
flat 2D style for now - shadow removed, paper-style shadows may be added later */
.legend {
position: fixed;
bottom: 1.5rem;
left: calc(340px + 4rem);
background: #f3f0fa;
border-radius: 14px;
padding: 0.9rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 200;
font-size: 0.85rem;
color: #444;
font-family: sans-serif;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.6rem;
}
/* fixed 36x36 box for every icon, centered, so the text after each icon
lines up in a consistent column regardless of each icon's own width/height
(which now vary so the shapes LOOK the same size - see template) */
.legend-icon-wrap {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* flat 2D style for now - drop-shadow filter removed, paper-style shadows may be added later */
.legend-icon {
display: block;
}
</style> </style>

View File

@@ -3,13 +3,19 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getMyMessages } from '$lib/firebase/messages.js'; import { getMyMessages } from '$lib/firebase/messages.js';
import { getDecayInfo } from '$lib/utils/time.js'; import { getDecayInfo } from '$lib/utils/time.js';
import { getStats } from '$lib/utils/stats.js';
let messages = $state([]); let messages = $state([]);
let loading = $state(true); let loading = $state(true);
// personal engagement stats (read/echoed/letGo) - purely local to this
// device (see stats.js), read once when the page mounts
let stats = $state({ read: 0, echoed: 0, letGo: 0 });
onMount(async () => { onMount(async () => {
messages = await getMyMessages(); messages = await getMyMessages();
loading = false; loading = false;
stats = getStats();
}); });
</script> </script>
@@ -20,6 +26,12 @@
<p class="subtitle">Everything you've left behind</p> <p class="subtitle">Everything you've left behind</p>
</div> </div>
<!-- personal engagement stats summary - a private mirror of how this
device has moved through the app, see stats.js -->
<p class="stats-line">
you've read {stats.read} messages, echoed {stats.echoed}, let {stats.letGo} fade.
</p>
{#if loading} {#if loading}
<div class="loading">Loading your messages...</div> <div class="loading">Loading your messages...</div>
@@ -96,6 +108,16 @@
color: #aaa; color: #aaa;
} }
/* personal engagement stats - calm/muted like .subtitle, with a slight
contemplative italic to set it apart as a private aside rather than
part of the page's structural header */
.stats-line {
font-size: 0.85rem;
color: #999;
font-style: italic;
margin: -0.5rem 0 2rem;
}
.loading { .loading {
text-align: center; text-align: center;
color: #aaa; color: #aaa;