Cooler features and many many fixes later
This commit is contained in:
@@ -1,9 +1,20 @@
|
||||
import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; // tools for building and running db queries
|
||||
import { collection, query, where, getDocs, addDoc, getCountFromServer } from 'firebase/firestore'; // tools for building and running db queries
|
||||
import { db, auth } from './config'; // database connection + anonymous auth
|
||||
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
|
||||
import { doc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
|
||||
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
|
||||
import { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
|
||||
import ngeohash from 'ngeohash';
|
||||
|
||||
// --- global "memory counter" -----------------------------------------------
|
||||
// A single document (meta/stats) holds `totalMessagesEverPosted`: a running
|
||||
// total of every message ever created in the whole app, across all areas -
|
||||
// including ones that have since expired/decayed and dropped out of
|
||||
// getNearbyMessages's "active" results. It's deliberately a single shared
|
||||
// doc, separate from individual message documents, because it represents a
|
||||
// different thing: not "what's currently visible/active" (which shrinks as
|
||||
// messages decay) but "everything that has ever been said here" (which only
|
||||
// grows). See addMessage() and getTotalMessageCount() below.
|
||||
const statsRef = doc(db, 'meta', 'stats');
|
||||
|
||||
export async function getNearbyMessages(lat, lng) {
|
||||
const prefix = getQueryPrefix(lat, lng);
|
||||
|
||||
@@ -31,6 +42,31 @@ export async function getNearbyMessages(lat, lng) {
|
||||
return active;
|
||||
}
|
||||
|
||||
// --- "you're the first here" check (+page.svelte) -------------------------
|
||||
// getNearbyMessages() above already fetches every doc matching the geohash
|
||||
// prefix into `all`, then filters out anything past its 30-day decay into
|
||||
// `active`. So `active.length === 0` alone can't tell apart two very
|
||||
// different situations: "nobody has EVER posted in this ~1.2km area" vs
|
||||
// "people posted here before, but everything since faded/expired".
|
||||
// Rather than re-fetching and re-filtering all those documents again, this
|
||||
// runs the SAME geohash range query through getCountFromServer - a
|
||||
// Firestore aggregation query that returns just a number without
|
||||
// transferring any document data, so it stays cheap even though it repeats
|
||||
// the query. Returns true if at least one message (active or expired) has
|
||||
// ever existed in this area.
|
||||
export async function hasAnyMessagesEverNearby(lat, lng) {
|
||||
const prefix = getQueryPrefix(lat, lng);
|
||||
|
||||
const q = query(
|
||||
collection(db, 'messages'),
|
||||
where('geohash', '>=', prefix),
|
||||
where('geohash', '<', prefix + 'z')
|
||||
);
|
||||
|
||||
const snapshot = await getCountFromServer(q);
|
||||
return snapshot.data().count > 0;
|
||||
}
|
||||
|
||||
// update the echo counter
|
||||
export async function echoMessage(messageId) {
|
||||
const ref = doc(db, 'messages', messageId);
|
||||
@@ -40,8 +76,12 @@ export async function echoMessage(messageId) {
|
||||
});
|
||||
}
|
||||
|
||||
// adding the message location
|
||||
export async function addMessage(lat, lng, text, imageUrl = ''){
|
||||
// adding the message location
|
||||
// moodColor (optional): the author's picked pastel swatch from ComposeSheet's
|
||||
// mood-color row, as an hsl(...) string - or null if they didn't pick one.
|
||||
// stored as-is on the message doc; pins.js falls back to a random pastel
|
||||
// when this is null (see pinColor() in pins.js).
|
||||
export async function addMessage(lat, lng, text, imageUrl = '', moodColor = null){
|
||||
const geohash = ngeohash.encode(lat, lng, 6);
|
||||
|
||||
await addDoc(collection(db, 'messages'), {
|
||||
@@ -50,12 +90,68 @@ export async function addMessage(lat, lng, text, imageUrl = ''){
|
||||
lat,
|
||||
lng,
|
||||
geohash,
|
||||
moodColor,
|
||||
createdAt: serverTimestamp(),
|
||||
lastEchoAt: serverTimestamp(),
|
||||
echoCount: 0,
|
||||
// links the message to this device's persistent anonymous Firebase UID
|
||||
authorId: auth.currentUser?.uid ?? 'anon'
|
||||
});
|
||||
|
||||
// --- global "memory counter": increment-only ----------------------------
|
||||
// Same increment(1) pattern as echoCount above, but on the shared
|
||||
// meta/stats doc instead of this message's own doc. setDoc with
|
||||
// { merge: true } both creates meta/stats on the very first call (if it
|
||||
// doesn't exist yet - increment() treats a missing field as starting from
|
||||
// 0) and increments the existing field on every call after that. This
|
||||
// counter never decrements: when a message later expires/decays out of
|
||||
// getNearbyMessages's active results, nothing here is touched, so the
|
||||
// total keeps representing everything ever posted, not just what's
|
||||
// currently visible.
|
||||
// Wrapped in try/catch so a failure here (e.g. security rules not yet
|
||||
// deployed for meta/stats) never breaks the actual posting of the
|
||||
// message above - the counter is a nice-to-have, not core functionality.
|
||||
try {
|
||||
await setDoc(statsRef, { totalMessagesEverPosted: increment(1) }, { merge: true });
|
||||
} catch (err) {
|
||||
console.error('[memory counter] failed to increment meta/stats:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch the current value of the global "memory counter" (meta/stats), for
|
||||
// the GlobalCountPill. Returns 0 if no message has ever been posted yet (the
|
||||
// doc won't exist until the very first addMessage() call above), or if the
|
||||
// read fails for any reason (e.g. security rules not yet deployed) - the pill
|
||||
// simply won't render in that case (see GlobalCountPill's $globalCount check).
|
||||
export async function getTotalMessageCount() {
|
||||
try {
|
||||
const snap = await getDoc(statsRef);
|
||||
return snap.exists() ? (snap.data().totalMessagesEverPosted ?? 0) : 0;
|
||||
} catch (err) {
|
||||
console.error('[memory counter] failed to read meta/stats:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- direct by-ID lookup, for shared links/QR codes (SharePopover.svelte,
|
||||
// +page.svelte's auto-open-on-load logic) ----------------------------------
|
||||
// getNearbyMessages() above can only ever find messages inside the current
|
||||
// device's ~1.2km geohash prefix - a message shared as a link/QR code may
|
||||
// have been left anywhere in the world, so opening it can't go through that
|
||||
// geohash filter at all. getDoc-by-ID is a direct document lookup that
|
||||
// works regardless of geohash, at the cost of needing the exact message ID
|
||||
// (which is exactly what the shared link/QR encodes). Returns null (rather
|
||||
// than throwing) if the doc doesn't exist or the read fails, so callers can
|
||||
// show a graceful "this one has already faded" message instead of an error.
|
||||
export async function getMessageById(id) {
|
||||
try {
|
||||
const ref = doc(db, 'messages', id);
|
||||
const snap = await getDoc(ref);
|
||||
return snap.exists() ? { id: snap.id, ...snap.data() } : null;
|
||||
} catch (err) {
|
||||
console.error('[share link] failed to fetch message by id:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// fetch every message this device has authored, for the archive page
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
import { getDecayInfo } from '$lib/utils/time.js'
|
||||
import { echoMessage } from '$lib/firebase/messages.js'
|
||||
import { playEchoTone } from '$lib/utils/sound.js';
|
||||
import SharePopover from '$lib/components/SharePopover.svelte';
|
||||
import {
|
||||
incrementRead,
|
||||
incrementEchoed,
|
||||
incrementLetGo,
|
||||
hasSeenLastChancePrompt,
|
||||
markLastChancePromptSeen
|
||||
} from '$lib/utils/stats.js';
|
||||
|
||||
let { message } = $props();
|
||||
|
||||
@@ -10,15 +18,74 @@
|
||||
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
||||
);
|
||||
|
||||
// --- "last chance" state ----------------------------------------------
|
||||
// A message is on its genuinely LAST day if it has 1 (or 0) days left
|
||||
// AND isn't already expired/faded - daysLeft <= 1 && !isExpired.
|
||||
// getDecayInfo() derives daysLeft from lastEchoAt, so a message that was
|
||||
// recently echoed already has a much higher daysLeft and won't trigger
|
||||
// this - "hasn't been echoed recently enough to have more time" is
|
||||
// automatically satisfied whenever this condition is true.
|
||||
let isLastChance = $derived(
|
||||
decay ? (decay.daysLeft <= 1 && !decay.isExpired) : false
|
||||
);
|
||||
|
||||
let echoed = $state(false);
|
||||
|
||||
// one-time "last chance" prompt for the currently-open message
|
||||
let showLastChancePrompt = $state(false);
|
||||
|
||||
// tracks which message was open last time this effect ran, so the
|
||||
// read-count + prompt logic below only fires once per "open" (not on
|
||||
// every reactive re-run while the same message stays selected)
|
||||
let previousMessageId = null;
|
||||
|
||||
$effect(() => {
|
||||
const currentId = message?.id ?? null;
|
||||
|
||||
if (currentId && currentId !== previousMessageId) {
|
||||
// --- engagement stat: a detail view was just opened ----------
|
||||
incrementRead();
|
||||
|
||||
// --- one-time "last chance" prompt ----------------------------
|
||||
// only show it if this message is CURRENTLY in its last-chance
|
||||
// state AND this device hasn't already seen the prompt for this
|
||||
// message ID. Marking it seen immediately (rather than waiting
|
||||
// for an explicit dismiss) means it won't reappear even if the
|
||||
// user navigates away without closing it.
|
||||
if (isLastChance && !hasSeenLastChancePrompt(currentId)) {
|
||||
showLastChancePrompt = true;
|
||||
markLastChancePromptSeen(currentId);
|
||||
} else {
|
||||
showLastChancePrompt = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentId) {
|
||||
showLastChancePrompt = false; // closed - reset for the next open
|
||||
}
|
||||
|
||||
previousMessageId = currentId;
|
||||
});
|
||||
|
||||
async function handleEcho() {
|
||||
await echoMessage(message.id);
|
||||
echoed = true;
|
||||
// engagement stat: Echo pressed
|
||||
incrementEchoed();
|
||||
// taking action dismisses the last-chance prompt for this message
|
||||
showLastChancePrompt = false;
|
||||
// gentle ascending "thank you" tone confirming the echo went through
|
||||
playEchoTone();
|
||||
}
|
||||
|
||||
// Let go's behavior is unchanged (just closes the sheet) - this only
|
||||
// adds the engagement-stat increment and prompt dismissal alongside it
|
||||
function handleLetGo() {
|
||||
incrementLetGo();
|
||||
showLastChancePrompt = false;
|
||||
mapStore.set({ selectedMessage: null, composing: false });
|
||||
}
|
||||
|
||||
let startY = 0; // where the swipe started
|
||||
|
||||
function startDrag(e) {
|
||||
@@ -49,25 +116,43 @@
|
||||
>
|
||||
<div class="handle"></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="content" class:last-chance={isLastChance}>
|
||||
{#if message.imageUrl}
|
||||
<img class="message-img" src={message.imageUrl} alt="message attachment" />
|
||||
{/if}
|
||||
<p class="message-text">{message.text}</p>
|
||||
{#if decay}
|
||||
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||
{#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>
|
||||
{/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">
|
||||
<button class="echo-button"
|
||||
class:echoed={echoed}
|
||||
onclick={handleEcho}
|
||||
<button class="echo-button"
|
||||
class:echoed={echoed}
|
||||
onclick={handleEcho}
|
||||
disabled={echoed}>
|
||||
{echoed ? 'Echoed' : 'Echo'}
|
||||
</button>
|
||||
<button class="letgo-button" onclick={() => mapStore.set(
|
||||
{ selectedMessage: null, composing: false })}>
|
||||
<button class="letgo-button" onclick={handleLetGo}>
|
||||
let go
|
||||
</button>
|
||||
<!-- share link + QR code popover (see SharePopover.svelte) -->
|
||||
<SharePopover {message} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -81,7 +166,12 @@
|
||||
right: 0;
|
||||
background: white;
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 1rem 1.5rem 2rem;
|
||||
/* extra 64px of bottom padding = the height of .bottom-nav in
|
||||
+page.svelte (z-index 200, above this sheet's 100), which sits on
|
||||
top of the sheet's bottom edge and was covering the Echo/Let go
|
||||
buttons. pushing the buttons up by that much keeps them clear of
|
||||
the nav bar instead of hidden behind it. */
|
||||
padding: 1rem 1.5rem calc(2rem + 64px);
|
||||
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
@@ -121,6 +211,58 @@
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
/* "last chance" treatment - a soft warm peach tint + border, distinct
|
||||
from the normal white card but staying within the existing pastel/
|
||||
parchment palette. A faint glow (box-shadow) adds a touch of warmth
|
||||
without being a jarring "alert" color like red. */
|
||||
.content.last-chance {
|
||||
background: #fff6ea;
|
||||
border: 1px solid #f3dcb8;
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem;
|
||||
margin: -0.25rem -0.25rem 0;
|
||||
box-shadow: 0 0 0 4px rgba(243, 184, 92, 0.08);
|
||||
}
|
||||
|
||||
/* replaces the normal "fading in X days" meta line when isLastChance */
|
||||
.last-chance-text {
|
||||
color: #b08a5a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* one-time prompt shown above the action buttons - same warm palette as
|
||||
.content.last-chance, slightly more saturated so it reads as the
|
||||
"active" element within the card */
|
||||
.last-chance-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
background: #fdf0db;
|
||||
border: 1px solid #f3dcb8;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
margin-bottom: 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
color: #9c7240;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.last-chance-prompt p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dismiss-prompt {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #c4a37a;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { mapStore } from '$lib/stores/mapStore.js';
|
||||
import { setMessages } from '$lib/stores/messagesStore.js';
|
||||
import { refreshGlobalCount } from '$lib/stores/globalCountStore.js';
|
||||
import { addMessage } from '$lib/firebase/messages.js'
|
||||
import { hasFace } from '$lib/utils/faceDetection.js';
|
||||
import { uploadImage } from '$lib/Firebase/storage.js';
|
||||
@@ -20,11 +21,109 @@
|
||||
let submitting = $state(false);
|
||||
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 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
|
||||
|
||||
// --- 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) {
|
||||
const file = event.target.files[0]; // only one file will be allowed so always index 0
|
||||
|
||||
@@ -76,7 +175,12 @@
|
||||
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
|
||||
const { getNearbyMessages } = await import ('$lib/firebase/messages.js');
|
||||
@@ -89,6 +193,7 @@
|
||||
text = '';
|
||||
selectedFile = null;
|
||||
imagePreview = null;
|
||||
selectedMoodColor = null;
|
||||
submitting = false;
|
||||
|
||||
// close the sheet
|
||||
@@ -117,11 +222,47 @@
|
||||
<span class="counter" class:over={remaining < 0}>{remaining}</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
bind:value={text}
|
||||
placeholder="Share a moment for someone else to overhear"
|
||||
rows="5"
|
||||
></textarea>
|
||||
<!-- wrapper gives the rotating-placeholder overlay below a positioning context -->
|
||||
<div class="textarea-wrap">
|
||||
<textarea
|
||||
bind:value={text}
|
||||
aria-label="Leave a message"
|
||||
onfocus={() => textareaFocused = true}
|
||||
onblur={() => textareaFocused = false}
|
||||
rows="5"
|
||||
></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">
|
||||
{#if checkingFace}
|
||||
@@ -265,6 +406,27 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
border: 1.5px solid #eee;
|
||||
@@ -288,6 +450,28 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
@@ -307,6 +491,35 @@
|
||||
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 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
137
src/lib/components/GlobalCountPill.svelte
Normal file
137
src/lib/components/GlobalCountPill.svelte
Normal 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>
|
||||
@@ -1,36 +1,109 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { env } from '$env/dynamic/public';
|
||||
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 { 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 longitude;
|
||||
// ^ 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 map = $state(null);
|
||||
|
||||
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)
|
||||
let readIds = new SvelteSet();
|
||||
// Marker/Size/Point/Polyline are only available once their libraries
|
||||
// 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 - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
|
||||
function addUserLocationMarker(centerLat, centerLng) {
|
||||
// use the shared locationPin() so the user's marker matches the
|
||||
// style of the message pins, instead of building a one-off dot here
|
||||
userMarker = new AdvancedMarkerElement({
|
||||
userMarker = new Marker({
|
||||
position: { lat: centerLat, lng: centerLng },
|
||||
map,
|
||||
title: 'Your location',
|
||||
zIndex: 1000,
|
||||
content: locationPin()
|
||||
icon: toMarkerIcon(locationPin())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,20 +117,39 @@
|
||||
version: 'weekly',
|
||||
});
|
||||
|
||||
const { Map } = await importLibrary('maps');
|
||||
({ AdvancedMarkerElement } = await importLibrary('marker'));
|
||||
// switched from AdvancedMarkerElement back to the legacy Marker API
|
||||
// (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, {
|
||||
center: { lat: centerLat, lng: centerLng },
|
||||
zoom: 15,
|
||||
disableDefaultUI: true,
|
||||
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);
|
||||
@@ -66,33 +158,30 @@
|
||||
// function to render pins
|
||||
function renderPins(messages) {
|
||||
// clear current pins
|
||||
markers.forEach(marker => (marker.map = null));
|
||||
markers.forEach(marker => marker.setMap(null));
|
||||
markers = [];
|
||||
|
||||
messages.forEach(message => {
|
||||
// pick a pin shape based on message state:
|
||||
// - messages the user has opened this session get the read circle
|
||||
// (takes priority - once you've read it, it's a circle even if echoed)
|
||||
// - otherwise, echoed messages (echoCount > 0) get the heart pin
|
||||
// - everything else gets the unread star
|
||||
let icon;
|
||||
if (readIds.has(message.id)) {
|
||||
icon = readPin();
|
||||
} else if (message.echoCount > 0) {
|
||||
icon = echoedPin();
|
||||
} else {
|
||||
icon = unreadPin();
|
||||
}
|
||||
// every message gets the same plain circle pin (previously this
|
||||
// varied - star/circle/heart - based on read/unread/echoed state,
|
||||
// swapped on click; that's gone now, so clicking a pin never
|
||||
// changes its appearance, only opens the detail view below).
|
||||
// message.moodColor (optional, set by ComposeSheet's swatch row and
|
||||
// stored in Firestore via addMessage) overrides the random pastel
|
||||
// color inside messagePin() when present - see pins.js's pinColor()
|
||||
const icon = messagePin(message.moodColor);
|
||||
|
||||
const marker = new AdvancedMarkerElement({
|
||||
const marker = new Marker({
|
||||
position: { lat: message.lat, lng: message.lng },
|
||||
map,
|
||||
title: message.text,
|
||||
content: icon // AdvancedMarkerElement takes a DOM node via `content`, not an `icon` option
|
||||
icon: toMarkerIcon(icon)
|
||||
});
|
||||
|
||||
marker.addEventListener('click', () => {
|
||||
readIds.add(message.id); // mark as read; SvelteSet triggers the $effect below to re-render pins
|
||||
// legacy Marker uses the Maps JS event system (addListener), not
|
||||
// DOM events - this also avoids the AdvancedMarkerElement
|
||||
// gmp-click deprecation warning entirely
|
||||
marker.addListener('click', () => {
|
||||
mapStore.set({ selectedMessage: message, composing: false });
|
||||
});
|
||||
|
||||
@@ -106,11 +195,275 @@
|
||||
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>
|
||||
|
||||
<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>
|
||||
/* 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)
|
||||
so that the parent's padding-bottom on mobile actually shrinks the
|
||||
visible map instead of the map overflowing past it */
|
||||
@@ -118,4 +471,107 @@
|
||||
width: 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>
|
||||
63
src/lib/components/SensingToggle.svelte
Normal file
63
src/lib/components/SensingToggle.svelte
Normal 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>
|
||||
230
src/lib/components/SharePopover.svelte
Normal file
230
src/lib/components/SharePopover.svelte
Normal 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>
|
||||
@@ -9,6 +9,15 @@
|
||||
import { getDecayInfo } from '$lib/utils/time.js';
|
||||
import { echoMessage } from '$lib/firebase/messages.js';
|
||||
import { playEchoTone } from '$lib/utils/sound.js';
|
||||
import { getPresenceText } from '$lib/utils/presence.js';
|
||||
import SharePopover from '$lib/components/SharePopover.svelte';
|
||||
import {
|
||||
incrementRead,
|
||||
incrementEchoed,
|
||||
incrementLetGo,
|
||||
hasSeenLastChancePrompt,
|
||||
markLastChancePromptSeen
|
||||
} from '$lib/utils/stats.js';
|
||||
|
||||
// currently-selected message; when set, the detail view is shown instead of the list
|
||||
let { message } = $props();
|
||||
@@ -18,6 +27,53 @@
|
||||
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
||||
);
|
||||
|
||||
// --- "last chance" state -------------------------------------------------
|
||||
// A message is on its genuinely LAST day if it has 1 (or 0) days left AND
|
||||
// isn't already expired/faded - daysLeft <= 1 && !isExpired. getDecayInfo()
|
||||
// derives daysLeft from lastEchoAt, so a message that was recently echoed
|
||||
// already has a much higher daysLeft and won't trigger this - "hasn't been
|
||||
// echoed recently enough to have more time" is automatically satisfied
|
||||
// whenever this condition is true.
|
||||
let isLastChance = $derived(
|
||||
decay ? (decay.daysLeft <= 1 && !decay.isExpired) : false
|
||||
);
|
||||
|
||||
// one-time "last chance" prompt for the currently-open message
|
||||
let showLastChancePrompt = $state(false);
|
||||
|
||||
// tracks which message was open last time this effect ran, so the
|
||||
// read-count + prompt logic below only fires once per "open" (not on
|
||||
// every reactive re-run while the same message stays selected)
|
||||
let previousMessageId = null;
|
||||
|
||||
$effect(() => {
|
||||
const currentId = message?.id ?? null;
|
||||
|
||||
if (currentId && currentId !== previousMessageId) {
|
||||
// --- engagement stat: a detail view was just opened -----------------
|
||||
incrementRead();
|
||||
|
||||
// --- one-time "last chance" prompt -----------------------------------
|
||||
// only show it if this message is CURRENTLY in its last-chance state
|
||||
// AND this device hasn't already seen the prompt for this message ID.
|
||||
// Marking it seen immediately (rather than waiting for an explicit
|
||||
// dismiss) means it won't reappear even if the user navigates away
|
||||
// without closing it.
|
||||
if (isLastChance && !hasSeenLastChancePrompt(currentId)) {
|
||||
showLastChancePrompt = true;
|
||||
markLastChancePromptSeen(currentId);
|
||||
} else {
|
||||
showLastChancePrompt = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentId) {
|
||||
showLastChancePrompt = false; // closed - reset for the next open
|
||||
}
|
||||
|
||||
previousMessageId = currentId;
|
||||
});
|
||||
|
||||
// only show the 3 most recent nearby messages, so the list view fits
|
||||
// without scrolling and the hint card stays visible underneath it
|
||||
let topMessages = $derived(
|
||||
@@ -26,6 +82,11 @@
|
||||
.slice(0, 3)
|
||||
);
|
||||
|
||||
// ambient presence indicator ("X people have been here today"), recomputed
|
||||
// whenever the nearby-messages store updates (i.e. whenever
|
||||
// getNearbyMessages refreshes the data)
|
||||
let presenceText = $derived(getPresenceText($messagesStore));
|
||||
|
||||
// back to the list view
|
||||
function close() {
|
||||
mapStore.set({ selectedMessage: null, composing: false });
|
||||
@@ -33,10 +94,20 @@
|
||||
|
||||
async function handleEcho() {
|
||||
await echoMessage(message.id);
|
||||
// engagement stat: Echo pressed
|
||||
incrementEchoed();
|
||||
// gentle ascending "thank you" tone confirming the echo went through
|
||||
playEchoTone();
|
||||
close(); // back to the list once echoed
|
||||
}
|
||||
|
||||
// close()'s behavior is unchanged (back to the list) - this only adds the
|
||||
// engagement-stat increment and prompt dismissal alongside it
|
||||
function handleLetGo() {
|
||||
incrementLetGo();
|
||||
showLastChancePrompt = false;
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
@@ -44,29 +115,50 @@
|
||||
<!-- detail view: shown for the selected message -->
|
||||
<button class="back-btn" onclick={close}>← Back</button>
|
||||
|
||||
{#if message.imageUrl}
|
||||
<img class="message-img" src={message.imageUrl} alt="message attachment" />
|
||||
{/if}
|
||||
<div class="detail-content" class:last-chance={isLastChance}>
|
||||
{#if message.imageUrl}
|
||||
<img class="message-img" src={message.imageUrl} alt="message attachment" />
|
||||
{/if}
|
||||
|
||||
<p class="message-text">{message.text}</p>
|
||||
<p class="message-text">{message.text}</p>
|
||||
|
||||
{#if decay}
|
||||
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||
{/if}
|
||||
{#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>
|
||||
{/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">
|
||||
<button class="echo-button" onclick={handleEcho}>
|
||||
Echo
|
||||
</button>
|
||||
<button class="letgo-button" onclick={close}>
|
||||
<button class="letgo-button" onclick={handleLetGo}>
|
||||
Let go
|
||||
</button>
|
||||
<!-- share link + QR code popover (see SharePopover.svelte) -->
|
||||
<SharePopover {message} />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- list view: header, compose button, nearby messages -->
|
||||
<div class="panel-header">
|
||||
<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>
|
||||
|
||||
<hr />
|
||||
@@ -115,6 +207,13 @@
|
||||
<p>Messages appear as you explore</p>
|
||||
<p class="hint-sub">Tap the confetti on the map to read them</p>
|
||||
</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}
|
||||
</div>
|
||||
|
||||
@@ -183,6 +282,58 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* "last chance" treatment - a soft warm peach tint + border, distinct from
|
||||
the normal panel background but staying within the existing pastel/
|
||||
parchment palette. A faint glow (box-shadow) adds a touch of warmth
|
||||
without being a jarring "alert" color like red. */
|
||||
.detail-content.last-chance {
|
||||
background: #fff6ea;
|
||||
border: 1px solid #f3dcb8;
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem;
|
||||
margin: -0.25rem -0.25rem 0;
|
||||
box-shadow: 0 0 0 4px rgba(243, 184, 92, 0.08);
|
||||
}
|
||||
|
||||
/* replaces the normal "fading in X days" meta line when isLastChance */
|
||||
.last-chance-text {
|
||||
color: #b08a5a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* one-time prompt shown above the action buttons - same warm palette as
|
||||
.detail-content.last-chance, slightly more saturated so it reads as the
|
||||
"active" element within the card */
|
||||
.last-chance-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
background: #fdf0db;
|
||||
border: 1px solid #f3dcb8;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #9c7240;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.last-chance-prompt p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dismiss-prompt {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #c4a37a;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
@@ -248,6 +399,30 @@
|
||||
background: #b090e8;
|
||||
}
|
||||
|
||||
/* same pill-button look as .compose-btn, but on an <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 {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
|
||||
17
src/lib/stores/globalCountStore.js
Normal file
17
src/lib/stores/globalCountStore.js
Normal 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
43
src/lib/utils/geo.js
Normal 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;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import ngeohash from 'ngeohash'; // library that does the geohasing encoding/dec
|
||||
// are shorter than this prefix and the >= / < range query in getNearbyMessages
|
||||
// never matches anything (this is why pins disappeared)
|
||||
export function encode(lat, lng) {
|
||||
return ngeohash.encode(lat, lng, 6);
|
||||
return ngeohash.encode(lat, lng, 9);
|
||||
}
|
||||
|
||||
// encodes a lat/lng pair to a 4 character string geohash (~40km radius) used as a Firestore query prefix
|
||||
@@ -16,5 +16,5 @@ export function encode(lat, lng) {
|
||||
// reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes
|
||||
// stored on existing messages (see encode() above)
|
||||
export function getQueryPrefix(lat, lng) {
|
||||
return ngeohash.encode(lat, lng, 4);
|
||||
return ngeohash.encode(lat, lng, 6);
|
||||
}
|
||||
98
src/lib/utils/mapStyles.js
Normal file
98
src/lib/utils/mapStyles.js
Normal 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' }]
|
||||
}
|
||||
];
|
||||
@@ -8,64 +8,49 @@ function randomPastel() {
|
||||
};
|
||||
}
|
||||
|
||||
// turns an SVG string into a DOM element sized to `size` x `size` px.
|
||||
// AdvancedMarkerElement (the marker API this app uses) takes a DOM node
|
||||
// via its `content` option, not an icon/url like the older google.maps.Marker,
|
||||
// so we inline the SVG as a data URL on an <img> instead of returning an icon object.
|
||||
function svgToElement(svg, size) {
|
||||
const img = document.createElement('img');
|
||||
img.src = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg);
|
||||
img.style.width = `${size}px`;
|
||||
img.style.height = `${size}px`;
|
||||
// AdvancedMarkerElement anchors content by its bottom-center by default
|
||||
// (made for teardrop pins). Shift up by half the height so the *center*
|
||||
// of our shapes lands on the actual coordinate instead.
|
||||
img.style.transform = 'translateY(-50%)';
|
||||
// flat 2D style for now (no drop-shadow) - paper-style shadows may be added later
|
||||
return img;
|
||||
// picks the fill color for a pin: if the message has a moodColor (an
|
||||
// optional pastel color the author picked in ComposeSheet's swatch row,
|
||||
// stored as an hsl(...) string), use that directly so the pin matches what
|
||||
// they chose. otherwise fall back to the existing random pastel exactly as
|
||||
// before. `glow` isn't currently rendered by any pin shape below, but is
|
||||
// still returned for shape consistency with randomPastel().
|
||||
function pinColor(moodColor) {
|
||||
if (moodColor) {
|
||||
return { solid: moodColor, glow: moodColor };
|
||||
}
|
||||
return randomPastel();
|
||||
}
|
||||
|
||||
// star pin for unread messages: solid pastel star, no glow halo, flat fill
|
||||
export function unreadPin() {
|
||||
const { solid } = randomPastel();
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60">
|
||||
<polygon points="30,12 34,24 47,24 37,32 41,44 30,36 19,44 23,32 13,24 26,24"
|
||||
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, 46);
|
||||
// turns an SVG string into a { url, size } pair for google.maps.Marker's
|
||||
// `icon` option. Markers (the legacy marker API this app now uses, switched
|
||||
// back from AdvancedMarkerElement so a JS map `styles` array can take
|
||||
// effect - AdvancedMarkerElement requires a mapId, which causes Google to
|
||||
// ignore any `styles` array) take an Icon object - { url, scaledSize, anchor }
|
||||
// - rather than a DOM node, so we just return the data-URL + size here and
|
||||
// let MapView build the google.maps.Size/Point objects (those classes only
|
||||
// exist once the Maps JS API has loaded).
|
||||
// flat 2D style for now (no drop-shadow) - paper-style shadows may be added later
|
||||
function svgToIcon(svg, size) {
|
||||
return {
|
||||
url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg),
|
||||
size
|
||||
};
|
||||
}
|
||||
|
||||
// circle pin for read messages: solid pastel perfect circle, no glow halo
|
||||
export function readPin() {
|
||||
const { solid } = randomPastel();
|
||||
// pin for every message: solid pastel perfect circle, no glow halo.
|
||||
// previously there were 3 shapes (star/circle/heart) for unread/read/echoed
|
||||
// messages, swapped on click - now every message pin looks the same, so
|
||||
// clicking a pin no longer needs to change its appearance.
|
||||
// moodColor (optional): the author's picked swatch color, if any - see pinColor() above
|
||||
export function messagePin(moodColor) {
|
||||
const { solid } = pinColor(moodColor);
|
||||
const svg = `
|
||||
<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}"/>
|
||||
</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 = `
|
||||
<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);
|
||||
return svgToIcon(svg, 28);
|
||||
}
|
||||
|
||||
// fixed dark dot used for the user's own location (not randomized, no glow)
|
||||
@@ -75,5 +60,5 @@ export function locationPin() {
|
||||
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
|
||||
<circle cx="16" cy="16" r="5" fill="white"/>
|
||||
</svg>`;
|
||||
return svgToElement(svg, 32);
|
||||
return svgToIcon(svg, 32);
|
||||
}
|
||||
|
||||
69
src/lib/utils/presence.js
Normal file
69
src/lib/utils/presence.js
Normal 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
91
src/lib/utils/stats.js
Normal 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
47
src/lib/utils/trail.js
Normal 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;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
<script>
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { page } from '$app/stores';
|
||||
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();
|
||||
|
||||
@@ -77,6 +78,14 @@
|
||||
block or delay anything underneath. -->
|
||||
{@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 -->
|
||||
{#if ritualMounted}
|
||||
<div class="ritual-overlay" class:visible={ritualVisible} aria-hidden="true">
|
||||
@@ -84,15 +93,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- floating link to the archive page, hidden while already on it -->
|
||||
{#if $page.url.pathname !== '/archive'}
|
||||
<a href="/archive" class="archive-link">📁</a>
|
||||
{/if}
|
||||
<!-- ===================================================================
|
||||
GLOBAL TEXTURE OVERLAY (start)
|
||||
A subtle parchment/paper grain over the ENTIRE app (was previously
|
||||
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>
|
||||
/* full-screen opening ritual overlay - soft cream background matching
|
||||
the app's pastel palette, sits above everything (including the
|
||||
.archive-link below at z-index 300) while it's visible */
|
||||
the app's pastel palette, sits above everything while it's visible */
|
||||
.ritual-overlay {
|
||||
position: fixed;
|
||||
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;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
||||
text-decoration: none;
|
||||
font-size: 1.1rem;
|
||||
z-index: 300;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
pointer-events: none;
|
||||
opacity: 0.08;
|
||||
mix-blend-mode: multiply;
|
||||
background-color: #ecdfc4;
|
||||
background-image:
|
||||
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");
|
||||
background-size: 110px 110px;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
/* GLOBAL TEXTURE OVERLAY (end) */
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script>
|
||||
import { onMount } from '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 { 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 { replaceState } from '$app/navigation'; // clears the ?message= param after handling a shared link
|
||||
|
||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||
import SidePanel from '$lib/components/SidePanel.svelte';
|
||||
@@ -21,10 +24,59 @@
|
||||
|
||||
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(() => {
|
||||
// sign this device in anonymously so messages can be linked to a persistent UID
|
||||
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) {
|
||||
error = "Your browser doesn't support geolocation :(";
|
||||
return; // do nothing
|
||||
@@ -38,7 +90,7 @@
|
||||
error = "Location access denied. Please enable location to use Overheard.";
|
||||
}
|
||||
);
|
||||
// populate the messages store
|
||||
// populate the messages store
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const messages = await getNearbyMessages(position.coords.latitude, position.coords.longitude);
|
||||
@@ -46,15 +98,166 @@
|
||||
// previous list and plays a soft chime for any newly-appeared pins
|
||||
setMessages(messages);
|
||||
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>
|
||||
|
||||
<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}
|
||||
<p class="error">{error}</p>
|
||||
{:else if !(lat && lng)}
|
||||
@@ -67,10 +270,27 @@
|
||||
on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav -->
|
||||
{#if lat && lng}
|
||||
<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>
|
||||
{/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-->
|
||||
{#if windowWidth < 768}
|
||||
<BottomSheet message={$mapStore.selectedMessage} />
|
||||
@@ -80,57 +300,6 @@
|
||||
<SidePanel message={$mapStore.selectedMessage} />
|
||||
{/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-->
|
||||
{#if !$mapStore.composing && !$mapStore.selectedMessage}
|
||||
<button
|
||||
@@ -177,7 +346,8 @@
|
||||
:global(body) {
|
||||
margin: 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 {
|
||||
@@ -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
|
||||
"shifted" state is needed anymore (that rule has been removed) */
|
||||
.fab {
|
||||
@@ -212,6 +407,15 @@
|
||||
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 */
|
||||
.bottom-nav {
|
||||
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>
|
||||
@@ -3,13 +3,19 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { getMyMessages } from '$lib/firebase/messages.js';
|
||||
import { getDecayInfo } from '$lib/utils/time.js';
|
||||
import { getStats } from '$lib/utils/stats.js';
|
||||
|
||||
let messages = $state([]);
|
||||
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 () => {
|
||||
messages = await getMyMessages();
|
||||
loading = false;
|
||||
stats = getStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -20,6 +26,12 @@
|
||||
<p class="subtitle">Everything you've left behind</p>
|
||||
</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}
|
||||
<div class="loading">Loading your messages...</div>
|
||||
|
||||
@@ -96,6 +108,16 @@
|
||||
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 {
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
|
||||
Reference in New Issue
Block a user