diff --git a/src/lib/Firebase/config.js b/src/lib/Firebase/config.js
index 02b2c04..fa35e59 100644
--- a/src/lib/Firebase/config.js
+++ b/src/lib/Firebase/config.js
@@ -5,6 +5,7 @@
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
+import { getAuth } from 'firebase/auth';
import {
PUBLIC_FIREBASE_API_KEY,
PUBLIC_FIREBASE_AUTH_DOMAIN,
@@ -25,4 +26,5 @@ const firebaseConfig = {
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
-export const storage = getStorage(app);
\ No newline at end of file
+export const storage = getStorage(app);
+export const auth = getAuth(app); // anonymous auth, gives each device a persistent UID
\ No newline at end of file
diff --git a/src/lib/Firebase/messages.js b/src/lib/Firebase/messages.js
index 1802c02..5d5a6b3 100644
--- a/src/lib/Firebase/messages.js
+++ b/src/lib/Firebase/messages.js
@@ -1,5 +1,5 @@
import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; // tools for building and running db queries
-import { db } from './config'; // database connection
+import { db, auth } from './config'; // database connection + anonymous auth
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
import { doc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
import ngeohash from 'ngeohash';
@@ -45,24 +45,29 @@ export async function addMessage(lat, lng, text, imageUrl = ''){
const geohash = ngeohash.encode(lat, lng, 6);
await addDoc(collection(db, 'messages'), {
- text,
- imageUrl,
- lat,
+ text,
+ imageUrl,
+ lat,
lng,
- geohash,
+ geohash,
createdAt: serverTimestamp(),
lastEchoAt: serverTimestamp(),
echoCount: 0,
- sessionId: getSessionId()
+ // links the message to this device's persistent anonymous Firebase UID
+ authorId: auth.currentUser?.uid ?? 'anon'
});
}
-// session ID (temporary)
-function getSessionId() {
- let id = localStorage.getItem('overheard_session');
- if (!id) {
- id = crypto.randomUUID(); // created random useer id
- localStorage.setItem('oveheard_session', id);
- }
- return id;
+// fetch every message this device has authored, for the archive page
+export async function getMyMessages() {
+ const uid = auth.currentUser?.uid;
+ if (!uid) return []; // not signed in yet, nothing to show
+
+ const q = query(
+ collection(db, 'messages'),
+ where('authorId', '==', uid)
+ );
+
+ const snapshot = await getDocs(q);
+ return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
}
\ No newline at end of file
diff --git a/src/lib/components/BottomSheet.svelte b/src/lib/components/BottomSheet.svelte
index 04e9b4a..0d329dd 100644
--- a/src/lib/components/BottomSheet.svelte
+++ b/src/lib/components/BottomSheet.svelte
@@ -2,6 +2,7 @@
import { mapStore } from '$lib/stores/mapStore.js';
import { getDecayInfo } from '$lib/utils/time.js'
import { echoMessage } from '$lib/firebase/messages.js'
+ import { playEchoTone } from '$lib/utils/sound.js';
let { message } = $props();
@@ -14,6 +15,8 @@
async function handleEcho() {
await echoMessage(message.id);
echoed = true;
+ // gentle ascending "thank you" tone confirming the echo went through
+ playEchoTone();
}
let startY = 0; // where the swipe started
@@ -79,7 +82,7 @@
background: white;
border-radius: 20px 20px 0 0;
padding: 1rem 1.5rem 2rem;
- box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
+ /* flat 2D style for now - shadow removed, paper-style shadows may be added later */
transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
z-index: 100;
@@ -102,6 +105,7 @@
height: 4px;
background: #ddd;
border-radius: 2px;
+ /* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.message-text {
@@ -131,6 +135,7 @@
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
+ /* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.letgo-button {
@@ -142,6 +147,7 @@
border-radius: 10px;
font-size: 0.95rem;
cursor: pointer;
+ /* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
.echo-button.echoed {
@@ -155,6 +161,7 @@
object-fit: cover;
border-radius: 12px;
margin-bottom: 0.75rem;
+ /* flat 2D style for now - shadow removed, paper-style shadows may be added later */
}
@keyframes pulsate {
diff --git a/src/lib/components/ComposeSheet.svelte b/src/lib/components/ComposeSheet.svelte
index f43a644..b531846 100644
--- a/src/lib/components/ComposeSheet.svelte
+++ b/src/lib/components/ComposeSheet.svelte
@@ -1,12 +1,20 @@
-
+
+{#if !isMobile}
+
+
+{/if}
+
+
+
-
@@ -142,6 +162,8 @@
\ No newline at end of file
diff --git a/src/lib/components/SidePanel.svelte b/src/lib/components/SidePanel.svelte
index 2f8654f..21bfbe1 100644
--- a/src/lib/components/SidePanel.svelte
+++ b/src/lib/components/SidePanel.svelte
@@ -1,130 +1,332 @@
-
- {#if message}
-
- {#if message.imageUrl}
-
- {/if}
-
{message.text}
- {#if decay}
-
left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.
- {/if}
-
-
- {echoed ? 'Echoed' : 'Echo'}
-
- mapStore.set(
- {selectedMessage: null, composing: false})}>
- Let go
-
-
-
diff --git a/src/lib/stores/messagesStore.js b/src/lib/stores/messagesStore.js
index 2fce44e..ddb36aa 100644
--- a/src/lib/stores/messagesStore.js
+++ b/src/lib/stores/messagesStore.js
@@ -1,3 +1,22 @@
-import { writable } from 'svelte/store';
+import { writable, get } from 'svelte/store';
+import { playNewPinChime } from '$lib/utils/sound.js';
-export const messagesStore = writable([]); // the store will fill up when the page lloads and queries firestore
\ No newline at end of file
+export const messagesStore = writable([]); // the store will fill up when the page lloads and queries firestore
+
+// Replaces the store's contents with a fresh list of nearby messages, and
+// plays a soft chime if any pin in the new list wasn't present before -
+// covering both "someone else posted nearby" and "this device's own query
+// refreshed with new results".
+export function setMessages(newMessages) {
+ const previous = get(messagesStore);
+ const previousIds = new Set(previous.map((m) => m.id));
+ const hasNewPin = newMessages.some((m) => !previousIds.has(m.id));
+
+ // skip the chime on the very first load (previous list is empty) -
+ // otherwise every pin from the initial fetch would "ding" at once
+ if (hasNewPin && previous.length > 0) {
+ playNewPinChime();
+ }
+
+ messagesStore.set(newMessages);
+}
\ No newline at end of file
diff --git a/src/lib/stores/userStore.js b/src/lib/stores/userStore.js
new file mode 100644
index 0000000..0b2c7d9
--- /dev/null
+++ b/src/lib/stores/userStore.js
@@ -0,0 +1,20 @@
+import { writable } from 'svelte/store';
+import { auth } from '$lib/firebase/config.js';
+import { signInAnonymously, onAuthStateChanged } from 'firebase/auth';
+
+// tracks the current Firebase auth user (anonymous, persists across reloads on this device)
+export const userStore = writable({
+ uid: null,
+ ready: false
+});
+
+// signs the device in anonymously (if not already) and keeps userStore in sync
+export function initAuth() {
+ signInAnonymously(auth);
+
+ onAuthStateChanged(auth, (user) => {
+ if (user) {
+ userStore.set({ uid: user.uid, ready: true });
+ }
+ });
+}
diff --git a/src/lib/utils/geohash.js b/src/lib/utils/geohash.js
index 0c22f3d..6230e5c 100644
--- a/src/lib/utils/geohash.js
+++ b/src/lib/utils/geohash.js
@@ -1,6 +1,10 @@
import ngeohash from 'ngeohash'; // library that does the geohasing encoding/decoding yippee
// encodes the latitude/longitude pair to a 6 character string geohash (~1.2km radius)
+// reverted from 9 -> 6: must match the precision addMessage() writes to Firestore
+// (messages.js uses ngeohash.encode(lat, lng, 6)), otherwise stored geohashes
+// are shorter than this prefix and the >= / < range query in getNearbyMessages
+// never matches anything (this is why pins disappeared)
export function encode(lat, lng) {
return ngeohash.encode(lat, lng, 6);
}
@@ -9,7 +13,8 @@ export function encode(lat, lng) {
// basically like looking for all geohashes that start with this 4 characters
// it will include all geohashes in a ~40km radius of the given lat/lng pair
-// maybe we lessen the radius later but for now is good for testing
+// reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes
+// stored on existing messages (see encode() above)
export function getQueryPrefix(lat, lng) {
return ngeohash.encode(lat, lng, 4);
}
\ No newline at end of file
diff --git a/src/lib/utils/pins.js b/src/lib/utils/pins.js
new file mode 100644
index 0000000..ef5e453
--- /dev/null
+++ b/src/lib/utils/pins.js
@@ -0,0 +1,79 @@
+// generates a random pastel hue, returning both a solid fill and a
+// translucent version of the same color for the glow halo behind each pin
+function randomPastel() {
+ const hue = Math.floor(Math.random() * 360);
+ return {
+ solid: `hsl(${hue}, 60%, 72%)`,
+ glow: `hsla(${hue}, 60%, 72%, 0.25)`
+ };
+}
+
+// turns an SVG string into a DOM element sized to `size` x `size` px.
+// AdvancedMarkerElement (the marker API this app uses) takes a DOM node
+// via its `content` option, not an icon/url like the older google.maps.Marker,
+// so we inline the SVG as a data URL on an instead of returning an icon object.
+function svgToElement(svg, size) {
+ const img = document.createElement('img');
+ img.src = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg);
+ img.style.width = `${size}px`;
+ img.style.height = `${size}px`;
+ // AdvancedMarkerElement anchors content by its bottom-center by default
+ // (made for teardrop pins). Shift up by half the height so the *center*
+ // of our shapes lands on the actual coordinate instead.
+ img.style.transform = 'translateY(-50%)';
+ // flat 2D style for now (no drop-shadow) - paper-style shadows may be added later
+ return img;
+}
+
+// star pin for unread messages: solid pastel star, no glow halo, flat fill
+export function unreadPin() {
+ const { solid } = randomPastel();
+ const svg = `
+ `;
+ // sized to match locationPin (32px) per latest request, so message pins
+ // are no longer larger than the current-location marker
+ return svgToElement(svg, 46);
+}
+
+// circle pin for read messages: solid pastel perfect circle, no glow halo
+export function readPin() {
+ const { solid } = randomPastel();
+ const svg = `
+ `;
+ // sized to match locationPin (32px) per latest request, so message pins
+ // are no longer larger than the current-location marker
+ return svgToElement(svg, 28);
+}
+
+// heart pin for echoed messages: solid pastel heart, no glow halo
+export function echoedPin() {
+ const { solid } = randomPastel();
+ // replaced the old single-point teardrop path with an actual two-lobed
+ // heart: a notch/cleft at the top center (28,17) and a point at the
+ // bottom (28,46)
+ // dropped the white inner-highlight overlay - it gave the heart a
+ // ring/outline look instead of a solid fill, which the user didn't want
+ const svg = `
+ `;
+ // sized to match locationPin (32px) per latest request, so message pins
+ // are no longer larger than the current-location marker
+ return svgToElement(svg, 32);
+}
+
+// fixed dark dot used for the user's own location (not randomized, no glow)
+export function locationPin() {
+ const svg = `
+ `;
+ return svgToElement(svg, 32);
+}
diff --git a/src/lib/utils/sound.js b/src/lib/utils/sound.js
new file mode 100644
index 0000000..b528a94
--- /dev/null
+++ b/src/lib/utils/sound.js
@@ -0,0 +1,165 @@
+// Ambient sound design for Overheard, built entirely with the Web Audio API.
+// No audio files are loaded - every sound here is a sine wave generated on
+// the fly by an OscillatorNode and shaped by a GainNode "envelope".
+import { browser } from '$app/environment';
+
+// ---------------------------------------------------------------------
+// AudioContext setup + the "unlock on first interaction" workaround
+// ---------------------------------------------------------------------
+// Browsers (Chrome, Safari, Firefox) block audio from playing until the
+// page has received at least one user gesture (click/tap/keypress). An
+// AudioContext created before that gesture starts in a "suspended" state,
+// and any sound scheduled on it is silently dropped.
+//
+// To handle this without requiring a manual "enable sound" button, we:
+// 1. Lazily create a single shared AudioContext the first time it's needed
+// (getAudioContext below), instead of one at module load time.
+// 2. Attach one-time listeners for pointerdown/keydown/touchstart to the
+// window. The very first time the user interacts with the page at all,
+// we create/resume the shared AudioContext so it's "unlocked" before
+// any chime needs to play.
+// 3. Every play function also calls ctx.resume() defensively - resuming an
+// already-running context is a harmless no-op, but if the context is
+// still suspended (e.g. a chime fires before any interaction happened),
+// this gives it another chance to start once a gesture has occurred.
+//
+// Net effect: sound is "on" by default with no toggle, and simply stays
+// silent until the user has interacted with the page once - which matches
+// what every browser requires anyway.
+
+let audioCtx = null;
+
+function getAudioContext() {
+ if (!browser) return null; // never run on the server during SSR
+
+ if (!audioCtx) {
+ // webkitAudioContext fallback covers older Safari
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
+ audioCtx = new AudioContextClass();
+ }
+
+ // no-op if already running; otherwise attempts to start playback -
+ // this only actually succeeds once a user gesture has occurred
+ if (audioCtx.state === 'suspended') {
+ audioCtx.resume();
+ }
+
+ return audioCtx;
+}
+
+if (browser) {
+ // One-time "unlock" listeners: the first tap/click/keypress anywhere on
+ // the page creates (or resumes) the shared AudioContext so it's ready
+ // before the user does anything that should make a sound (e.g. echoing
+ // a message). { once: true } removes each listener after it fires.
+ const unlockAudio = () => getAudioContext();
+ window.addEventListener('pointerdown', unlockAudio, { once: true });
+ window.addEventListener('keydown', unlockAudio, { once: true });
+ window.addEventListener('touchstart', unlockAudio, { once: true });
+}
+
+// ---------------------------------------------------------------------
+// Shared helper: play a single sine-wave tone with an attack/decay envelope
+// ---------------------------------------------------------------------
+// A "click"-free tone needs its volume to ramp up and back down smoothly
+// rather than switching on/off instantly (an instant on/off creates an
+// audible pop). We do this with a GainNode whose gain value we schedule
+// over time - this is the "envelope".
+//
+// ctx - the shared AudioContext
+// frequency - pitch of the tone, in Hz
+// startTime - when (in ctx.currentTime seconds) the tone should begin
+// duration - total length of the tone, in seconds
+// peakGain - maximum volume (0-1) reached during the attack
+function playTone(ctx, frequency, startTime, duration, peakGain) {
+ const oscillator = ctx.createOscillator();
+ const gainNode = ctx.createGain();
+
+ // 'sine' is the smoothest, purest waveform Web Audio offers - no harsh
+ // overtones, which keeps the chime feeling soft/ambient rather than
+ // buzzy (a square or sawtooth wave would sound much more aggressive).
+ oscillator.type = 'sine';
+ oscillator.frequency.value = frequency;
+
+ // Envelope: start silent, quickly fade in (attack), then fade back out
+ // to silence (decay) before the oscillator stops. The very short attack
+ // (10ms) avoids a click at the start, and the longer exponential decay
+ // gives the "ding" its natural-sounding tail.
+ const attackTime = 0.01; // 10ms fade-in - fast enough to feel instant, slow enough to avoid a click
+ gainNode.gain.setValueAtTime(0.0001, startTime); // start essentially silent
+ gainNode.gain.exponentialRampToValueAtTime(peakGain, startTime + attackTime); // quick fade in to peak volume
+ // exponential ramps can't target exactly 0, so we ramp down to a
+ // near-silent value (0.0001) by the end of the tone's duration
+ gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + duration);
+
+ // oscillator -> gain -> speakers
+ oscillator.connect(gainNode);
+ gainNode.connect(ctx.destination);
+
+ oscillator.start(startTime);
+ oscillator.stop(startTime + duration);
+}
+
+// ---------------------------------------------------------------------
+// Feature 1: new pin chime
+// ---------------------------------------------------------------------
+// A single soft "ding" played whenever a new message pin appears nearby,
+// whether posted by someone else or revealed by this device's own refresh.
+export function playNewPinChime() {
+ const ctx = getAudioContext();
+ if (!ctx) return; // SSR or AudioContext unavailable - do nothing
+
+ const now = ctx.currentTime;
+
+ // A5 (880Hz) is high enough to feel light/bell-like without being shrill,
+ // and a single note keeps the chime unobtrusive - just a soft notice
+ // rather than a full melody.
+ const frequency = 880;
+
+ // 0.4s total: comfortably inside the requested 0.3-0.5s range. Most of
+ // that time is the decay tail, so it reads as one quick "ding" rather
+ // than a sustained tone.
+ const duration = 0.4;
+
+ // Peak volume of 0.06 (out of a possible 0-1) keeps this firmly in
+ // "ambient/background" territory - audible but never jarring, even if
+ // several pins load in quick succession.
+ const peakGain = 0.06;
+
+ playTone(ctx, frequency, now, duration, peakGain);
+}
+
+// ---------------------------------------------------------------------
+// Feature 2: echo confirmation tone
+// ---------------------------------------------------------------------
+// A gentle three-note ascending arpeggio played when the user successfully
+// echoes a message - a small musical "thank you".
+export function playEchoTone() {
+ const ctx = getAudioContext();
+ if (!ctx) return; // SSR or AudioContext unavailable - do nothing
+
+ const now = ctx.currentTime;
+
+ // C5 -> E5 -> G5: a major triad arpeggio. Ascending pitches read as
+ // positive/affirming (vs. a descending run, which tends to sound like
+ // an error or "closing" cue), and a major chord feels warm rather than
+ // tense.
+ const notes = [523.25, 659.25, 783.99]; // C5, E5, G5 in Hz
+
+ // Each note is short and they overlap slightly (0.14s apart vs. each
+ // lasting 0.25s), which makes the arpeggio feel like one fluid gesture
+ // rather than three separate beeps. 2 gaps * 0.14s + final note's
+ // 0.25s duration = 0.53s total - comfortably under the 1-second limit.
+ const noteSpacing = 0.14; // seconds between each note's start time
+ const noteDuration = 0.25; // seconds each individual note lasts
+
+ // Slightly lower peak gain than the pin chime since three overlapping
+ // notes add up to more total energy than one - keeps the overall
+ // loudness in the same soft "ambient" range.
+ const peakGain = 0.05;
+
+ notes.forEach((frequency, index) => {
+ const startTime = now + index * noteSpacing;
+ playTone(ctx, frequency, startTime, noteDuration, peakGain);
+ });
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 5c4f0f7..a81e2e3 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,11 +1,150 @@
+
{@render children()}
+
+
+{#if ritualMounted}
+
+
Some things are only true if you're standing here.
+
+{/if}
+
+
+{#if $page.url.pathname !== '/archive'}
+ 📁
+{/if}
+
+
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 6920698..1cd95d2 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -3,8 +3,10 @@
import MapView from '$lib/components/MapView.svelte';
import { getNearbyMessages } from '$lib/firebase/messages.js';
- import { messagesStore } from '$lib/stores/messagesStore.js';
+ import { messagesStore, setMessages } from '$lib/stores/messagesStore.js';
import { mapStore } from '$lib/stores/mapStore.js';
+ import { initAuth } from '$lib/stores/userStore.js';
+ import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab
import BottomSheet from '$lib/components/BottomSheet.svelte';
import SidePanel from '$lib/components/SidePanel.svelte';
@@ -20,6 +22,9 @@
let isMobile = $derived(windowWidth < 768);
onMount(() => {
+ // sign this device in anonymously so messages can be linked to a persistent UID
+ initAuth();
+
if (!navigator.geolocation) {
error = "Your browser doesn't support geolocation :(";
return; // do nothing
@@ -37,7 +42,9 @@
navigator.geolocation.getCurrentPosition(
async (position) => {
const messages = await getNearbyMessages(position.coords.latitude, position.coords.longitude);
- messagesStore.set(messages);
+ // setMessages (instead of messagesStore.set) compares against the
+ // previous list and plays a soft chime for any newly-appeared pins
+ setMessages(messages);
console.log('messages loaded:', $messagesStore);
}
);
@@ -50,38 +57,119 @@
{#if error}
{error}
-{:else if lat && lng}
-
-{:else}
+{:else if !(lat && lng)}
Looking for you...
{/if}
-
- {#if lat && lng}
-
+
+{#if lat && lng}
+