Cooler features and many many fixes later
This commit is contained in:
@@ -22,5 +22,27 @@ service cloud.firestore {
|
|||||||
// nobody can delete messages through the client
|
// nobody can delete messages through the client
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- global "memory counter" (GlobalCountPill) -------------------------
|
||||||
|
// a single shared doc, meta/stats, holding totalMessagesEverPosted.
|
||||||
|
// anyone can read it (it's shown to every visitor); writes are limited to
|
||||||
|
// just that one field, same "only these fields can change" pattern as the
|
||||||
|
// echoCount update rule above, so this doc can't be hijacked to store
|
||||||
|
// arbitrary data.
|
||||||
|
match /meta/stats {
|
||||||
|
allow read: if true;
|
||||||
|
|
||||||
|
// first-ever write: only the counter field, and it must be a number
|
||||||
|
allow create: if
|
||||||
|
request.resource.data.keys().hasOnly(['totalMessagesEverPosted']) &&
|
||||||
|
request.resource.data.totalMessagesEverPosted is number;
|
||||||
|
|
||||||
|
// every later write (increment(1)) may only touch that same field
|
||||||
|
allow update: if
|
||||||
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['totalMessagesEverPosted']);
|
||||||
|
|
||||||
|
// nobody can delete the counter through the client
|
||||||
|
allow delete: if false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
207
package-lock.json
generated
207
package-lock.json
generated
@@ -11,7 +11,8 @@
|
|||||||
"@googlemaps/js-api-loader": "^2.1.0",
|
"@googlemaps/js-api-loader": "^2.1.0",
|
||||||
"@mediapipe/tasks-vision": "^0.10.35",
|
"@mediapipe/tasks-vision": "^0.10.35",
|
||||||
"firebase": "^12.14.0",
|
"firebase": "^12.14.0",
|
||||||
"ngeohash": "^0.6.3"
|
"ngeohash": "^0.6.3",
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
@@ -1318,6 +1319,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
@@ -1370,6 +1380,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -1397,6 +1416,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
@@ -1467,6 +1492,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/firebase": {
|
"node_modules/firebase": {
|
||||||
"version": "12.14.0",
|
"version": "12.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/firebase/-/firebase-12.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/firebase/-/firebase-12.14.0.tgz",
|
||||||
@@ -1836,6 +1874,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash.camelcase": {
|
"node_modules/lodash.camelcase": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
@@ -1907,6 +1957,51 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1927,6 +2022,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
@@ -1980,6 +2084,89 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -1989,6 +2176,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
||||||
@@ -2043,6 +2236,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
||||||
@@ -2295,6 +2494,12 @@
|
|||||||
"node": ">=0.8.0"
|
"node": ">=0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@googlemaps/js-api-loader": "^2.1.0",
|
"@googlemaps/js-api-loader": "^2.1.0",
|
||||||
"@mediapipe/tasks-vision": "^0.10.35",
|
"@mediapipe/tasks-vision": "^0.10.35",
|
||||||
"firebase": "^12.14.0",
|
"firebase": "^12.14.0",
|
||||||
"ngeohash": "^0.6.3"
|
"ngeohash": "^0.6.3",
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; // tools for building and running db queries
|
import { collection, query, where, getDocs, addDoc, getCountFromServer } from 'firebase/firestore'; // tools for building and running db queries
|
||||||
import { db, auth } from './config'; // database connection + anonymous auth
|
import { db, auth } from './config'; // database connection + anonymous auth
|
||||||
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
|
import { getQueryPrefix } from '$lib/utils/geohash'; // convert coordinates into geohash string
|
||||||
import { doc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
|
import { doc, getDoc, setDoc, updateDoc, increment, serverTimestamp } from 'firebase/firestore';
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
|
|
||||||
|
// --- global "memory counter" -----------------------------------------------
|
||||||
|
// A single document (meta/stats) holds `totalMessagesEverPosted`: a running
|
||||||
|
// total of every message ever created in the whole app, across all areas -
|
||||||
|
// including ones that have since expired/decayed and dropped out of
|
||||||
|
// getNearbyMessages's "active" results. It's deliberately a single shared
|
||||||
|
// doc, separate from individual message documents, because it represents a
|
||||||
|
// different thing: not "what's currently visible/active" (which shrinks as
|
||||||
|
// messages decay) but "everything that has ever been said here" (which only
|
||||||
|
// grows). See addMessage() and getTotalMessageCount() below.
|
||||||
|
const statsRef = doc(db, 'meta', 'stats');
|
||||||
|
|
||||||
export async function getNearbyMessages(lat, lng) {
|
export async function getNearbyMessages(lat, lng) {
|
||||||
const prefix = getQueryPrefix(lat, lng);
|
const prefix = getQueryPrefix(lat, lng);
|
||||||
|
|
||||||
@@ -31,6 +42,31 @@ export async function getNearbyMessages(lat, lng) {
|
|||||||
return active;
|
return active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- "you're the first here" check (+page.svelte) -------------------------
|
||||||
|
// getNearbyMessages() above already fetches every doc matching the geohash
|
||||||
|
// prefix into `all`, then filters out anything past its 30-day decay into
|
||||||
|
// `active`. So `active.length === 0` alone can't tell apart two very
|
||||||
|
// different situations: "nobody has EVER posted in this ~1.2km area" vs
|
||||||
|
// "people posted here before, but everything since faded/expired".
|
||||||
|
// Rather than re-fetching and re-filtering all those documents again, this
|
||||||
|
// runs the SAME geohash range query through getCountFromServer - a
|
||||||
|
// Firestore aggregation query that returns just a number without
|
||||||
|
// transferring any document data, so it stays cheap even though it repeats
|
||||||
|
// the query. Returns true if at least one message (active or expired) has
|
||||||
|
// ever existed in this area.
|
||||||
|
export async function hasAnyMessagesEverNearby(lat, lng) {
|
||||||
|
const prefix = getQueryPrefix(lat, lng);
|
||||||
|
|
||||||
|
const q = query(
|
||||||
|
collection(db, 'messages'),
|
||||||
|
where('geohash', '>=', prefix),
|
||||||
|
where('geohash', '<', prefix + 'z')
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await getCountFromServer(q);
|
||||||
|
return snapshot.data().count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// update the echo counter
|
// update the echo counter
|
||||||
export async function echoMessage(messageId) {
|
export async function echoMessage(messageId) {
|
||||||
const ref = doc(db, 'messages', messageId);
|
const ref = doc(db, 'messages', messageId);
|
||||||
@@ -41,7 +77,11 @@ export async function echoMessage(messageId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// adding the message location
|
// adding the message location
|
||||||
export async function addMessage(lat, lng, text, imageUrl = ''){
|
// moodColor (optional): the author's picked pastel swatch from ComposeSheet's
|
||||||
|
// mood-color row, as an hsl(...) string - or null if they didn't pick one.
|
||||||
|
// stored as-is on the message doc; pins.js falls back to a random pastel
|
||||||
|
// when this is null (see pinColor() in pins.js).
|
||||||
|
export async function addMessage(lat, lng, text, imageUrl = '', moodColor = null){
|
||||||
const geohash = ngeohash.encode(lat, lng, 6);
|
const geohash = ngeohash.encode(lat, lng, 6);
|
||||||
|
|
||||||
await addDoc(collection(db, 'messages'), {
|
await addDoc(collection(db, 'messages'), {
|
||||||
@@ -50,12 +90,68 @@ export async function addMessage(lat, lng, text, imageUrl = ''){
|
|||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
geohash,
|
geohash,
|
||||||
|
moodColor,
|
||||||
createdAt: serverTimestamp(),
|
createdAt: serverTimestamp(),
|
||||||
lastEchoAt: serverTimestamp(),
|
lastEchoAt: serverTimestamp(),
|
||||||
echoCount: 0,
|
echoCount: 0,
|
||||||
// links the message to this device's persistent anonymous Firebase UID
|
// links the message to this device's persistent anonymous Firebase UID
|
||||||
authorId: auth.currentUser?.uid ?? 'anon'
|
authorId: auth.currentUser?.uid ?? 'anon'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- global "memory counter": increment-only ----------------------------
|
||||||
|
// Same increment(1) pattern as echoCount above, but on the shared
|
||||||
|
// meta/stats doc instead of this message's own doc. setDoc with
|
||||||
|
// { merge: true } both creates meta/stats on the very first call (if it
|
||||||
|
// doesn't exist yet - increment() treats a missing field as starting from
|
||||||
|
// 0) and increments the existing field on every call after that. This
|
||||||
|
// counter never decrements: when a message later expires/decays out of
|
||||||
|
// getNearbyMessages's active results, nothing here is touched, so the
|
||||||
|
// total keeps representing everything ever posted, not just what's
|
||||||
|
// currently visible.
|
||||||
|
// Wrapped in try/catch so a failure here (e.g. security rules not yet
|
||||||
|
// deployed for meta/stats) never breaks the actual posting of the
|
||||||
|
// message above - the counter is a nice-to-have, not core functionality.
|
||||||
|
try {
|
||||||
|
await setDoc(statsRef, { totalMessagesEverPosted: increment(1) }, { merge: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[memory counter] failed to increment meta/stats:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch the current value of the global "memory counter" (meta/stats), for
|
||||||
|
// the GlobalCountPill. Returns 0 if no message has ever been posted yet (the
|
||||||
|
// doc won't exist until the very first addMessage() call above), or if the
|
||||||
|
// read fails for any reason (e.g. security rules not yet deployed) - the pill
|
||||||
|
// simply won't render in that case (see GlobalCountPill's $globalCount check).
|
||||||
|
export async function getTotalMessageCount() {
|
||||||
|
try {
|
||||||
|
const snap = await getDoc(statsRef);
|
||||||
|
return snap.exists() ? (snap.data().totalMessagesEverPosted ?? 0) : 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[memory counter] failed to read meta/stats:', err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- direct by-ID lookup, for shared links/QR codes (SharePopover.svelte,
|
||||||
|
// +page.svelte's auto-open-on-load logic) ----------------------------------
|
||||||
|
// getNearbyMessages() above can only ever find messages inside the current
|
||||||
|
// device's ~1.2km geohash prefix - a message shared as a link/QR code may
|
||||||
|
// have been left anywhere in the world, so opening it can't go through that
|
||||||
|
// geohash filter at all. getDoc-by-ID is a direct document lookup that
|
||||||
|
// works regardless of geohash, at the cost of needing the exact message ID
|
||||||
|
// (which is exactly what the shared link/QR encodes). Returns null (rather
|
||||||
|
// than throwing) if the doc doesn't exist or the read fails, so callers can
|
||||||
|
// show a graceful "this one has already faded" message instead of an error.
|
||||||
|
export async function getMessageById(id) {
|
||||||
|
try {
|
||||||
|
const ref = doc(db, 'messages', id);
|
||||||
|
const snap = await getDoc(ref);
|
||||||
|
return snap.exists() ? { id: snap.id, ...snap.data() } : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[share link] failed to fetch message by id:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch every message this device has authored, for the archive page
|
// fetch every message this device has authored, for the archive page
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
import { getDecayInfo } from '$lib/utils/time.js'
|
import { getDecayInfo } from '$lib/utils/time.js'
|
||||||
import { echoMessage } from '$lib/firebase/messages.js'
|
import { echoMessage } from '$lib/firebase/messages.js'
|
||||||
import { playEchoTone } from '$lib/utils/sound.js';
|
import { playEchoTone } from '$lib/utils/sound.js';
|
||||||
|
import SharePopover from '$lib/components/SharePopover.svelte';
|
||||||
|
import {
|
||||||
|
incrementRead,
|
||||||
|
incrementEchoed,
|
||||||
|
incrementLetGo,
|
||||||
|
hasSeenLastChancePrompt,
|
||||||
|
markLastChancePromptSeen
|
||||||
|
} from '$lib/utils/stats.js';
|
||||||
|
|
||||||
let { message } = $props();
|
let { message } = $props();
|
||||||
|
|
||||||
@@ -10,15 +18,74 @@
|
|||||||
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- "last chance" state ----------------------------------------------
|
||||||
|
// A message is on its genuinely LAST day if it has 1 (or 0) days left
|
||||||
|
// AND isn't already expired/faded - daysLeft <= 1 && !isExpired.
|
||||||
|
// getDecayInfo() derives daysLeft from lastEchoAt, so a message that was
|
||||||
|
// recently echoed already has a much higher daysLeft and won't trigger
|
||||||
|
// this - "hasn't been echoed recently enough to have more time" is
|
||||||
|
// automatically satisfied whenever this condition is true.
|
||||||
|
let isLastChance = $derived(
|
||||||
|
decay ? (decay.daysLeft <= 1 && !decay.isExpired) : false
|
||||||
|
);
|
||||||
|
|
||||||
let echoed = $state(false);
|
let echoed = $state(false);
|
||||||
|
|
||||||
|
// one-time "last chance" prompt for the currently-open message
|
||||||
|
let showLastChancePrompt = $state(false);
|
||||||
|
|
||||||
|
// tracks which message was open last time this effect ran, so the
|
||||||
|
// read-count + prompt logic below only fires once per "open" (not on
|
||||||
|
// every reactive re-run while the same message stays selected)
|
||||||
|
let previousMessageId = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const currentId = message?.id ?? null;
|
||||||
|
|
||||||
|
if (currentId && currentId !== previousMessageId) {
|
||||||
|
// --- engagement stat: a detail view was just opened ----------
|
||||||
|
incrementRead();
|
||||||
|
|
||||||
|
// --- one-time "last chance" prompt ----------------------------
|
||||||
|
// only show it if this message is CURRENTLY in its last-chance
|
||||||
|
// state AND this device hasn't already seen the prompt for this
|
||||||
|
// message ID. Marking it seen immediately (rather than waiting
|
||||||
|
// for an explicit dismiss) means it won't reappear even if the
|
||||||
|
// user navigates away without closing it.
|
||||||
|
if (isLastChance && !hasSeenLastChancePrompt(currentId)) {
|
||||||
|
showLastChancePrompt = true;
|
||||||
|
markLastChancePromptSeen(currentId);
|
||||||
|
} else {
|
||||||
|
showLastChancePrompt = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentId) {
|
||||||
|
showLastChancePrompt = false; // closed - reset for the next open
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMessageId = currentId;
|
||||||
|
});
|
||||||
|
|
||||||
async function handleEcho() {
|
async function handleEcho() {
|
||||||
await echoMessage(message.id);
|
await echoMessage(message.id);
|
||||||
echoed = true;
|
echoed = true;
|
||||||
|
// engagement stat: Echo pressed
|
||||||
|
incrementEchoed();
|
||||||
|
// taking action dismisses the last-chance prompt for this message
|
||||||
|
showLastChancePrompt = false;
|
||||||
// gentle ascending "thank you" tone confirming the echo went through
|
// gentle ascending "thank you" tone confirming the echo went through
|
||||||
playEchoTone();
|
playEchoTone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let go's behavior is unchanged (just closes the sheet) - this only
|
||||||
|
// adds the engagement-stat increment and prompt dismissal alongside it
|
||||||
|
function handleLetGo() {
|
||||||
|
incrementLetGo();
|
||||||
|
showLastChancePrompt = false;
|
||||||
|
mapStore.set({ selectedMessage: null, composing: false });
|
||||||
|
}
|
||||||
|
|
||||||
let startY = 0; // where the swipe started
|
let startY = 0; // where the swipe started
|
||||||
|
|
||||||
function startDrag(e) {
|
function startDrag(e) {
|
||||||
@@ -49,14 +116,31 @@
|
|||||||
>
|
>
|
||||||
<div class="handle"></div>
|
<div class="handle"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content" class:last-chance={isLastChance}>
|
||||||
{#if message.imageUrl}
|
{#if message.imageUrl}
|
||||||
<img class="message-img" src={message.imageUrl} alt="message attachment" />
|
<img class="message-img" src={message.imageUrl} alt="message attachment" />
|
||||||
{/if}
|
{/if}
|
||||||
<p class="message-text">{message.text}</p>
|
<p class="message-text">{message.text}</p>
|
||||||
{#if decay}
|
{#if decay}
|
||||||
|
{#if isLastChance}
|
||||||
|
<!-- "last chance" framing replaces the normal countdown text -->
|
||||||
|
<p class="meta last-chance-text">this is the last chance to keep this alive</p>
|
||||||
|
{:else}
|
||||||
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showLastChancePrompt}
|
||||||
|
<!-- one-time prompt (see stats.js) - gently frames Echo as
|
||||||
|
the way to give this message more time, without being
|
||||||
|
pushy. Dismissible on its own, or implicitly dismissed
|
||||||
|
by pressing Echo/Let go below. -->
|
||||||
|
<div class="last-chance-prompt">
|
||||||
|
<p>if this one means something, an echo will keep it here a little longer.</p>
|
||||||
|
<button class="dismiss-prompt" onclick={() => showLastChancePrompt = false} aria-label="dismiss">×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="echo-button"
|
<button class="echo-button"
|
||||||
class:echoed={echoed}
|
class:echoed={echoed}
|
||||||
@@ -64,10 +148,11 @@
|
|||||||
disabled={echoed}>
|
disabled={echoed}>
|
||||||
{echoed ? 'Echoed' : 'Echo'}
|
{echoed ? 'Echoed' : 'Echo'}
|
||||||
</button>
|
</button>
|
||||||
<button class="letgo-button" onclick={() => mapStore.set(
|
<button class="letgo-button" onclick={handleLetGo}>
|
||||||
{ selectedMessage: null, composing: false })}>
|
|
||||||
let go
|
let go
|
||||||
</button>
|
</button>
|
||||||
|
<!-- share link + QR code popover (see SharePopover.svelte) -->
|
||||||
|
<SharePopover {message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -81,7 +166,12 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 20px 20px 0 0;
|
border-radius: 20px 20px 0 0;
|
||||||
padding: 1rem 1.5rem 2rem;
|
/* extra 64px of bottom padding = the height of .bottom-nav in
|
||||||
|
+page.svelte (z-index 200, above this sheet's 100), which sits on
|
||||||
|
top of the sheet's bottom edge and was covering the Echo/Let go
|
||||||
|
buttons. pushing the buttons up by that much keeps them clear of
|
||||||
|
the nav bar instead of hidden behind it. */
|
||||||
|
padding: 1rem 1.5rem calc(2rem + 64px);
|
||||||
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
||||||
transform: translateY(100%);
|
transform: translateY(100%);
|
||||||
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
|
transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
@@ -121,6 +211,58 @@
|
|||||||
margin-bottom: 1.2rem;
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "last chance" treatment - a soft warm peach tint + border, distinct
|
||||||
|
from the normal white card but staying within the existing pastel/
|
||||||
|
parchment palette. A faint glow (box-shadow) adds a touch of warmth
|
||||||
|
without being a jarring "alert" color like red. */
|
||||||
|
.content.last-chance {
|
||||||
|
background: #fff6ea;
|
||||||
|
border: 1px solid #f3dcb8;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
margin: -0.25rem -0.25rem 0;
|
||||||
|
box-shadow: 0 0 0 4px rgba(243, 184, 92, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* replaces the normal "fading in X days" meta line when isLastChance */
|
||||||
|
.last-chance-text {
|
||||||
|
color: #b08a5a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* one-time prompt shown above the action buttons - same warm palette as
|
||||||
|
.content.last-chance, slightly more saturated so it reads as the
|
||||||
|
"active" element within the card */
|
||||||
|
.last-chance-prompt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #fdf0db;
|
||||||
|
border: 1px solid #f3dcb8;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9c7240;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-chance-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-prompt {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #c4a37a;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapStore } from '$lib/stores/mapStore.js';
|
import { mapStore } from '$lib/stores/mapStore.js';
|
||||||
import { setMessages } from '$lib/stores/messagesStore.js';
|
import { setMessages } from '$lib/stores/messagesStore.js';
|
||||||
|
import { refreshGlobalCount } from '$lib/stores/globalCountStore.js';
|
||||||
import { addMessage } from '$lib/firebase/messages.js'
|
import { addMessage } from '$lib/firebase/messages.js'
|
||||||
import { hasFace } from '$lib/utils/faceDetection.js';
|
import { hasFace } from '$lib/utils/faceDetection.js';
|
||||||
import { uploadImage } from '$lib/Firebase/storage.js';
|
import { uploadImage } from '$lib/Firebase/storage.js';
|
||||||
@@ -20,11 +21,109 @@
|
|||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let remaining = $derived(240-text.length);
|
let remaining = $derived(240-text.length);
|
||||||
|
|
||||||
|
// --- rotating placeholder prompts -------------------------------------
|
||||||
|
// A small set of incomplete, contemplative fragments that cycle through
|
||||||
|
// as a placeholder for the textarea while it's empty - a gentle nudge
|
||||||
|
// toward a certain kind of writing, without dictating what gets written.
|
||||||
|
const placeholderPrompts = [
|
||||||
|
"today I noticed...",
|
||||||
|
"if you're reading this...",
|
||||||
|
"this place...",
|
||||||
|
"I wish someone knew...",
|
||||||
|
"the air smells like...",
|
||||||
|
"before I forget..."
|
||||||
|
];
|
||||||
|
|
||||||
|
// index of the prompt currently shown, and its crossfade opacity
|
||||||
|
let promptIndex = $state(0);
|
||||||
|
let promptOpacity = $state(1);
|
||||||
|
|
||||||
|
// whether the textarea currently has focus - tracked via onfocus/onblur
|
||||||
|
// on the textarea below, since this overlay's visibility needs to react
|
||||||
|
// to focus in JS, not just CSS.
|
||||||
|
let textareaFocused = $state(false);
|
||||||
|
|
||||||
|
// The overlay (see markup below) only exists while the textarea is both
|
||||||
|
// empty and unfocused. The moment either condition flips - the user
|
||||||
|
// starts typing, or just taps/clicks into the field - the prompts get
|
||||||
|
// out of the way entirely, per the feature spec.
|
||||||
|
let showPromptOverlay = $derived(text.length === 0 && !textareaFocused);
|
||||||
|
|
||||||
|
// Cycles through placeholderPrompts on a timer, crossfading between them
|
||||||
|
// via promptOpacity. This effect only runs while showPromptOverlay is
|
||||||
|
// true; the moment it becomes false (typing starts, or the field gains
|
||||||
|
// focus), the cleanup below clears any pending timers and the cycle
|
||||||
|
// simply stops - clearing the textarea (and leaving it unfocused) makes
|
||||||
|
// showPromptOverlay true again, re-running this effect and starting a
|
||||||
|
// fresh cycle.
|
||||||
|
$effect(() => {
|
||||||
|
if (!showPromptOverlay) return;
|
||||||
|
|
||||||
|
// Timing mirrors the fade conventions used elsewhere in the app
|
||||||
|
// (the opening ritual / "first here" bubble): a multi-second hold
|
||||||
|
// per prompt, then a ~1s crossfade into the next one.
|
||||||
|
const holdMs = 3000;
|
||||||
|
const fadeMs = 1000;
|
||||||
|
|
||||||
|
let fadeOutTimer;
|
||||||
|
let advanceTimer;
|
||||||
|
|
||||||
|
// Schedules one hold -> fade-out -> advance -> fade-in step, then
|
||||||
|
// schedules itself again. An $effect only re-runs when the values it
|
||||||
|
// READS change, and this step only WRITES promptIndex/promptOpacity,
|
||||||
|
// so the cycle has to keep itself going via setTimeout rather than
|
||||||
|
// relying on the effect re-running on its own.
|
||||||
|
function scheduleStep() {
|
||||||
|
fadeOutTimer = setTimeout(() => {
|
||||||
|
promptOpacity = 0; // begin crossfade out
|
||||||
|
|
||||||
|
advanceTimer = setTimeout(() => {
|
||||||
|
promptIndex = (promptIndex + 1) % placeholderPrompts.length;
|
||||||
|
promptOpacity = 1; // crossfade the new prompt in
|
||||||
|
scheduleStep();
|
||||||
|
}, fadeMs);
|
||||||
|
}, holdMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleStep();
|
||||||
|
|
||||||
|
// cleanup: runs when showPromptOverlay flips to false (typing starts
|
||||||
|
// or the field is focused) and on component unmount - clears
|
||||||
|
// whichever of the two timers is currently pending so the cycle
|
||||||
|
// stops cleanly with no stray callbacks.
|
||||||
|
return () => {
|
||||||
|
clearTimeout(fadeOutTimer);
|
||||||
|
clearTimeout(advanceTimer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
let selectedFile = $state(null);
|
let selectedFile = $state(null);
|
||||||
let imagePreview = $state(null); // what <img> will read
|
let imagePreview = $state(null); // what <img> will read
|
||||||
let imageError = $state(null);
|
let imageError = $state(null);
|
||||||
let checkingFace = $state(false); // this will show the checking image... message
|
let checkingFace = $state(false); // this will show the checking image... message
|
||||||
|
|
||||||
|
// --- mood color (intentionally unexplained feature) -------------------
|
||||||
|
// 5 preset pastel swatches, same hue/saturation/lightness formula as
|
||||||
|
// pins.js's randomPastel() (hsl(hue, 60%, 72%)) so a picked color sits
|
||||||
|
// naturally alongside the existing pin palette. Picking one is optional:
|
||||||
|
// selectedMoodColor stays null until tapped, and tapping the already-
|
||||||
|
// selected swatch again clears it back to null. null flows through to
|
||||||
|
// addMessage() -> Firestore as moodColor: null, and pins.js falls back to
|
||||||
|
// its existing random pastel for any pin with no moodColor.
|
||||||
|
const moodColors = [
|
||||||
|
'hsl(340, 60%, 72%)', // soft pink
|
||||||
|
'hsl(210, 60%, 72%)', // soft blue
|
||||||
|
'hsl(140, 60%, 72%)', // soft green
|
||||||
|
'hsl(265, 60%, 72%)', // soft purple
|
||||||
|
'hsl(40, 60%, 72%)' // soft peach/yellow
|
||||||
|
];
|
||||||
|
let selectedMoodColor = $state(null);
|
||||||
|
|
||||||
|
function toggleMoodColor(color) {
|
||||||
|
// tapping the selected swatch again deselects it (back to "no opinion")
|
||||||
|
selectedMoodColor = selectedMoodColor === color ? null : color;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleImageSelect(event) {
|
async function handleImageSelect(event) {
|
||||||
const file = event.target.files[0]; // only one file will be allowed so always index 0
|
const file = event.target.files[0]; // only one file will be allowed so always index 0
|
||||||
|
|
||||||
@@ -76,7 +175,12 @@
|
|||||||
imageUrl = await uploadImage(selectedFile);
|
imageUrl = await uploadImage(selectedFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
await addMessage(lat, lng, text.trim(), imageUrl);
|
await addMessage(lat, lng, text.trim(), imageUrl, selectedMoodColor);
|
||||||
|
|
||||||
|
// the global "memory counter" (meta/stats) just incremented inside
|
||||||
|
// addMessage() above - refetch it so the pill's count updates
|
||||||
|
// immediately for this device, without waiting on a page reload
|
||||||
|
refreshGlobalCount();
|
||||||
|
|
||||||
// reload pins so that they show up with new message
|
// reload pins so that they show up with new message
|
||||||
const { getNearbyMessages } = await import ('$lib/firebase/messages.js');
|
const { getNearbyMessages } = await import ('$lib/firebase/messages.js');
|
||||||
@@ -89,6 +193,7 @@
|
|||||||
text = '';
|
text = '';
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
imagePreview = null;
|
imagePreview = null;
|
||||||
|
selectedMoodColor = null;
|
||||||
submitting = false;
|
submitting = false;
|
||||||
|
|
||||||
// close the sheet
|
// close the sheet
|
||||||
@@ -117,12 +222,48 @@
|
|||||||
<span class="counter" class:over={remaining < 0}>{remaining}</span>
|
<span class="counter" class:over={remaining < 0}>{remaining}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- wrapper gives the rotating-placeholder overlay below a positioning context -->
|
||||||
|
<div class="textarea-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
placeholder="Share a moment for someone else to overhear"
|
aria-label="Leave a message"
|
||||||
|
onfocus={() => textareaFocused = true}
|
||||||
|
onblur={() => textareaFocused = false}
|
||||||
rows="5"
|
rows="5"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
|
<!-- Rotating placeholder overlay: the native placeholder attribute
|
||||||
|
can't crossfade between values, so this is a plain <p>
|
||||||
|
absolutely positioned over the textarea instead, styled to look
|
||||||
|
like placeholder text. It only exists in the DOM while the
|
||||||
|
field is empty and unfocused (showPromptOverlay above) -
|
||||||
|
aria-hidden since it's an ambient visual cue rather than real
|
||||||
|
content, and pointer-events: none so it never blocks clicks
|
||||||
|
into the textarea underneath. -->
|
||||||
|
{#if showPromptOverlay}
|
||||||
|
<p class="prompt-overlay" style="opacity: {promptOpacity}" aria-hidden="true">
|
||||||
|
{placeholderPrompts[promptIndex]}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- mood color swatches: 5 preset pastel colors, no labels or explanation
|
||||||
|
by design (see project brief / pins.js). selecting one is optional -
|
||||||
|
a subtle ring + scale-up marks the selected swatch; tapping it again
|
||||||
|
deselects. -->
|
||||||
|
<div class="mood-row">
|
||||||
|
{#each moodColors as color}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mood-swatch"
|
||||||
|
class:selected={selectedMoodColor === color}
|
||||||
|
style="background-color: {color}"
|
||||||
|
aria-label="color"
|
||||||
|
onclick={() => toggleMoodColor(color)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class = "image-section">
|
<div class = "image-section">
|
||||||
{#if checkingFace}
|
{#if checkingFace}
|
||||||
<p class="checking">Checking image...</p>
|
<p class="checking">Checking image...</p>
|
||||||
@@ -265,6 +406,27 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* positioning context for the rotating-placeholder overlay (see
|
||||||
|
.prompt-overlay below). Plain block on mobile, sized by the
|
||||||
|
textarea's natural height; .compose.desktop below gives it the
|
||||||
|
flex-growing role textarea itself used to have. */
|
||||||
|
.textarea-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* on desktop, .textarea-wrap takes over the flex-growing role that
|
||||||
|
textarea used to have on its own, so the wrapper - and the textarea
|
||||||
|
inside it - still fill the remaining square space. Scoped to desktop
|
||||||
|
only: on mobile .compose isn't a flex container, and an unscoped
|
||||||
|
display: flex here would make .textarea-wrap a flex column with no
|
||||||
|
defined height, collapsing the textarea inside it to 0 height. */
|
||||||
|
.compose.desktop .textarea-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1.5px solid #eee;
|
border: 1.5px solid #eee;
|
||||||
@@ -288,6 +450,28 @@
|
|||||||
border-color: #111;
|
border-color: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* visually mimics the textarea's placeholder text - same font, size,
|
||||||
|
line-height and padding as the textarea (the extra 1.5px on padding
|
||||||
|
accounts for the textarea's border under box-sizing: border-box, so
|
||||||
|
the overlay text lines up with where typed text would actually
|
||||||
|
start), but lighter in color. Absolutely positioned over the textarea
|
||||||
|
so promptOpacity (set in the $effect above) can crossfade it between
|
||||||
|
prompts - something a native placeholder attribute can't do. */
|
||||||
|
.prompt-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: calc(0.9rem + 1.5px);
|
||||||
|
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #aaa;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.submit {
|
.submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
@@ -307,6 +491,35 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* mood color swatch row - small circles, no text/labels by design */
|
||||||
|
.mood-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-swatch {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
/* outline (not box-shadow, for the flat 2D look) starts transparent
|
||||||
|
so the ring only appears once .selected adds a visible color -
|
||||||
|
transitioning it avoids a layout jump */
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
transition: outline-color 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* selected swatch: dark ring + slight scale-up, the only visual cue
|
||||||
|
that a color is picked */
|
||||||
|
.mood-swatch.selected {
|
||||||
|
outline-color: #111;
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
.image-section {
|
.image-section {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import { messagesStore } from '$lib/stores/messagesStore.js'; // pass the messages store here
|
import { messagesStore } from '$lib/stores/messagesStore.js'; // pass the messages store here
|
||||||
import { mapStore } from '$lib/stores/mapStore.js'; // use this to track interactions with da map
|
import { mapStore } from '$lib/stores/mapStore.js'; // use this to track interactions with da map
|
||||||
import { unreadPin, readPin, echoedPin, locationPin } from '$lib/utils/pins.js'; // custom SVG pin generators
|
import { messagePin, locationPin } from '$lib/utils/pins.js'; // custom SVG pin generators
|
||||||
|
import { mapStyles } from '$lib/utils/mapStyles.js'; // desaturated/atmospheric map styling - only works now that mapId is gone
|
||||||
|
import { getTrail, addTrailPoint } from '$lib/utils/trail.js'; // private, on-device-only location history
|
||||||
|
import { distanceMeters, bearingDegrees } from '$lib/utils/geo.js'; // great-circle distance/bearing for "sensing mode"
|
||||||
|
|
||||||
// export let latitude;
|
// export let latitude;
|
||||||
// export let longitude;
|
// export let longitude;
|
||||||
// ^ this didn't work for some reason, so instead we get the props like this: (based on internet research this is the fix)
|
// ^ this didn't work for some reason, so instead we get the props like this: (based on internet research this is the fix)
|
||||||
let { lat, lng } = $props();
|
// firstHereMounted/firstHereVisible: "you're the first here" bubble state,
|
||||||
|
// computed in +page.svelte (zero-results check + fade timing) - rendered
|
||||||
|
// here so it can be positioned near the user's location marker below.
|
||||||
|
// sensingMode: combined compass-arrow + proximity-vibration mode, toggled
|
||||||
|
// by SensingToggle in +page.svelte (mobile-only - see the effect below).
|
||||||
|
let { lat, lng, firstHereMounted = false, firstHereVisible = false, sensingMode = false } = $props();
|
||||||
|
|
||||||
let mapDiv;
|
let mapDiv;
|
||||||
let map = $state(null);
|
let map = $state(null);
|
||||||
|
|
||||||
let markers = []; // keep track of pins on map
|
let markers = []; // keep track of pins on map
|
||||||
let userMarker;
|
|
||||||
let AdvancedMarkerElement;
|
|
||||||
|
|
||||||
// ids of messages the user has opened, for this session only (resets on reload)
|
// Marker/Size/Point/Polyline are only available once their libraries
|
||||||
let readIds = new SvelteSet();
|
// have loaded (assigned in onMount below) - declared here so
|
||||||
|
// toMarkerIcon, addUserLocationMarker, renderPins, and the trail effect
|
||||||
|
// can all use them.
|
||||||
|
let Marker, Size, Point, Polyline;
|
||||||
|
|
||||||
|
// --- personal location trail (private, on-device only) ----------------
|
||||||
|
// `trailPoints` holds this device's full visit history (see trail.js),
|
||||||
|
// read/appended once in onMount. `trailPolyline` is the Polyline
|
||||||
|
// instance once created (lazily, inside the $effect below) - plain
|
||||||
|
// variables, not runes, since they're written once and don't need to
|
||||||
|
// trigger re-renders themselves.
|
||||||
|
let trailPoints = [];
|
||||||
|
let trailPolyline;
|
||||||
|
|
||||||
|
// `showTrail` is the only piece of UI state for this feature - toggled
|
||||||
|
// by the small button in the markup below. Defaults to ON: the trail is
|
||||||
|
// styled to be faint enough (see the $effect below) that it doesn't
|
||||||
|
// compete with message pins, so showing it by default lets people
|
||||||
|
// notice this quiet "personal map of presence" without it being loud -
|
||||||
|
// the toggle just lets them hide it if they'd rather not see it.
|
||||||
|
let showTrail = $state(true);
|
||||||
|
|
||||||
|
// --- "sensing mode": compass arrow + proximity vibration ---------------
|
||||||
|
// `heading` is the device's current compass heading in degrees (0 = true
|
||||||
|
// north, clockwise), updated by the deviceorientation listener below.
|
||||||
|
// Defaults to 0 ("facing north") rather than null: devices/browsers
|
||||||
|
// without an orientation sensor (e.g. desktop) never fire that event, so
|
||||||
|
// heading would otherwise stay null forever and the arrow would never
|
||||||
|
// appear at all. With a 0 default, the arrow instead falls back to a
|
||||||
|
// "north-up" compass - pointing at the absolute bearing toward the
|
||||||
|
// target - and starts rotating relative to the device's actual facing
|
||||||
|
// direction the moment real orientation data arrives.
|
||||||
|
// `nearestOffScreenBearing` is the compass bearing (also 0-360, 0 = true
|
||||||
|
// north) from the user's current position to the nearest message pin -
|
||||||
|
// recomputed on the interval below. `null` only when there's no nearby
|
||||||
|
// message at all (or the map isn't ready yet), in which case the arrow
|
||||||
|
// stays hidden - there's nothing to point at.
|
||||||
|
let heading = $state(0);
|
||||||
|
let nearestOffScreenBearing = $state(null);
|
||||||
|
|
||||||
|
// The arrow needs to point at `nearestOffScreenBearing` RELATIVE TO the
|
||||||
|
// device's current facing direction - e.g. if the pin is due north
|
||||||
|
// (bearing 0) and the device is currently facing east (heading 90), the
|
||||||
|
// arrow should point 90deg counter-clockwise (-90 / 270) from "up" on
|
||||||
|
// the screen, since "up" on the screen = wherever the device is facing.
|
||||||
|
// Subtracting heading from the target bearing gives exactly that (with
|
||||||
|
// heading's default of 0, "up" simply means "true north" until real
|
||||||
|
// orientation data arrives). `null` (nothing to point at) hides the
|
||||||
|
// arrow entirely.
|
||||||
|
let arrowRotation = $derived(
|
||||||
|
nearestOffScreenBearing !== null
|
||||||
|
? (nearestOffScreenBearing - heading + 360) % 360
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
// converts a { url, size } pin (from pins.js) into an Icon object for
|
||||||
|
// the legacy Marker API - centers the icon on its coordinate (anchor at
|
||||||
|
// size/2, size/2) the same way the old AdvancedMarkerElement content's
|
||||||
|
// `translateY(-50%)` did, but for both axes since Marker's default
|
||||||
|
// anchor is bottom-center rather than center-center.
|
||||||
|
function toMarkerIcon({ url, size }) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
scaledSize: new Size(size, size),
|
||||||
|
anchor: new Point(size / 2, size / 2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let userMarker;
|
||||||
|
|
||||||
/** Jisu Legacy - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
|
/** Jisu Legacy - 내 위치 마커 (메시지 핀과 구분되는 파란 점) */
|
||||||
function addUserLocationMarker(centerLat, centerLng) {
|
function addUserLocationMarker(centerLat, centerLng) {
|
||||||
// use the shared locationPin() so the user's marker matches the
|
// use the shared locationPin() so the user's marker matches the
|
||||||
// style of the message pins, instead of building a one-off dot here
|
// style of the message pins, instead of building a one-off dot here
|
||||||
userMarker = new AdvancedMarkerElement({
|
userMarker = new Marker({
|
||||||
position: { lat: centerLat, lng: centerLng },
|
position: { lat: centerLat, lng: centerLng },
|
||||||
map,
|
map,
|
||||||
title: 'Your location',
|
title: 'Your location',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
content: locationPin()
|
icon: toMarkerIcon(locationPin())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,20 +117,39 @@
|
|||||||
version: 'weekly',
|
version: 'weekly',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { Map } = await importLibrary('maps');
|
// switched from AdvancedMarkerElement back to the legacy Marker API
|
||||||
({ AdvancedMarkerElement } = await importLibrary('marker'));
|
// (with SVG data-URL icons from pins.js) so a JS `styles` array can
|
||||||
|
// take effect on this map again - Google ignores any `styles` array
|
||||||
|
// on maps that have a mapId set, and AdvancedMarkerElement requires
|
||||||
|
// one. NOTE: the legacy `Marker` class (plus `Size`/`Point`) live in
|
||||||
|
// the 'marker'/'core' libraries, NOT 'maps' - both are still needed.
|
||||||
|
// Polyline (used by the location trail below) lives in 'maps'
|
||||||
|
// alongside Map.
|
||||||
|
const { Map, Polyline: PolylineCtor } = await importLibrary('maps');
|
||||||
|
Polyline = PolylineCtor;
|
||||||
|
({ Marker } = await importLibrary('marker'));
|
||||||
|
({ Size, Point } = await importLibrary('core'));
|
||||||
|
|
||||||
|
// --- personal location trail: log this visit ----------------------
|
||||||
|
// addTrailPoint() writes { lat, lng, timestamp: Date.now() } to
|
||||||
|
// localStorage and returns the FULL updated history (oldest first),
|
||||||
|
// including this visit - so the line drawn below is always
|
||||||
|
// up to date. This runs unconditionally on every successful
|
||||||
|
// geolocation fetch (MapView only mounts once lat/lng are known),
|
||||||
|
// regardless of `showTrail` - the history keeps growing even while
|
||||||
|
// the trail is hidden. Nothing here touches Firestore; it's purely
|
||||||
|
// local to this device/browser (see trail.js).
|
||||||
|
trailPoints = addTrailPoint(centerLat, centerLng);
|
||||||
|
|
||||||
// NOTE: a JS `styles` array was tried here for the soft white look, but
|
|
||||||
// Google Maps ignores (and errors on) inline `styles` when `mapId` is set,
|
|
||||||
// and mapId is required for AdvancedMarkerElement (our pins). Removed so
|
|
||||||
// pins render again; the soft style would need to be a Cloud-based Map
|
|
||||||
// Style configured for this mapId in the Google Cloud Console instead.
|
|
||||||
map = new Map(mapDiv, {
|
map = new Map(mapDiv, {
|
||||||
center: { lat: centerLat, lng: centerLng },
|
center: { lat: centerLat, lng: centerLng },
|
||||||
zoom: 15,
|
zoom: 15,
|
||||||
disableDefaultUI: true,
|
disableDefaultUI: true,
|
||||||
gestureHandling: 'greedy',
|
gestureHandling: 'greedy',
|
||||||
mapId: 'DEMO_MAP_ID'
|
// mapId removed - legacy Markers don't need it, and removing it
|
||||||
|
// is what lets this `styles` array be respected (see comment above) -
|
||||||
|
// desaturated/atmospheric look defined in mapStyles.js
|
||||||
|
styles: mapStyles
|
||||||
});
|
});
|
||||||
|
|
||||||
addUserLocationMarker(centerLat, centerLng);
|
addUserLocationMarker(centerLat, centerLng);
|
||||||
@@ -66,33 +158,30 @@
|
|||||||
// function to render pins
|
// function to render pins
|
||||||
function renderPins(messages) {
|
function renderPins(messages) {
|
||||||
// clear current pins
|
// clear current pins
|
||||||
markers.forEach(marker => (marker.map = null));
|
markers.forEach(marker => marker.setMap(null));
|
||||||
markers = [];
|
markers = [];
|
||||||
|
|
||||||
messages.forEach(message => {
|
messages.forEach(message => {
|
||||||
// pick a pin shape based on message state:
|
// every message gets the same plain circle pin (previously this
|
||||||
// - messages the user has opened this session get the read circle
|
// varied - star/circle/heart - based on read/unread/echoed state,
|
||||||
// (takes priority - once you've read it, it's a circle even if echoed)
|
// swapped on click; that's gone now, so clicking a pin never
|
||||||
// - otherwise, echoed messages (echoCount > 0) get the heart pin
|
// changes its appearance, only opens the detail view below).
|
||||||
// - everything else gets the unread star
|
// message.moodColor (optional, set by ComposeSheet's swatch row and
|
||||||
let icon;
|
// stored in Firestore via addMessage) overrides the random pastel
|
||||||
if (readIds.has(message.id)) {
|
// color inside messagePin() when present - see pins.js's pinColor()
|
||||||
icon = readPin();
|
const icon = messagePin(message.moodColor);
|
||||||
} else if (message.echoCount > 0) {
|
|
||||||
icon = echoedPin();
|
|
||||||
} else {
|
|
||||||
icon = unreadPin();
|
|
||||||
}
|
|
||||||
|
|
||||||
const marker = new AdvancedMarkerElement({
|
const marker = new Marker({
|
||||||
position: { lat: message.lat, lng: message.lng },
|
position: { lat: message.lat, lng: message.lng },
|
||||||
map,
|
map,
|
||||||
title: message.text,
|
title: message.text,
|
||||||
content: icon // AdvancedMarkerElement takes a DOM node via `content`, not an `icon` option
|
icon: toMarkerIcon(icon)
|
||||||
});
|
});
|
||||||
|
|
||||||
marker.addEventListener('click', () => {
|
// legacy Marker uses the Maps JS event system (addListener), not
|
||||||
readIds.add(message.id); // mark as read; SvelteSet triggers the $effect below to re-render pins
|
// DOM events - this also avoids the AdvancedMarkerElement
|
||||||
|
// gmp-click deprecation warning entirely
|
||||||
|
marker.addListener('click', () => {
|
||||||
mapStore.set({ selectedMessage: message, composing: false });
|
mapStore.set({ selectedMessage: message, composing: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,11 +195,275 @@
|
|||||||
renderPins($messagesStore); // we put pins on the map
|
renderPins($messagesStore); // we put pins on the map
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- personal location trail: draw + toggle ---------------------------
|
||||||
|
// Re-runs whenever `map` (becomes available once, in onMount) or
|
||||||
|
// `showTrail` (toggled by the button below) changes. `trailPoints` and
|
||||||
|
// `Polyline` are plain variables, not runes - they're both assigned in
|
||||||
|
// onMount BEFORE `map` is, in the same synchronous block, so by the time
|
||||||
|
// `map` becomes non-null and triggers this effect, they're already set
|
||||||
|
// (same pattern the renderPins effect above relies on for Marker/etc).
|
||||||
|
$effect(() => {
|
||||||
|
if (!map || !Polyline) return;
|
||||||
|
|
||||||
|
// lazily create the Polyline once, the first time the map is ready.
|
||||||
|
// Edge case: a single point (first-ever visit) has nothing to
|
||||||
|
// connect, so we simply don't create a line - trailPolyline stays
|
||||||
|
// undefined and the toggle below has nothing to show/hide.
|
||||||
|
if (!trailPolyline && trailPoints.length >= 2) {
|
||||||
|
const path = trailPoints.map(p => ({ lat: p.lat, lng: p.lng }));
|
||||||
|
|
||||||
|
trailPolyline = new Polyline({
|
||||||
|
path,
|
||||||
|
clickable: false, // purely decorative - shouldn't intercept clicks meant for the map/pins
|
||||||
|
zIndex: 1, // sit beneath message/location pins (which default to higher stacking)
|
||||||
|
// the solid stroke is fully transparent - only the dash
|
||||||
|
// `icons` below are actually drawn, which is how dashed
|
||||||
|
// lines are done with google.maps.Polyline (it has no
|
||||||
|
// native dash-pattern option)
|
||||||
|
strokeOpacity: 0,
|
||||||
|
icons: [{
|
||||||
|
icon: {
|
||||||
|
path: 'M 0,-1 0,1', // a short vertical dash
|
||||||
|
strokeOpacity: 0.35, // faint - shouldn't compete visually with message pins
|
||||||
|
strokeColor: '#999', // neutral grey, fits the desaturated/parchment map palette
|
||||||
|
scale: 3
|
||||||
|
},
|
||||||
|
offset: '0',
|
||||||
|
repeat: '14px' // gap between dashes
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// show/hide based on the toggle - setMap(null) removes it from the
|
||||||
|
// map without destroying the instance, so toggling back on doesn't
|
||||||
|
// need to rebuild the path.
|
||||||
|
if (trailPolyline) {
|
||||||
|
trailPolyline.setMap(showTrail ? map : null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- sensing mode: nearest off-screen pin ------------------------------
|
||||||
|
// Looks at every currently-loaded nearby message and finds both (a) the
|
||||||
|
// closest one that's currently outside the map's visible bounds, and (b)
|
||||||
|
// the closest one overall. Sets nearestOffScreenBearing to the bearing
|
||||||
|
// toward (a) if one exists, otherwise falls back to (b).
|
||||||
|
// The fallback matters because getNearbyMessages's ~1.2km geohash area is
|
||||||
|
// roughly the same size as the map's default viewport - in the common
|
||||||
|
// case EVERY nearby message is already on-screen, so "off-screen only"
|
||||||
|
// would mean nearestOffScreenBearing (and therefore the arrow) is null
|
||||||
|
// almost all the time. Preferring an off-screen pin (pointing toward
|
||||||
|
// something not already visible is more useful) but falling back to the
|
||||||
|
// nearest pin overall means the arrow still has something to point at,
|
||||||
|
// instead of never appearing. Stays null only if there are no nearby
|
||||||
|
// messages at all (or the map/bounds aren't ready yet) - either way, the
|
||||||
|
// arrow hides itself via arrowRotation above.
|
||||||
|
function updateNearestOffScreenPin(userLat, userLng) {
|
||||||
|
const bounds = map?.getBounds();
|
||||||
|
|
||||||
|
let nearestOffScreen = null;
|
||||||
|
let nearestOffScreenDist = Infinity;
|
||||||
|
let nearestOverall = null;
|
||||||
|
let nearestOverallDist = Infinity;
|
||||||
|
|
||||||
|
for (const message of $messagesStore) {
|
||||||
|
const dist = distanceMeters(userLat, userLng, message.lat, message.lng);
|
||||||
|
|
||||||
|
if (dist < nearestOverallDist) {
|
||||||
|
nearestOverallDist = dist;
|
||||||
|
nearestOverall = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScreen = bounds?.contains({ lat: message.lat, lng: message.lng }) ?? false;
|
||||||
|
if (!onScreen && dist < nearestOffScreenDist) {
|
||||||
|
nearestOffScreenDist = dist;
|
||||||
|
nearestOffScreen = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = nearestOffScreen ?? nearestOverall;
|
||||||
|
|
||||||
|
nearestOffScreenBearing = target
|
||||||
|
? bearingDegrees(userLat, userLng, target.lat, target.lng)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sensing mode: proximity vibration ----------------------------------
|
||||||
|
// Checks every nearby message against (userLat, userLng) and, for any pin
|
||||||
|
// within PROXIMITY_METERS that this session hasn't already buzzed for,
|
||||||
|
// fires a single soft pulse. `vibratedIds` is a plain (non-reactive) Set -
|
||||||
|
// it only needs to track membership for this check, not drive any UI -
|
||||||
|
// and is recreated each time sensing mode is turned on (see the effect
|
||||||
|
// below), so re-enabling the toggle lets the same pin buzz again.
|
||||||
|
const PROXIMITY_METERS = 50;
|
||||||
|
let vibratedIds = new Set();
|
||||||
|
|
||||||
|
function checkProximityVibration(userLat, userLng) {
|
||||||
|
// gracefully do nothing on browsers without the Vibration API
|
||||||
|
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') return;
|
||||||
|
|
||||||
|
for (const message of $messagesStore) {
|
||||||
|
if (vibratedIds.has(message.id)) continue;
|
||||||
|
|
||||||
|
const dist = distanceMeters(userLat, userLng, message.lat, message.lng);
|
||||||
|
if (dist <= PROXIMITY_METERS) {
|
||||||
|
navigator.vibrate(100); // single soft pulse
|
||||||
|
vibratedIds.add(message.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sensing mode: lifecycle --------------------------------------------
|
||||||
|
// Runs whenever `sensingMode` changes (toggled by SensingToggle in
|
||||||
|
// +page.svelte). While ON, this sets up everything the two features need
|
||||||
|
// and tears it all down again the moment it's turned OFF or this
|
||||||
|
// component unmounts - no listeners/timers/watchers should outlive the
|
||||||
|
// toggle being on.
|
||||||
|
$effect(() => {
|
||||||
|
if (!sensingMode) return;
|
||||||
|
|
||||||
|
// fresh "already buzzed" set each time sensing mode is (re-)enabled
|
||||||
|
vibratedIds = new Set();
|
||||||
|
|
||||||
|
// Live position for sensing: separate from the `lat`/`lng` props
|
||||||
|
// (which only reflect the one-time fix used to center the map) -
|
||||||
|
// watchPosition keeps these updated as the user actually walks
|
||||||
|
// around, which is what makes "proximity" and "nearest off-screen
|
||||||
|
// pin" meaningful. Starts from the map's initial fix so there's a
|
||||||
|
// usable value immediately, before the first watch update arrives.
|
||||||
|
let userLat = Number(lat);
|
||||||
|
let userLng = Number(lng);
|
||||||
|
|
||||||
|
// --- DeviceOrientationEvent: device compass heading -----------------
|
||||||
|
// iOS exposes a ready-made compass heading via webkitCompassHeading
|
||||||
|
// (0 = true north, increases clockwise). Other browsers only provide
|
||||||
|
// `alpha` (rotation around the z-axis, increasing COUNTER-clockwise
|
||||||
|
// from north when the device is lying flat) - `360 - alpha` converts
|
||||||
|
// that into the same "clockwise from north" convention. This is an
|
||||||
|
// approximation (it assumes the device is held flat/upright facing
|
||||||
|
// the user) but is good enough for a rough directional arrow.
|
||||||
|
function handleOrientation(event) {
|
||||||
|
if (event.webkitCompassHeading != null) {
|
||||||
|
heading = event.webkitCompassHeading;
|
||||||
|
} else if (event.alpha != null) {
|
||||||
|
heading = (360 - event.alpha) % 360;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && typeof DeviceOrientationEvent !== 'undefined') {
|
||||||
|
window.addEventListener('deviceorientation', handleOrientation);
|
||||||
|
// Chrome on Android only reports a true compass heading via
|
||||||
|
// 'deviceorientationabsolute' - plain 'deviceorientation' events
|
||||||
|
// there have `absolute: false` and `alpha` relative to whatever
|
||||||
|
// direction the device happened to be facing when it booted, not
|
||||||
|
// true north, which made the arrow point in an arbitrary (and
|
||||||
|
// sometimes never-updating) direction. iOS doesn't dispatch this
|
||||||
|
// event at all, so it just never fires there and
|
||||||
|
// webkitCompassHeading (above) continues to be used.
|
||||||
|
window.addEventListener('deviceorientationabsolute', handleOrientation);
|
||||||
|
}
|
||||||
|
// if DeviceOrientationEvent doesn't exist at all, heading just stays
|
||||||
|
// null forever - arrowRotation stays null and the arrow stays hidden,
|
||||||
|
// which is the "handle unavailable API gracefully" behavior.
|
||||||
|
|
||||||
|
// --- periodic sensing check ------------------------------------------
|
||||||
|
// every few seconds, recompute the nearest off-screen pin (for the
|
||||||
|
// arrow) and run the proximity-vibration check, using the latest
|
||||||
|
// watched position.
|
||||||
|
function runSensingCheck() {
|
||||||
|
updateNearestOffScreenPin(userLat, userLng);
|
||||||
|
checkProximityVibration(userLat, userLng);
|
||||||
|
}
|
||||||
|
|
||||||
|
runSensingCheck();
|
||||||
|
const intervalId = setInterval(runSensingCheck, 3000);
|
||||||
|
|
||||||
|
// keep userLat/userLng current as the device moves
|
||||||
|
let watchId = null;
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.geolocation?.watchPosition) {
|
||||||
|
watchId = navigator.geolocation.watchPosition(
|
||||||
|
(position) => {
|
||||||
|
userLat = position.coords.latitude;
|
||||||
|
userLng = position.coords.longitude;
|
||||||
|
},
|
||||||
|
() => {}, // ignore watch errors - sensing just keeps using the last-known position
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 5000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup: tear everything down the moment sensing mode is turned off
|
||||||
|
// (or this component is destroyed) - no lingering listeners/timers.
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('deviceorientation', handleOrientation);
|
||||||
|
window.removeEventListener('deviceorientationabsolute', handleOrientation);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
if (watchId !== null) {
|
||||||
|
navigator.geolocation.clearWatch(watchId);
|
||||||
|
}
|
||||||
|
heading = 0; // back to the "facing north" default (see declaration above)
|
||||||
|
nearestOffScreenBearing = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="map" bind:this={mapDiv}></div>
|
<div class="map-wrapper">
|
||||||
|
<div class="map" bind:this={mapDiv}></div>
|
||||||
|
|
||||||
|
<!-- small, unobtrusive toggle for the personal location trail (private,
|
||||||
|
on-device only - see trail.js). Placed top-left so it's discoverable
|
||||||
|
without competing with the FAB (bottom-right) or top bars elsewhere. -->
|
||||||
|
<button
|
||||||
|
class="trail-toggle"
|
||||||
|
class:active={showTrail}
|
||||||
|
onclick={() => showTrail = !showTrail}
|
||||||
|
aria-label={showTrail ? 'Hide your location trail' : 'Show your location trail'}
|
||||||
|
title={showTrail ? 'Hide your location trail' : 'Show your location trail'}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||||
|
<path d="M1.5 8 H5 M7.5 8 H9.5 M12 8 H14.5" stroke-dasharray="2 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- "you're the first here" speech bubble. The map is created with
|
||||||
|
center: {lat, lng} (see onMount) and the user-location marker is
|
||||||
|
drawn at that same center point, so positioning this bubble at
|
||||||
|
50%/50% of .map-wrapper places it directly above the marker. -->
|
||||||
|
{#if firstHereMounted}
|
||||||
|
<p class="first-here" class:visible={firstHereVisible} aria-hidden="true">
|
||||||
|
no one has been here before — or at least, no one who said so.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- "sensing mode" compass arrow: only rendered while sensing mode is on
|
||||||
|
AND there's a heading + a nearby message to point at (arrowRotation
|
||||||
|
is null otherwise - see the $derived above and
|
||||||
|
updateNearestOffScreenPin's fallback). Bottom-left placement
|
||||||
|
keeps it clear of .trail-toggle (top-left) and the FAB/SensingToggle
|
||||||
|
(bottom-right, in +page.svelte). The inner <svg> is what actually
|
||||||
|
rotates, via arrowRotation.
|
||||||
|
Also hidden while a message is open (BottomSheet/SidePanel showing
|
||||||
|
selectedMessage) - the arrow's bottom-left position overlaps that
|
||||||
|
reading view, and pointing toward another pin isn't useful while
|
||||||
|
you're in the middle of reading one. -->
|
||||||
|
{#if sensingMode && arrowRotation !== null && !$mapStore.selectedMessage}
|
||||||
|
<div class="compass-arrow" aria-hidden="true">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" style="transform: rotate({arrowRotation}deg)">
|
||||||
|
<path d="M10 1 L16 15 L10 11.5 L4 15 Z" fill="#9c8ad6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* positioning context for .trail-toggle, which is absolutely placed
|
||||||
|
within the map area regardless of the desktop/mobile layout in
|
||||||
|
+page.svelte */
|
||||||
|
.map-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* fill the .map-container parent (height: 100% instead of min-height: 100vh)
|
/* fill the .map-container parent (height: 100% instead of min-height: 100vh)
|
||||||
so that the parent's padding-bottom on mobile actually shrinks the
|
so that the parent's padding-bottom on mobile actually shrinks the
|
||||||
visible map instead of the map overflowing past it */
|
visible map instead of the map overflowing past it */
|
||||||
@@ -118,4 +471,107 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "sensing mode" compass arrow - small round pastel "puck", bottom-left
|
||||||
|
of the map (clear of .trail-toggle at top-left and the FAB/
|
||||||
|
SensingToggle at bottom-right in +page.svelte). bottom offset clears
|
||||||
|
the mobile bottom nav (64px) the same way .fab's 5rem does. The arrow
|
||||||
|
<svg> inside is rotated via arrowRotation (see <script>); the puck
|
||||||
|
itself never rotates. */
|
||||||
|
.compass-arrow {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 5rem;
|
||||||
|
left: 1rem;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compass-arrow svg {
|
||||||
|
transition: transform 0.15s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* small circular toggle, top-left of the map - flat 2D style (no
|
||||||
|
shadow) matching the rest of the app's current look. Muted/translucent
|
||||||
|
so it doesn't draw attention away from the map itself; .active just
|
||||||
|
darkens it slightly to show the trail is currently on. */
|
||||||
|
.trail-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
color: #aaa;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 120;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trail-toggle.active {
|
||||||
|
color: #666;
|
||||||
|
border-color: rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "you're the first here" speech bubble - "cute speech bubble" look:
|
||||||
|
soft cream background (same #f9f7f4 as the opening ritual overlay in
|
||||||
|
+layout.svelte) with rounded corners, so the text reads clearly
|
||||||
|
against the desaturated map. Positioned at the center of .map-wrapper
|
||||||
|
(= the user-location marker's position, see comment in the markup
|
||||||
|
above) and shifted up + left by its own size/2 plus the marker's
|
||||||
|
16px radius, so the bubble sits just above the marker with its tail
|
||||||
|
(::after below) pointing down at it. Starts invisible; .visible fades
|
||||||
|
it in/out over 1.2s (timing controlled by the $effects in +page.svelte). */
|
||||||
|
.first-here {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, calc(-100% - 16px));
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.85rem 1.4rem;
|
||||||
|
max-width: 80%;
|
||||||
|
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #666;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 50;
|
||||||
|
background: #f9f7f4;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first-here.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* small triangular "tail" pointing down from the bubble toward the
|
||||||
|
location marker beneath it */
|
||||||
|
.first-here::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 8px solid transparent;
|
||||||
|
border-right: 8px solid transparent;
|
||||||
|
border-top: 8px solid #f9f7f4;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
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 { getDecayInfo } from '$lib/utils/time.js';
|
||||||
import { echoMessage } from '$lib/firebase/messages.js';
|
import { echoMessage } from '$lib/firebase/messages.js';
|
||||||
import { playEchoTone } from '$lib/utils/sound.js';
|
import { playEchoTone } from '$lib/utils/sound.js';
|
||||||
|
import { getPresenceText } from '$lib/utils/presence.js';
|
||||||
|
import SharePopover from '$lib/components/SharePopover.svelte';
|
||||||
|
import {
|
||||||
|
incrementRead,
|
||||||
|
incrementEchoed,
|
||||||
|
incrementLetGo,
|
||||||
|
hasSeenLastChancePrompt,
|
||||||
|
markLastChancePromptSeen
|
||||||
|
} from '$lib/utils/stats.js';
|
||||||
|
|
||||||
// currently-selected message; when set, the detail view is shown instead of the list
|
// currently-selected message; when set, the detail view is shown instead of the list
|
||||||
let { message } = $props();
|
let { message } = $props();
|
||||||
@@ -18,6 +27,53 @@
|
|||||||
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
message ? getDecayInfo(message.createdAt, message.lastEchoAt) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- "last chance" state -------------------------------------------------
|
||||||
|
// A message is on its genuinely LAST day if it has 1 (or 0) days left AND
|
||||||
|
// isn't already expired/faded - daysLeft <= 1 && !isExpired. getDecayInfo()
|
||||||
|
// derives daysLeft from lastEchoAt, so a message that was recently echoed
|
||||||
|
// already has a much higher daysLeft and won't trigger this - "hasn't been
|
||||||
|
// echoed recently enough to have more time" is automatically satisfied
|
||||||
|
// whenever this condition is true.
|
||||||
|
let isLastChance = $derived(
|
||||||
|
decay ? (decay.daysLeft <= 1 && !decay.isExpired) : false
|
||||||
|
);
|
||||||
|
|
||||||
|
// one-time "last chance" prompt for the currently-open message
|
||||||
|
let showLastChancePrompt = $state(false);
|
||||||
|
|
||||||
|
// tracks which message was open last time this effect ran, so the
|
||||||
|
// read-count + prompt logic below only fires once per "open" (not on
|
||||||
|
// every reactive re-run while the same message stays selected)
|
||||||
|
let previousMessageId = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const currentId = message?.id ?? null;
|
||||||
|
|
||||||
|
if (currentId && currentId !== previousMessageId) {
|
||||||
|
// --- engagement stat: a detail view was just opened -----------------
|
||||||
|
incrementRead();
|
||||||
|
|
||||||
|
// --- one-time "last chance" prompt -----------------------------------
|
||||||
|
// only show it if this message is CURRENTLY in its last-chance state
|
||||||
|
// AND this device hasn't already seen the prompt for this message ID.
|
||||||
|
// Marking it seen immediately (rather than waiting for an explicit
|
||||||
|
// dismiss) means it won't reappear even if the user navigates away
|
||||||
|
// without closing it.
|
||||||
|
if (isLastChance && !hasSeenLastChancePrompt(currentId)) {
|
||||||
|
showLastChancePrompt = true;
|
||||||
|
markLastChancePromptSeen(currentId);
|
||||||
|
} else {
|
||||||
|
showLastChancePrompt = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentId) {
|
||||||
|
showLastChancePrompt = false; // closed - reset for the next open
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMessageId = currentId;
|
||||||
|
});
|
||||||
|
|
||||||
// only show the 3 most recent nearby messages, so the list view fits
|
// only show the 3 most recent nearby messages, so the list view fits
|
||||||
// without scrolling and the hint card stays visible underneath it
|
// without scrolling and the hint card stays visible underneath it
|
||||||
let topMessages = $derived(
|
let topMessages = $derived(
|
||||||
@@ -26,6 +82,11 @@
|
|||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ambient presence indicator ("X people have been here today"), recomputed
|
||||||
|
// whenever the nearby-messages store updates (i.e. whenever
|
||||||
|
// getNearbyMessages refreshes the data)
|
||||||
|
let presenceText = $derived(getPresenceText($messagesStore));
|
||||||
|
|
||||||
// back to the list view
|
// back to the list view
|
||||||
function close() {
|
function close() {
|
||||||
mapStore.set({ selectedMessage: null, composing: false });
|
mapStore.set({ selectedMessage: null, composing: false });
|
||||||
@@ -33,10 +94,20 @@
|
|||||||
|
|
||||||
async function handleEcho() {
|
async function handleEcho() {
|
||||||
await echoMessage(message.id);
|
await echoMessage(message.id);
|
||||||
|
// engagement stat: Echo pressed
|
||||||
|
incrementEchoed();
|
||||||
// gentle ascending "thank you" tone confirming the echo went through
|
// gentle ascending "thank you" tone confirming the echo went through
|
||||||
playEchoTone();
|
playEchoTone();
|
||||||
close(); // back to the list once echoed
|
close(); // back to the list once echoed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// close()'s behavior is unchanged (back to the list) - this only adds the
|
||||||
|
// engagement-stat increment and prompt dismissal alongside it
|
||||||
|
function handleLetGo() {
|
||||||
|
incrementLetGo();
|
||||||
|
showLastChancePrompt = false;
|
||||||
|
close();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -44,6 +115,7 @@
|
|||||||
<!-- detail view: shown for the selected message -->
|
<!-- detail view: shown for the selected message -->
|
||||||
<button class="back-btn" onclick={close}>← Back</button>
|
<button class="back-btn" onclick={close}>← Back</button>
|
||||||
|
|
||||||
|
<div class="detail-content" class:last-chance={isLastChance}>
|
||||||
{#if message.imageUrl}
|
{#if message.imageUrl}
|
||||||
<img class="message-img" src={message.imageUrl} alt="message attachment" />
|
<img class="message-img" src={message.imageUrl} alt="message attachment" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -51,22 +123,42 @@
|
|||||||
<p class="message-text">{message.text}</p>
|
<p class="message-text">{message.text}</p>
|
||||||
|
|
||||||
{#if decay}
|
{#if decay}
|
||||||
|
{#if isLastChance}
|
||||||
|
<!-- "last chance" framing replaces the normal countdown text -->
|
||||||
|
<p class="meta last-chance-text">this is the last chance to keep this alive</p>
|
||||||
|
{:else}
|
||||||
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
<p class="meta">left {decay.daysAgo} days ago. fading in {decay.daysLeft} days.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showLastChancePrompt}
|
||||||
|
<!-- one-time prompt (see stats.js) - gently frames Echo as the way
|
||||||
|
to give this message more time, without being pushy. Dismissible
|
||||||
|
on its own, or implicitly dismissed by pressing Echo/Let go below. -->
|
||||||
|
<div class="last-chance-prompt">
|
||||||
|
<p>if this one means something, an echo will keep it here a little longer.</p>
|
||||||
|
<button class="dismiss-prompt" onclick={() => showLastChancePrompt = false} aria-label="dismiss">×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="echo-button" onclick={handleEcho}>
|
<button class="echo-button" onclick={handleEcho}>
|
||||||
Echo
|
Echo
|
||||||
</button>
|
</button>
|
||||||
<button class="letgo-button" onclick={close}>
|
<button class="letgo-button" onclick={handleLetGo}>
|
||||||
Let go
|
Let go
|
||||||
</button>
|
</button>
|
||||||
|
<!-- share link + QR code popover (see SharePopover.svelte) -->
|
||||||
|
<SharePopover {message} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- list view: header, compose button, nearby messages -->
|
<!-- list view: header, compose button, nearby messages -->
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h1>Overheard: Shared Secrets</h1>
|
<h1>Overheard: Shared Secrets</h1>
|
||||||
<p class="subtitle">Messages near you</p>
|
<!-- ambient presence indicator replacing the old static "Messages near
|
||||||
|
you" text - styling/font-size unchanged, only the copy is dynamic -->
|
||||||
|
<p class="subtitle">{presenceText}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
@@ -115,6 +207,13 @@
|
|||||||
<p>Messages appear as you explore</p>
|
<p>Messages appear as you explore</p>
|
||||||
<p class="hint-sub">Tap the confetti on the map to read them</p>
|
<p class="hint-sub">Tap the confetti on the map to read them</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- replaces the old top-right floating archive button (desktop only -
|
||||||
|
mobile already has an Archive tab in the bottom nav). styled to
|
||||||
|
match .compose-btn so the two pill buttons feel like a pair -->
|
||||||
|
<a href="/archive" class="archive-btn">
|
||||||
|
Your archive
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -183,6 +282,58 @@
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "last chance" treatment - a soft warm peach tint + border, distinct from
|
||||||
|
the normal panel background but staying within the existing pastel/
|
||||||
|
parchment palette. A faint glow (box-shadow) adds a touch of warmth
|
||||||
|
without being a jarring "alert" color like red. */
|
||||||
|
.detail-content.last-chance {
|
||||||
|
background: #fff6ea;
|
||||||
|
border: 1px solid #f3dcb8;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
margin: -0.25rem -0.25rem 0;
|
||||||
|
box-shadow: 0 0 0 4px rgba(243, 184, 92, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* replaces the normal "fading in X days" meta line when isLastChance */
|
||||||
|
.last-chance-text {
|
||||||
|
color: #b08a5a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* one-time prompt shown above the action buttons - same warm palette as
|
||||||
|
.detail-content.last-chance, slightly more saturated so it reads as the
|
||||||
|
"active" element within the card */
|
||||||
|
.last-chance-prompt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #fdf0db;
|
||||||
|
border: 1px solid #f3dcb8;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9c7240;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-chance-prompt p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-prompt {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #c4a37a;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -248,6 +399,30 @@
|
|||||||
background: #b090e8;
|
background: #b090e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* same pill-button look as .compose-btn, but on an <a> tag, so it needs
|
||||||
|
its own block/text-align/text-decoration resets to read as a button
|
||||||
|
rather than an inline link */
|
||||||
|
.archive-btn {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #c4a8f5;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-btn:hover {
|
||||||
|
background: #b090e8;
|
||||||
|
}
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
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
|
// are shorter than this prefix and the >= / < range query in getNearbyMessages
|
||||||
// never matches anything (this is why pins disappeared)
|
// never matches anything (this is why pins disappeared)
|
||||||
export function encode(lat, lng) {
|
export function encode(lat, lng) {
|
||||||
return ngeohash.encode(lat, lng, 6);
|
return ngeohash.encode(lat, lng, 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodes a lat/lng pair to a 4 character string geohash (~40km radius) used as a Firestore query prefix
|
// encodes a lat/lng pair to a 4 character string geohash (~40km radius) used as a Firestore query prefix
|
||||||
@@ -16,5 +16,5 @@ export function encode(lat, lng) {
|
|||||||
// reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes
|
// reverted from 7 -> 4: a 7-char prefix can't match the 6-char geohashes
|
||||||
// stored on existing messages (see encode() above)
|
// stored on existing messages (see encode() above)
|
||||||
export function getQueryPrefix(lat, lng) {
|
export function getQueryPrefix(lat, lng) {
|
||||||
return ngeohash.encode(lat, lng, 4);
|
return ngeohash.encode(lat, lng, 6);
|
||||||
}
|
}
|
||||||
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.
|
// picks the fill color for a pin: if the message has a moodColor (an
|
||||||
// AdvancedMarkerElement (the marker API this app uses) takes a DOM node
|
// optional pastel color the author picked in ComposeSheet's swatch row,
|
||||||
// via its `content` option, not an icon/url like the older google.maps.Marker,
|
// stored as an hsl(...) string), use that directly so the pin matches what
|
||||||
// so we inline the SVG as a data URL on an <img> instead of returning an icon object.
|
// they chose. otherwise fall back to the existing random pastel exactly as
|
||||||
function svgToElement(svg, size) {
|
// before. `glow` isn't currently rendered by any pin shape below, but is
|
||||||
const img = document.createElement('img');
|
// still returned for shape consistency with randomPastel().
|
||||||
img.src = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg);
|
function pinColor(moodColor) {
|
||||||
img.style.width = `${size}px`;
|
if (moodColor) {
|
||||||
img.style.height = `${size}px`;
|
return { solid: moodColor, glow: moodColor };
|
||||||
// AdvancedMarkerElement anchors content by its bottom-center by default
|
}
|
||||||
// (made for teardrop pins). Shift up by half the height so the *center*
|
return randomPastel();
|
||||||
// of our shapes lands on the actual coordinate instead.
|
|
||||||
img.style.transform = 'translateY(-50%)';
|
|
||||||
// flat 2D style for now (no drop-shadow) - paper-style shadows may be added later
|
|
||||||
return img;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// star pin for unread messages: solid pastel star, no glow halo, flat fill
|
// turns an SVG string into a { url, size } pair for google.maps.Marker's
|
||||||
export function unreadPin() {
|
// `icon` option. Markers (the legacy marker API this app now uses, switched
|
||||||
const { solid } = randomPastel();
|
// back from AdvancedMarkerElement so a JS map `styles` array can take
|
||||||
const svg = `
|
// effect - AdvancedMarkerElement requires a mapId, which causes Google to
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60">
|
// ignore any `styles` array) take an Icon object - { url, scaledSize, anchor }
|
||||||
<polygon points="30,12 34,24 47,24 37,32 41,44 30,36 19,44 23,32 13,24 26,24"
|
// - rather than a DOM node, so we just return the data-URL + size here and
|
||||||
fill="${solid}"/>
|
// let MapView build the google.maps.Size/Point objects (those classes only
|
||||||
</svg>`;
|
// exist once the Maps JS API has loaded).
|
||||||
// sized to match locationPin (32px) per latest request, so message pins
|
// flat 2D style for now (no drop-shadow) - paper-style shadows may be added later
|
||||||
// are no longer larger than the current-location marker
|
function svgToIcon(svg, size) {
|
||||||
return svgToElement(svg, 46);
|
return {
|
||||||
|
url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg),
|
||||||
|
size
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// circle pin for read messages: solid pastel perfect circle, no glow halo
|
// pin for every message: solid pastel perfect circle, no glow halo.
|
||||||
export function readPin() {
|
// previously there were 3 shapes (star/circle/heart) for unread/read/echoed
|
||||||
const { solid } = randomPastel();
|
// messages, swapped on click - now every message pin looks the same, so
|
||||||
|
// clicking a pin no longer needs to change its appearance.
|
||||||
|
// moodColor (optional): the author's picked swatch color, if any - see pinColor() above
|
||||||
|
export function messagePin(moodColor) {
|
||||||
|
const { solid } = pinColor(moodColor);
|
||||||
const svg = `
|
const svg = `
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50">
|
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50">
|
||||||
<circle cx="25" cy="25" r="20" fill="${solid}"/>
|
<circle cx="25" cy="25" r="20" fill="${solid}"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
// sized to match locationPin (32px) per latest request, so message pins
|
// sized to match locationPin (32px) per latest request, so message pins
|
||||||
// are no longer larger than the current-location marker
|
// are no longer larger than the current-location marker
|
||||||
return svgToElement(svg, 28);
|
return svgToIcon(svg, 28);
|
||||||
}
|
|
||||||
|
|
||||||
// heart pin for echoed messages: solid pastel heart, no glow halo
|
|
||||||
export function echoedPin() {
|
|
||||||
const { solid } = randomPastel();
|
|
||||||
// replaced the old single-point teardrop path with an actual two-lobed
|
|
||||||
// heart: a notch/cleft at the top center (28,17) and a point at the
|
|
||||||
// bottom (28,46)
|
|
||||||
// dropped the white inner-highlight overlay - it gave the heart a
|
|
||||||
// ring/outline look instead of a solid fill, which the user didn't want
|
|
||||||
const svg = `
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 56 56">
|
|
||||||
<path d="M28,46 C28,46 10,33 10,20 C10,13 15,9 21,9 C25,9 28,12 28,17 C28,12 31,9 35,9 C41,9 46,13 46,20 C46,33 28,46 28,46 Z"
|
|
||||||
fill="${solid}"/>
|
|
||||||
</svg>`;
|
|
||||||
// sized to match locationPin (32px) per latest request, so message pins
|
|
||||||
// are no longer larger than the current-location marker
|
|
||||||
return svgToElement(svg, 32);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fixed dark dot used for the user's own location (not randomized, no glow)
|
// fixed dark dot used for the user's own location (not randomized, no glow)
|
||||||
@@ -75,5 +60,5 @@ export function locationPin() {
|
|||||||
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
|
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
|
||||||
<circle cx="16" cy="16" r="5" fill="white"/>
|
<circle cx="16" cy="16" r="5" fill="white"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
return svgToElement(svg, 32);
|
return svgToIcon(svg, 32);
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/lib/utils/presence.js
Normal file
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>
|
<script>
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { page } from '$app/stores'; // current route, used to hide the count pill on /archive
|
||||||
|
import GlobalCountPill from '$lib/components/GlobalCountPill.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -77,6 +78,14 @@
|
|||||||
block or delay anything underneath. -->
|
block or delay anything underneath. -->
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
|
<!-- global "memory counter" pill, top-right on every page EXCEPT /archive -
|
||||||
|
the archive is the user's own private list of messages, so the global
|
||||||
|
"everyone, everywhere" count would feel out of place there (see
|
||||||
|
GlobalCountPill.svelte for the pill itself). -->
|
||||||
|
{#if $page.url.pathname !== '/archive'}
|
||||||
|
<GlobalCountPill />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- one-time opening ritual overlay, first-ever visit only -->
|
<!-- one-time opening ritual overlay, first-ever visit only -->
|
||||||
{#if ritualMounted}
|
{#if ritualMounted}
|
||||||
<div class="ritual-overlay" class:visible={ritualVisible} aria-hidden="true">
|
<div class="ritual-overlay" class:visible={ritualVisible} aria-hidden="true">
|
||||||
@@ -84,15 +93,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- floating link to the archive page, hidden while already on it -->
|
<!-- ===================================================================
|
||||||
{#if $page.url.pathname !== '/archive'}
|
GLOBAL TEXTURE OVERLAY (start)
|
||||||
<a href="/archive" class="archive-link">📁</a>
|
A subtle parchment/paper grain over the ENTIRE app (was previously
|
||||||
{/if}
|
just over the map - moved here per request). pointer-events: none
|
||||||
|
so it never blocks clicks/taps anywhere in the UI.
|
||||||
|
TO REMOVE: delete this <div> and the ".global-texture-overlay" CSS
|
||||||
|
block below - nothing else depends on either.
|
||||||
|
=================================================================== -->
|
||||||
|
<div class="global-texture-overlay" aria-hidden="true"></div>
|
||||||
|
<!-- GLOBAL TEXTURE OVERLAY (end) -->
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* full-screen opening ritual overlay - soft cream background matching
|
/* full-screen opening ritual overlay - soft cream background matching
|
||||||
the app's pastel palette, sits above everything (including the
|
the app's pastel palette, sits above everything while it's visible */
|
||||||
.archive-link below at z-index 300) while it's visible */
|
|
||||||
.ritual-overlay {
|
.ritual-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -131,20 +145,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-link {
|
/* =================================================================
|
||||||
|
GLOBAL TEXTURE OVERLAY (start) - see matching comment in the
|
||||||
|
markup above for removal instructions.
|
||||||
|
- position: fixed + inset: 0 covers the full viewport, regardless
|
||||||
|
of scroll position, on every page.
|
||||||
|
- z-index: 2000 sits above the opening ritual (1000) and every
|
||||||
|
other UI element (all of which use z-index 200 or less) - this
|
||||||
|
is intentional, since the texture is purely decorative and
|
||||||
|
pointer-events: none means it never intercepts input even when
|
||||||
|
it's the topmost element.
|
||||||
|
- mix-blend-mode: multiply + opacity: 0.08 + the warm #ecdfc4
|
||||||
|
background-color gently "ages" whatever's underneath (map,
|
||||||
|
white panels, sheets) without hiding any content. opacity
|
||||||
|
back down to 0.08 - the stronger feColorMatrix/tile-size
|
||||||
|
values below (tuned while testing at 0.10) keep the grain
|
||||||
|
from disappearing entirely at this lower opacity.
|
||||||
|
- background-image is the same inline SVG <feTurbulence> +
|
||||||
|
<feColorMatrix> grain used previously on just the map.
|
||||||
|
feColorMatrix scale 3.5 / offset -1.6 and background-size
|
||||||
|
110px give the grain a faint but present texture even at
|
||||||
|
0.08 opacity. */
|
||||||
|
.global-texture-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1rem;
|
inset: 0;
|
||||||
right: 1rem;
|
z-index: 2000;
|
||||||
background: white;
|
pointer-events: none;
|
||||||
border-radius: 50%;
|
opacity: 0.08;
|
||||||
width: 40px;
|
mix-blend-mode: multiply;
|
||||||
height: 40px;
|
background-color: #ecdfc4;
|
||||||
display: flex;
|
background-image:
|
||||||
align-items: center;
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch' result='noise'/%3E%3CfeColorMatrix in='noise' type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3.5 -1.6'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
justify-content: center;
|
background-size: 110px 110px;
|
||||||
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
background-repeat: repeat;
|
||||||
text-decoration: none;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
z-index: 300;
|
|
||||||
}
|
}
|
||||||
|
/* GLOBAL TEXTURE OVERLAY (end) */
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import MapView from '$lib/components/MapView.svelte';
|
import MapView from '$lib/components/MapView.svelte';
|
||||||
|
import SensingToggle from '$lib/components/SensingToggle.svelte';
|
||||||
|
|
||||||
import { getNearbyMessages } from '$lib/firebase/messages.js';
|
import { getNearbyMessages, hasAnyMessagesEverNearby, getMessageById } from '$lib/firebase/messages.js';
|
||||||
|
import { getDecayInfo } from '$lib/utils/time.js'; // checks whether a shared message has expired
|
||||||
import { messagesStore, setMessages } from '$lib/stores/messagesStore.js';
|
import { messagesStore, setMessages } from '$lib/stores/messagesStore.js';
|
||||||
import { mapStore } from '$lib/stores/mapStore.js';
|
import { mapStore } from '$lib/stores/mapStore.js';
|
||||||
import { initAuth } from '$lib/stores/userStore.js';
|
import { initAuth } from '$lib/stores/userStore.js';
|
||||||
import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab
|
import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab
|
||||||
|
import { replaceState } from '$app/navigation'; // clears the ?message= param after handling a shared link
|
||||||
|
|
||||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||||
import SidePanel from '$lib/components/SidePanel.svelte';
|
import SidePanel from '$lib/components/SidePanel.svelte';
|
||||||
@@ -21,10 +24,59 @@
|
|||||||
|
|
||||||
let isMobile = $derived(windowWidth < 768);
|
let isMobile = $derived(windowWidth < 768);
|
||||||
|
|
||||||
|
// --- "you're the first here" ambient message --------------------------
|
||||||
|
// `firstHereMounted` controls whether the message exists in the DOM at
|
||||||
|
// all - it's set true at most once, right after the initial
|
||||||
|
// getNearbyMessages() call resolves (see onMount below), and never
|
||||||
|
// re-checked again for the rest of this page load ("once per page load").
|
||||||
|
// `firstHereVisible` drives the CSS opacity fade transition, the same
|
||||||
|
// mount/visible split used by the opening ritual overlay in +layout.svelte.
|
||||||
|
let firstHereMounted = $state(false);
|
||||||
|
let firstHereVisible = $state(false);
|
||||||
|
|
||||||
|
// --- "sensing mode" (compass arrow + proximity vibration) ---------------
|
||||||
|
// Single combined toggle for both device-sensor features (see
|
||||||
|
// SensingToggle + MapView.svelte). Defaults to OFF: these features use
|
||||||
|
// device sensors (orientation, vibration, continuous location) more
|
||||||
|
// actively than the rest of the app, so they're an opt-in "mode" rather
|
||||||
|
// than always-on.
|
||||||
|
let sensingMode = $state(false);
|
||||||
|
|
||||||
|
// Turning the toggle ON is itself a user-interaction "click", which is
|
||||||
|
// exactly the context iOS Safari requires for
|
||||||
|
// DeviceOrientationEvent.requestPermission() - it must be called directly
|
||||||
|
// from a user gesture, so it has to happen here (in the toggle's click
|
||||||
|
// handler) rather than later inside MapView's effect. If the API doesn't
|
||||||
|
// exist at all (non-iOS), there's nothing to request - sensingMode just
|
||||||
|
// flips on and MapView's deviceorientation listener works without a
|
||||||
|
// permission prompt. If the user denies the prompt, sensingMode still
|
||||||
|
// turns on (proximity vibration doesn't need orientation), but no
|
||||||
|
// deviceorientation events will arrive, so the compass arrow simply never
|
||||||
|
// appears (arrowRotation stays null) - handled gracefully, no error shown.
|
||||||
|
async function handleSensingToggle(newValue) {
|
||||||
|
if (newValue && typeof DeviceOrientationEvent !== 'undefined'
|
||||||
|
&& typeof DeviceOrientationEvent.requestPermission === 'function') {
|
||||||
|
try {
|
||||||
|
await DeviceOrientationEvent.requestPermission();
|
||||||
|
} catch {
|
||||||
|
// ignore - arrow just won't appear without orientation data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sensingMode = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// sign this device in anonymously so messages can be linked to a persistent UID
|
// sign this device in anonymously so messages can be linked to a persistent UID
|
||||||
initAuth();
|
initAuth();
|
||||||
|
|
||||||
|
// The map fills 100vh and all its controls (FAB, bottom nav, sheets)
|
||||||
|
// are position:fixed, so this page itself never needs to scroll.
|
||||||
|
// This used to be an `overflow: hidden` rule on :global(body) in the
|
||||||
|
// stylesheet below, but that leaked across client-side navigation and
|
||||||
|
// broke scrolling on /archive too - applying/removing it here scopes
|
||||||
|
// it to exactly this page's lifetime.
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
error = "Your browser doesn't support geolocation :(";
|
error = "Your browser doesn't support geolocation :(";
|
||||||
return; // do nothing
|
return; // do nothing
|
||||||
@@ -46,15 +98,166 @@
|
|||||||
// previous list and plays a soft chime for any newly-appeared pins
|
// previous list and plays a soft chime for any newly-appeared pins
|
||||||
setMessages(messages);
|
setMessages(messages);
|
||||||
console.log('messages loaded:', $messagesStore);
|
console.log('messages loaded:', $messagesStore);
|
||||||
|
|
||||||
|
// --- "you're the first here" zero-results check --------
|
||||||
|
// getNearbyMessages() already filters out anything past its
|
||||||
|
// 30-day decay, so messages.length === 0 alone just means
|
||||||
|
// "nothing ACTIVE nearby right now" - it doesn't distinguish
|
||||||
|
// "no one has ever posted here" from "people posted here
|
||||||
|
// before, but it's all since faded". To make that
|
||||||
|
// distinction (per the feature spec, "truly never posted"
|
||||||
|
// interpretation), we only fall back to
|
||||||
|
// hasAnyMessagesEverNearby() - a cheap count-only query over
|
||||||
|
// the same geohash area, including expired messages - when
|
||||||
|
// the active list comes back empty. The ambient message only
|
||||||
|
// appears if THAT also comes back empty, i.e. literally
|
||||||
|
// nothing has ever been left in this ~1.2km area.
|
||||||
|
if (messages.length === 0) {
|
||||||
|
const everPosted = await hasAnyMessagesEverNearby(position.coords.latitude, position.coords.longitude);
|
||||||
|
if (!everPosted) {
|
||||||
|
firstHereMounted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// cleanup: undo the body overflow lock when this page unmounts
|
||||||
|
// (e.g. navigating to /archive), so other routes can scroll normally.
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fade-in + auto-dismiss timing for the "first here" message - mirrors
|
||||||
|
// the opening ritual overlay's pattern in +layout.svelte: wait a frame so
|
||||||
|
// the element first paints at opacity 0, then fade in; after a short
|
||||||
|
// hold, fade out; then unmount entirely so it can't linger over the map.
|
||||||
|
$effect(() => {
|
||||||
|
if (!firstHereMounted) return;
|
||||||
|
|
||||||
|
const fadeInFrame = requestAnimationFrame(() => {
|
||||||
|
firstHereVisible = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fadeInMs = 1200;
|
||||||
|
const holdMs = 4000; // "a few seconds" before it fades on its own
|
||||||
|
const fadeOutMs = 1200;
|
||||||
|
|
||||||
|
const fadeOutTimer = setTimeout(() => {
|
||||||
|
firstHereVisible = false;
|
||||||
|
}, fadeInMs + holdMs);
|
||||||
|
|
||||||
|
const removeTimer = setTimeout(() => {
|
||||||
|
firstHereMounted = false;
|
||||||
|
}, fadeInMs + holdMs + fadeOutMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(fadeInFrame);
|
||||||
|
clearTimeout(fadeOutTimer);
|
||||||
|
clearTimeout(removeTimer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Early dismissal: as soon as the user starts interacting (opening the
|
||||||
|
// compose sheet), fade the message out immediately rather than waiting
|
||||||
|
// for the timers above - "disappear once the user starts interacting".
|
||||||
|
$effect(() => {
|
||||||
|
if (firstHereMounted && $mapStore.composing) {
|
||||||
|
firstHereVisible = false;
|
||||||
|
setTimeout(() => { firstHereMounted = false; }, 1200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- auto-open a message shared via link/QR code (SharePopover.svelte) --
|
||||||
|
// `sharedLinkHandled` makes this run at most once per page load: the
|
||||||
|
// first time lat/lng are set (map has loaded) AND the URL has a
|
||||||
|
// `?message=<id>` param, it's flipped to true immediately, so later
|
||||||
|
// re-runs of this effect (e.g. lat/lng updating again) are no-ops.
|
||||||
|
let sharedLinkHandled = $state(false);
|
||||||
|
|
||||||
|
// "this one has already faded" ambient notice - same mount/visible split
|
||||||
|
// and fade timing as the "first here" message above, shown when a shared
|
||||||
|
// link points at a message that no longer exists or has expired.
|
||||||
|
let fadedNoticeMounted = $state(false);
|
||||||
|
let fadedNoticeVisible = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (sharedLinkHandled) return;
|
||||||
|
if (!(lat && lng)) return; // wait until the map has a location to center on
|
||||||
|
|
||||||
|
const sharedId = $page.url.searchParams.get('message');
|
||||||
|
sharedLinkHandled = true; // one-time entry behavior, regardless of outcome
|
||||||
|
|
||||||
|
if (!sharedId) return; // normal load, nothing to auto-open
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Direct Firestore lookup by document ID - bypasses
|
||||||
|
// getNearbyMessages's geohash filter entirely, since a shared
|
||||||
|
// message could have been left anywhere in the world, not just
|
||||||
|
// this device's ~1.2km area.
|
||||||
|
const shared = await getMessageById(sharedId);
|
||||||
|
const decay = shared ? getDecayInfo(shared.createdAt, shared.lastEchoAt) : null;
|
||||||
|
|
||||||
|
if (shared && decay && !decay.isExpired) {
|
||||||
|
mapStore.set({ selectedMessage: shared, composing: false });
|
||||||
|
} else {
|
||||||
|
// doesn't exist, or past its 30-day decay - graceful
|
||||||
|
// ambient notice instead of an error
|
||||||
|
fadedNoticeMounted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the `message` param so this is a one-time entry
|
||||||
|
// behavior, not persistent state - reloading or sharing the
|
||||||
|
// current URL afterward won't re-trigger the auto-open.
|
||||||
|
// replaceState (rather than goto) swaps the URL without a
|
||||||
|
// navigation/history entry.
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('message');
|
||||||
|
replaceState(url, {});
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
// fade-in + auto-dismiss timing for the "already faded" notice - mirrors
|
||||||
|
// the "first here" message's $effect above
|
||||||
|
$effect(() => {
|
||||||
|
if (!fadedNoticeMounted) return;
|
||||||
|
|
||||||
|
const fadeInFrame = requestAnimationFrame(() => {
|
||||||
|
fadedNoticeVisible = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fadeInMs = 1200;
|
||||||
|
const holdMs = 4000;
|
||||||
|
const fadeOutMs = 1200;
|
||||||
|
|
||||||
|
const fadeOutTimer = setTimeout(() => {
|
||||||
|
fadedNoticeVisible = false;
|
||||||
|
}, fadeInMs + holdMs);
|
||||||
|
|
||||||
|
const removeTimer = setTimeout(() => {
|
||||||
|
fadedNoticeMounted = false;
|
||||||
|
}, fadeInMs + holdMs + fadeOutMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(fadeInFrame);
|
||||||
|
clearTimeout(fadeOutTimer);
|
||||||
|
clearTimeout(removeTimer);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window bind:innerWidth={windowWidth} /> <!--this sends the windowWidth to our mobile checker -->
|
<svelte:window bind:innerWidth={windowWidth} /> <!--this sends the windowWidth to our mobile checker -->
|
||||||
|
|
||||||
|
<!-- "this one has already faded" notice for a shared link/QR code pointing
|
||||||
|
at a message that no longer exists or has expired (see the
|
||||||
|
sharedLinkHandled $effect above) - ambient and auto-dismissing, same
|
||||||
|
fade pattern as the "first here" bubble, rather than an error page -->
|
||||||
|
{#if fadedNoticeMounted}
|
||||||
|
<p class="faded-notice" class:visible={fadedNoticeVisible} aria-hidden="true">
|
||||||
|
this one has already faded.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error">{error}</p>
|
<p class="error">{error}</p>
|
||||||
{:else if !(lat && lng)}
|
{:else if !(lat && lng)}
|
||||||
@@ -67,10 +270,27 @@
|
|||||||
on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav -->
|
on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav -->
|
||||||
{#if lat && lng}
|
{#if lat && lng}
|
||||||
<div class="map-container" style="padding-bottom: {windowWidth < 768 ? '64px' : '0'}">
|
<div class="map-container" style="padding-bottom: {windowWidth < 768 ? '64px' : '0'}">
|
||||||
<MapView {lat} {lng} />
|
<!-- firstHereMounted/firstHereVisible (state + fade timing computed
|
||||||
|
in onMount/$effects above) are passed down so MapView can render
|
||||||
|
the "you're the first here" bubble positioned near the user's
|
||||||
|
location marker, rather than floating independently of the map.
|
||||||
|
sensingMode (toggled by SensingToggle below) drives MapView's
|
||||||
|
compass arrow + proximity vibration. -->
|
||||||
|
<MapView {lat} {lng} {firstHereMounted} {firstHereVisible} {sensingMode} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- "sensing mode" toggle: mobile-only (physical-world features don't make
|
||||||
|
sense on desktop), placed directly above the FAB so the two read as a
|
||||||
|
stack of floating controls in the bottom-right corner (positioning is
|
||||||
|
handled inside SensingToggle.svelte itself, like .trail-toggle in
|
||||||
|
MapView.svelte). Hidden whenever a message is open, same as the FAB
|
||||||
|
below - it sits right above the FAB, so it overlapped the open message
|
||||||
|
view (BottomSheet/SidePanel) the same way the FAB would have. -->
|
||||||
|
{#if isMobile && !$mapStore.selectedMessage}
|
||||||
|
<SensingToggle checked={sensingMode} onchange={handleSensingToggle} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- show the right panel based on mobile or desktop-->
|
<!-- show the right panel based on mobile or desktop-->
|
||||||
{#if windowWidth < 768}
|
{#if windowWidth < 768}
|
||||||
<BottomSheet message={$mapStore.selectedMessage} />
|
<BottomSheet message={$mapStore.selectedMessage} />
|
||||||
@@ -80,57 +300,6 @@
|
|||||||
<SidePanel message={$mapStore.selectedMessage} />
|
<SidePanel message={$mapStore.selectedMessage} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- pin legend, desktop only.
|
|
||||||
icons are inline copies of the exact shapes/viewBoxes from pins.js
|
|
||||||
(unreadPin/readPin/echoedPin/locationPin) instead of unrelated emoji,
|
|
||||||
so the key visually matches what's on the map. message-pin icons use
|
|
||||||
one fixed pastel fill here (rather than pins.js's randomPastel()) since
|
|
||||||
a legend needs a single representative swatch, not a random one. -->
|
|
||||||
{#if !isMobile}
|
|
||||||
<div class="legend">
|
|
||||||
<!-- each icon sits in a fixed 36x36 .legend-icon-wrap so the text
|
|
||||||
column stays aligned, even though the svgs below are sized
|
|
||||||
differently from each other. the star/heart shapes occupy a
|
|
||||||
smaller fraction of their own viewBox than the two circles do
|
|
||||||
of theirs, so they need a bigger svg size to LOOK the same size
|
|
||||||
on screen - sizes below were picked by eye, not by matching
|
|
||||||
pixel dimensions -->
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon-wrap">
|
|
||||||
<svg class="legend-icon" viewBox="0 0 32 32" width="23" height="23">
|
|
||||||
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
|
|
||||||
<circle cx="16" cy="16" r="5" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span>You are here</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon-wrap">
|
|
||||||
<svg class="legend-icon" viewBox="0 0 60 60" width="36" height="36">
|
|
||||||
<polygon points="30,12 34,24 47,24 37,32 41,44 30,36 19,44 23,32 13,24 26,24" fill="hsl(265, 60%, 80%)"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span>Unread</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon-wrap">
|
|
||||||
<svg class="legend-icon" viewBox="0 0 50 50" width="25" height="25">
|
|
||||||
<circle cx="25" cy="25" r="20" fill="hsl(265, 60%, 80%)"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span>Read</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon-wrap">
|
|
||||||
<svg class="legend-icon" viewBox="0 0 56 56" width="30" height="30">
|
|
||||||
<path d="M28,46 C28,46 10,33 10,20 C10,13 15,9 21,9 C25,9 28,12 28,17 C28,12 31,9 35,9 C41,9 46,13 46,20 C46,33 28,46 28,46 Z" fill="hsl(265, 60%, 80%)"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span>Echoed</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!--floating action button for adding a message; hidden while a message is open-->
|
<!--floating action button for adding a message; hidden while a message is open-->
|
||||||
{#if !$mapStore.composing && !$mapStore.selectedMessage}
|
{#if !$mapStore.composing && !$mapStore.selectedMessage}
|
||||||
<button
|
<button
|
||||||
@@ -177,7 +346,8 @@
|
|||||||
:global(body) {
|
:global(body) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
/* overflow: hidden moved into onMount above, scoped to this page's
|
||||||
|
lifetime - removed here so it doesn't leak into /archive. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.error, .loading {
|
.error, .loading {
|
||||||
@@ -190,6 +360,31 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "this one has already faded" notice - small pastel pill, top-center,
|
||||||
|
fades in/out via .visible (same opacity transition as .ritual-overlay in
|
||||||
|
+layout.svelte and .first-here in MapView.svelte) */
|
||||||
|
.faded-notice {
|
||||||
|
position: fixed;
|
||||||
|
top: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 250;
|
||||||
|
margin: 0;
|
||||||
|
background: #fdfaf5;
|
||||||
|
color: #999;
|
||||||
|
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faded-notice.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* purple FAB; only rendered when nothing is composing/selected, so no
|
/* purple FAB; only rendered when nothing is composing/selected, so no
|
||||||
"shifted" state is needed anymore (that rule has been removed) */
|
"shifted" state is needed anymore (that rule has been removed) */
|
||||||
.fab {
|
.fab {
|
||||||
@@ -212,6 +407,15 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* desktop has no bottom nav bar, so the 5rem bottom offset above (sized to
|
||||||
|
clear the mobile nav) leaves the FAB looking awkwardly high - bring it
|
||||||
|
down closer to the corner. mobile keeps the 5rem value from .fab above. */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.fab {
|
||||||
|
bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* mobile-only bottom nav bar with Map/Archive links */
|
/* mobile-only bottom nav bar with Map/Archive links */
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -261,46 +465,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* floating legend explaining the map pin shapes/colors, desktop only.
|
|
||||||
anchored bottom-left, just to the right of the 340px side panel.
|
|
||||||
flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
|
||||||
.legend {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 1.5rem;
|
|
||||||
left: calc(340px + 4rem);
|
|
||||||
background: #f3f0fa;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 0.9rem 1.1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
z-index: 200;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #444;
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* fixed 36x36 box for every icon, centered, so the text after each icon
|
|
||||||
lines up in a consistent column regardless of each icon's own width/height
|
|
||||||
(which now vary so the shapes LOOK the same size - see template) */
|
|
||||||
.legend-icon-wrap {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* flat 2D style for now - drop-shadow filter removed, paper-style shadows may be added later */
|
|
||||||
.legend-icon {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -3,13 +3,19 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getMyMessages } from '$lib/firebase/messages.js';
|
import { getMyMessages } from '$lib/firebase/messages.js';
|
||||||
import { getDecayInfo } from '$lib/utils/time.js';
|
import { getDecayInfo } from '$lib/utils/time.js';
|
||||||
|
import { getStats } from '$lib/utils/stats.js';
|
||||||
|
|
||||||
let messages = $state([]);
|
let messages = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
|
// personal engagement stats (read/echoed/letGo) - purely local to this
|
||||||
|
// device (see stats.js), read once when the page mounts
|
||||||
|
let stats = $state({ read: 0, echoed: 0, letGo: 0 });
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
messages = await getMyMessages();
|
messages = await getMyMessages();
|
||||||
loading = false;
|
loading = false;
|
||||||
|
stats = getStats();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -20,6 +26,12 @@
|
|||||||
<p class="subtitle">Everything you've left behind</p>
|
<p class="subtitle">Everything you've left behind</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- personal engagement stats summary - a private mirror of how this
|
||||||
|
device has moved through the app, see stats.js -->
|
||||||
|
<p class="stats-line">
|
||||||
|
you've read {stats.read} messages, echoed {stats.echoed}, let {stats.letGo} fade.
|
||||||
|
</p>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading your messages...</div>
|
<div class="loading">Loading your messages...</div>
|
||||||
|
|
||||||
@@ -96,6 +108,16 @@
|
|||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* personal engagement stats - calm/muted like .subtitle, with a slight
|
||||||
|
contemplative italic to set it apart as a private aside rather than
|
||||||
|
part of the page's structural header */
|
||||||
|
.stats-line {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
margin: -0.5rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
|
|||||||
Reference in New Issue
Block a user