ui changes - removed mobile features

This commit is contained in:
2026-06-15 02:37:06 +09:00
parent 65d5327a7b
commit 4c20bf5ab6
8 changed files with 131 additions and 466 deletions

View File

@@ -1,6 +1,6 @@
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 { getNearbyGeohashCells } from '$lib/utils/geohash'; // convert coordinates into a 3x3 grid of geohash cell prefixes (handles cell-boundary edge cases - see geohash.js)
import { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
import ngeohash from 'ngeohash';
@@ -16,17 +16,31 @@ import ngeohash from 'ngeohash';
const statsRef = doc(db, 'meta', 'stats');
export async function getNearbyMessages(lat, lng) {
const prefix = getQueryPrefix(lat, lng);
// query the user's geohash cell AND its 8 neighbors (3x3 grid) rather
// than a single prefix range - see getNearbyGeohashCells in geohash.js
// for why a single cell isn't reliable enough across devices.
const cells = getNearbyGeohashCells(lat, lng);
// filter by the geohash
const q = query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
const snapshots = await Promise.all(
cells.map(prefix => getDocs(query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
)))
);
const snapshot = await getDocs(q);
const all = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
// merge the 9 result sets into one list. Neighboring geohash cells never
// overlap, so each message can only appear in one snapshot - but we
// dedupe by doc id anyway as a defensive measure, in case that ever changes.
const seen = new Map();
for (const snapshot of snapshots) {
for (const docSnap of snapshot.docs) {
if (!seen.has(docSnap.id)) {
seen.set(docSnap.id, { id: docSnap.id, ...docSnap.data() });
}
}
}
const all = [...seen.values()];
// we filter out messages which have already expired (past their echo date)
const now = Date.now();
@@ -55,16 +69,19 @@ export async function getNearbyMessages(lat, lng) {
// 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);
// same 3x3 cell grid as getNearbyMessages, for the same boundary reason -
// neighboring cells never overlap, so the counts can simply be summed.
const cells = getNearbyGeohashCells(lat, lng);
const q = query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
const counts = await Promise.all(
cells.map(prefix => getCountFromServer(query(
collection(db, 'messages'),
where('geohash', '>=', prefix),
where('geohash', '<', prefix + 'z')
)))
);
const snapshot = await getCountFromServer(q);
return snapshot.data().count > 0;
return counts.some(snapshot => snapshot.data().count > 0);
}
// update the echo counter

View File

@@ -6,7 +6,6 @@
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;
@@ -14,9 +13,7 @@
// 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 { lat, lng, firstHereMounted = false, firstHereVisible = false } = $props();
let mapDiv;
let map = $state(null);
@@ -46,39 +43,6 @@
// 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
@@ -243,167 +207,6 @@
}
})
// --- 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-wrapper">
@@ -433,25 +236,6 @@
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>
@@ -472,31 +256,6 @@
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

View File

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

View File

@@ -115,42 +115,48 @@
<!-- detail view: shown for the selected message -->
<button class="back-btn" onclick={close}> Back</button>
<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>
{#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>
<!-- wraps both the message content and the action buttons, so the
"last chance" gold/cream container can extend to include the
buttons too - mirrors BottomSheet.svelte's .content wrapper, which
already does this, for visual consistency between mobile and desktop -->
<div class="detail-wrap" class:last-chance={isLastChance}>
<div class="detail-content">
{#if message.imageUrl}
<img class="message-img" src={message.imageUrl} alt="message attachment" />
{/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>
<p class="message-text">{message.text}</p>
<div class="actions">
<button class="echo-button" onclick={handleEcho}>
Echo
</button>
<button class="letgo-button" onclick={handleLetGo}>
Let go
</button>
<!-- share link + QR code popover (see SharePopover.svelte) -->
<SharePopover {message} />
{#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={handleLetGo}>
Let go
</button>
<!-- share link + QR code popover (see SharePopover.svelte) -->
<SharePopover {message} />
</div>
</div>
{:else}
<!-- list view: header, compose button, nearby messages -->
@@ -282,11 +288,23 @@
color: #999;
}
/* wraps .detail-content and .actions together - lets the "last chance"
background below extend around the action buttons too, not just the
message text. flex: 1 fills the remaining height of .panel (taking over
the role .actions's margin-top: auto used to play directly inside
.panel), so .actions (still margin-top: auto, now relative to this
wrapper) stays pinned to the bottom either way. */
.detail-wrap {
display: flex;
flex-direction: column;
flex: 1;
}
/* "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 {
.detail-wrap.last-chance {
background: #fff6ea;
border: 1px solid #f3dcb8;
border-radius: 14px;
@@ -302,7 +320,7 @@
}
/* one-time prompt shown above the action buttons - same warm palette as
.detail-content.last-chance, slightly more saturated so it reads as the
.detail-wrap.last-chance, slightly more saturated so it reads as the
"active" element within the card */
.last-chance-prompt {
display: flex;

View File

@@ -1,43 +0,0 @@
// --- geo utilities for "sensing mode" (compass arrow + proximity vibration) -
// Both formulas are standard great-circle formulas. They're approximations
// (Earth isn't a perfect sphere), but for the distances this app cares about
// - "is this pin within ~50m" and "which direction is it in" within a single
// ~1.2km neighborhood - that error is negligible.
const EARTH_RADIUS_M = 6371000; // mean Earth radius, in meters
function toRad(deg) {
return (deg * Math.PI) / 180;
}
function toDeg(rad) {
return (rad * 180) / Math.PI;
}
// Haversine formula: great-circle distance between two lat/lng points, in
// meters. Used by the proximity-vibration check to find pins within ~50m.
export function distanceMeters(lat1, lng1, lat2, lng2) {
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// Initial bearing (compass direction in degrees, 0-360, 0 = true north,
// clockwise) from point 1 to point 2. Used by the compass arrow to figure
// out which way the nearest off-screen pin is, before adjusting for the
// device's own heading.
export function bearingDegrees(lat1, lng1, lat2, lng2) {
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dLng = toRad(lng2 - lng1);
const y = Math.sin(dLng) * Math.cos(phi2);
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLng);
return (toDeg(Math.atan2(y, x)) + 360) % 360;
}

View File

@@ -17,4 +17,26 @@ export function encode(lat, lng) {
// stored on existing messages (see encode() above)
export function getQueryPrefix(lat, lng) {
return ngeohash.encode(lat, lng, 6);
}
// --- geohash cell-boundary problem ----------------------------------------
// A single precision-6 geohash cell is only ~1.2km x 0.6km. Two devices
// standing in the same real-world spot can still land in DIFFERENT cells if
// their reported coordinates fall on opposite sides of a cell edge - this is
// very common, since desktop browsers usually resolve location via WiFi/IP
// (often off by hundreds of meters) while phones use GPS (much tighter).
// Querying only getQueryPrefix(lat, lng)'s single cell means one device's
// query can miss messages that are physically just a few meters away, on the
// other side of that edge - which is exactly the "different pin counts on
// different devices" symptom.
//
// getNearbyGeohashCells returns the user's cell PLUS its 8 surrounding cells
// (a 3x3 grid, via ngeohash.neighbors). Querying all 9 means that as long as
// both devices' reported positions are within roughly one cell-width of each
// other (which covers normal GPS/WiFi error), their 3x3 grids overlap enough
// to cover the same messages - so both devices see the same "nearby" set
// even if they're each centered in a different cell.
export function getNearbyGeohashCells(lat, lng) {
const center = getQueryPrefix(lat, lng);
return [center, ...ngeohash.neighbors(center)];
}

View File

@@ -1,7 +1,6 @@
<script>
import { onMount } from 'svelte';
import MapView from '$lib/components/MapView.svelte';
import SensingToggle from '$lib/components/SensingToggle.svelte';
import { getNearbyMessages, hasAnyMessagesEverNearby, getMessageById } from '$lib/firebase/messages.js';
import { getDecayInfo } from '$lib/utils/time.js'; // checks whether a shared message has expired
@@ -34,37 +33,6 @@
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();
@@ -273,24 +241,11 @@
<!-- 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} />
location marker, rather than floating independently of the map. -->
<MapView {lat} {lng} {firstHereMounted} {firstHereVisible} />
</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} />