Compare commits

..
40 Commits
Author SHA1 Message Date
raucao c9a6304926 1.27.1
CI / Lint (push) Successful in 1m0s
CI / Test (push) Successful in 1m13s
2026-08-19 12:44:47 -06:00
raucao 6fb4ecede3 Merge pull request 'Fix WhatsApp links/numbers' (#79) from bugfix/whatsapp_links into master
CI / Lint (push) Successful in 59s
CI / Test (push) Successful in 1m13s
Reviewed-on: #79
2026-08-19 18:42:26 +00:00
raucao 77db625b11 Fix WhatsApp links/numbers
CI / Lint (pull_request) Successful in 1m3s
CI / Test (pull_request) Successful in 1m14s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
Our links would work after opening WhatsApp, but not render e.g. known
business names associated with the number on the intermediate page that
you open the app or web app from
2026-08-19 12:39:50 -06:00
raucao 6351cfbd07 Merge pull request 'Connect Nostr account from My Contributions' (#78) from feature/connect_nostr_from_contributions_timeline into master
CI / Lint (push) Successful in 1m2s
CI / Test (push) Successful in 1m12s
Reviewed-on: #78
2026-08-19 18:36:11 +00:00
raucao b7e46da3e9 Add test for new Nostr connect location
CI / Lint (pull_request) Successful in 1m2s
CI / Test (pull_request) Successful in 1m14s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-08-19 12:32:21 -06:00
raucao 3233150cc4 Connect Nostr account from My Contributions
And upon connection, immediately fetch contributions and render them
2026-08-19 12:24:50 -06:00
raucao 822472a0e6 Merge pull request 'App content/copy/links' (#77) from ui/copy into master
CI / Lint (push) Successful in 1m1s
CI / Test (push) Successful in 1m13s
Reviewed-on: #77
2026-08-19 18:03:58 +00:00
raucao 7ede81fe5f Add hint to empty contributions list
CI / Lint (pull_request) Successful in 1m2s
CI / Test (pull_request) Successful in 1m14s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-08-19 12:02:02 -06:00
raucao 3d2b723e05 Mention Nostr in the app description 2026-08-19 11:50:22 -06:00
raucao 10ecb745ee Suggest adding photos to contribute 2026-08-19 11:42:01 -06:00
raucao 59243d7703 Add link/license for map tiles 2026-08-19 11:41:42 -06:00
raucao 5bdb90c6f0 Merge pull request 'Migrate OSM cache to localForage/IndexedDB' (#76) from feature/indexeddb_cache into master
CI / Lint (push) Successful in 1m2s
CI / Test (push) Successful in 1m16s
Reviewed-on: #76
2026-08-19 17:15:58 +00:00
raucao 55a4a7a2da Upgrade pnpm in CI
CI / Lint (pull_request) Successful in 1m4s
CI / Test (pull_request) Successful in 1m17s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-08-19 11:14:11 -06:00
raucao 1de276fcc8 Fix OSM data refreshes not immediately rendering
CI / Test (pull_request) Failing after 8s
CI / Lint (pull_request) Failing after 12s
2026-08-19 11:01:42 -06:00
raucao 61242420d1 Migrate OSM cache to localForage/IndexedDB
We don't want to hit limits with localStorage, and it's non-blocking
2026-08-19 10:39:58 -06:00
raucao 45f6f898fa Merge pull request 'Fix perpetual "loading" titles when first opening the contributions list' (#75) from bugfix/contribution-loading into master
CI / Lint (push) Successful in 53s
CI / Test (push) Successful in 1m6s
Reviewed-on: #75
2026-08-19 15:29:11 +00:00
raucao 21d261ef17 Fix perpetual "loading" titles when first opening the contributions list
CI / Lint (pull_request) Successful in 54s
CI / Test (pull_request) Successful in 1m8s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-08-19 09:22:46 -06:00
raucao 3d578177ab 1.27.0
CI / Lint (push) Successful in 53s
CI / Test (push) Successful in 1m4s
2026-08-18 17:04:15 -06:00
raucao df9831b7c3 Merge pull request 'Add timeline/history for signed-in user's contributions' (#74) from feature/contributions_timeline into master
CI / Lint (push) Successful in 54s
CI / Test (push) Successful in 1m6s
Reviewed-on: #74
2026-08-18 23:01:26 +00:00
raucao 78687b63ff Move formatRelativeDate() to formatText util, add util tests
CI / Lint (pull_request) Successful in 54s
CI / Test (pull_request) Successful in 1m7s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-08-18 16:57:11 -06:00
raucao d6e47a1b27 Zoom out to fit ways/relations in the view when necessary 2026-08-18 16:51:25 -06:00
raucao 42cd620c48 Remove comment
CI / Lint (pull_request) Successful in 56s
CI / Test (pull_request) Successful in 1m8s
2026-08-18 16:45:40 -06:00
raucao b9e5213c27 Add timeline/history for signed-in user's contributions
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 1m6s
2026-08-18 16:21:31 -06:00
raucao d6fddddd4e Update pnpm workspace
CI / Lint (push) Successful in 55s
CI / Test (push) Successful in 1m4s
2026-08-18 11:43:16 -06:00
raucao 335ff1a9d8 1.26.2
CI / Lint (push) Successful in 52s
CI / Test (push) Successful in 1m2s
2026-08-12 13:26:37 -06:00
raucao 9dc080b86e Update dependencies
CI / Lint (push) Successful in 53s
CI / Test (push) Canceled after 1m3s
2026-08-12 13:24:12 -06:00
raucao a457e1521b 1.26.1
CI / Lint (push) Successful in 55s
CI / Test (push) Successful in 1m5s
2026-08-07 12:12:54 -06:00
raucao 39ab5e2cd1 Merge pull request 'Use our own relay as default Nostr Connect relay' (#72) from bugfix/default_signing_relay into master
CI / Lint (push) Successful in 53s
CI / Test (push) Successful in 1m4s
Reviewed-on: #72
2026-08-07 18:07:14 +00:00
raucao ce53d42581 Use our own relay as default Nostr Connect relay
CI / Lint (pull_request) Successful in 55s
CI / Test (pull_request) Successful in 1m5s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
Fixes the current default connect relay being offline (and having been
down for weeks now)
2026-08-07 12:04:57 -06:00
raucao 387fd6efed 1.26.0
CI / Lint (push) Successful in 53s
CI / Test (push) Successful in 1m4s
2026-08-07 07:33:52 -06:00
raucao aaf0d8c3e9 Merge pull request 'Add alt text to place photos' (#71) from bugix/photo_alt_text into master
CI / Lint (push) Successful in 53s
CI / Test (push) Successful in 1m2s
Reviewed-on: #71
2026-08-07 13:30:39 +00:00
raucao 9807a2b822 Add alt text to photo uploads
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 1m6s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
- Form field for optionally adding a description
- Publish as `imeta` `alt`
- Render as `<img>` alt when set

Fixes a placeholder being published instead. Also strips the current
placeholder if set as alt text (for existing uploads).
2026-08-07 07:25:53 -06:00
raucao ac9870240f Merge pull request 'Update dependencies' (#70) from chore/update_dependencies into master
CI / Lint (push) Successful in 1m7s
CI / Test (push) Successful in 59s
Reviewed-on: #70
2026-08-06 22:00:01 +00:00
raucao 1b185d1033 Upgrade lint and dev tooling to latest majors
CI / Test (pull_request) Successful in 1m4s
CI / Lint (pull_request) Successful in 1m12s
Release Drafter / Update release notes draft (pull_request) Successful in 21s
Bump eslint to ^10, @eslint/js to ^10, eslint-plugin-ember to ^13,
eslint-plugin-n to ^18, globals to ^17, stylelint to ^17,
stylelint-config-standard to ^40, sinon to ^22, and concurrently
to ^10.

Fix three new lint violations surfaced by the newer versions:
- Remove redundant padding-left after padding shorthand (stylelint 17)
- Remove useless null assignment before find() (eslint 10
  no-useless-assignment)
- Attach caught error as cause on rethrown Error (eslint 10
  preserve-caught-error)
2026-08-06 16:40:54 -05:00
raucao 4d4ed8c1ea Upgrade to oauth2-pkce 3
Bump oauth2-pkce from ^2.1.3 to ^3.0.0. In v3, getTokens() throws
ErrorAccessTokenExpired when the token has expired and no
onAccessTokenExpiry handler is configured. Add the handler to the
OSM auth client config so it automatically refreshes via the refresh
token instead of throwing
2026-08-06 16:35:11 -05:00
raucao 1613ebf0bf Upgrade to Ember 7.1
Bump ember-source from ~6.12.0 to ~7.1.0 and ember-cli from ^6.12.0
to ^7.1.0. Ember 7 removes APIs deprecated during the 6.x cycle
(import Ember, AMD bundles, inject from @ember/service) — none of
which were used in this codebase, so no source changes were needed.
2026-08-06 16:32:18 -05:00
raucao 4cb8c94c9c Upgrade to Babel 8, Vite 8, and babel-plugin-ember-template-compilation 4
Bump @babel/core, @babel/eslint-parser, @babel/plugin-transform-runtime,
and @babel/runtime to ^8.x. Remove useESModules and regenerator options
from @babel/plugin-transform-runtime config (both removed in Babel 8).
Switch @babel/eslint-parser import from the removed /experimental-worker
entry point to the main entry point.

Bump vite to ^8.0.0 (now using Rolldown/Oxc instead of esbuild/Rollup).
Bump @rollup/plugin-babel to ^7.1.0 and
babel-plugin-ember-template-compilation to ^4.0.0.

Patch ember-concurrency@5.2.0 to remove the api.assertVersion(7) call
that is incompatible with Babel 8 (ember-concurrency has not yet
released a Babel 8-compatible version).
2026-08-06 16:26:23 -05:00
raucao 1f87c802e1 Upgrade to Ember 6.12 stable
Move ember-source from ~6.11.0-alpha.6 to ~6.12.0 and ember-cli from
^6.10.0 to ^6.12.0. This is the final stable release of the 6.x
series and an LTS, clearing the way for a clean Ember 7 upgrade.
2026-08-06 16:19:14 -05:00
raucao a6576fcda8 Migrate to applesauce v6
Remove applesauce-factory (deleted in v6) and bump applesauce-core,
applesauce-relay, and applesauce-signers to ^6.2.x. Rewrite all
EventFactory usage from the removed constructor API
(`new EventFactory({ signer }).sign(template)`) to the new chainable
builder (`EventFactory.fromKind(k).content(...).sign(signer)`).

Remove `relay.eoseTimeout` overrides in nostr-auth — the eoseTimeout
property was removed in v6; NostrConnectSigner now uses persistent
subscriptions that don't rely on EOSE-based timeouts.

Update photo-gallery test mock to use 64-char hex pubkeys/ids/sigs
because v6's sign() validates event structure via validateEvent().

Also includes safe semver bumps to all other dependencies.
2026-08-06 16:13:24 -05:00
raucao 1822c4893f Update pinhead icon paths for 15.25.0 2026-08-06 16:12:37 -05:00
65 changed files with 7637 additions and 3388 deletions
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
version: 11
- name: Install Node
uses: actions/setup-node@v4
with:
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
version: 11
- name: Install Dependencies
run: pnpm install --frozen-lockfile
+42 -6
View File
@@ -34,6 +34,15 @@ import Icon from '#components/icon';
>remote storage</a>
to sync place bookmarks across apps and devices.
</p>
<p>
Connect your
<a
href="https://start.nostr.net"
target="_blank"
rel="noopener"
>Nostr</a>
identity to publish photos of places.
</p>
<details>
<summary>
@@ -91,6 +100,26 @@ import Icon from '#components/icon';
</a>
</td>
</tr>
<tr>
<td>
<a
href="https://openfreemap.org"
target="_blank"
rel="noopener"
>
Map tiles
</a>
</td>
<td>
<a
href="https://github.com/hyperknot/openfreemap/blob/main/LICENSE.md"
target="_blank"
rel="noopener"
>
Various
</a>
</td>
</tr>
<tr>
<td>
<a
@@ -139,13 +168,20 @@ import Icon from '#components/icon';
<div class="details-content">
<p>
<strong>Most impactful:</strong>
Add and improve data for points of interest in
<a
href="https://www.openstreetmap.org"
target="_blank"
rel="noopener"
>OpenStreetMap</a>.
</p>
<ul>
<li>
Add and improve data for points of interest in
<a
href="https://www.openstreetmap.org"
target="_blank"
rel="noopener"
>OpenStreetMap</a>.
</li>
<li>
Add place photos with your Nostr account.
</li>
</ul>
<p>
<strong>Most appreciated:</strong>
Use this app as much as you can and
+6
View File
@@ -25,6 +25,12 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
<span>Collections</span>
</button>
</li>
<li>
<button type="button" {{on "click" @onContributions}}>
<Icon @name="activity" @size={{20}} />
<span>My Contributions</span>
</button>
</li>
<li>
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
<Icon @name="settings" @size={{20}} />
+6
View File
@@ -23,6 +23,11 @@ export default class AppMenu extends Component {
this.router.transitionTo('lists.index');
}
@action
goToContributions() {
this.router.transitionTo('contributions');
}
<template>
<div class="sidebar app-menu-pane">
{{#if (eq this.currentView "menu")}}
@@ -30,6 +35,7 @@ export default class AppMenu extends Component {
@onNavigate={{this.setView}}
@onClose={{@onClose}}
@onSavedPlaces={{this.goToSavedPlaces}}
@onContributions={{this.goToContributions}}
/>
{{else if (eq this.currentView "settings")}}
+86
View File
@@ -0,0 +1,86 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or';
import formatRelativeDate from '../helpers/format-relative-date';
export default class ContributionPhoto extends Component {
get item() {
return this.args.item;
}
get primaryPhoto() {
return this.item?.photos?.[0] || null;
}
get extraPhotoCount() {
return Math.max(0, (this.item?.photos?.length || 0) - 1);
}
get placeName() {
return this.item?.placeName;
}
get isNameLoading() {
return this.item?.placeNameLoading;
}
get tags() {
// Collect unique tags across all photos in the contribution group
const all = (this.item?.photos || []).flatMap((p) => p.tags || []);
return [...new Set(all)];
}
<template>
<li>
<button
type="button"
class="contribution-item"
{{on "click" (fn @onSelect @item)}}
>
<div class="contribution-thumb">
{{#if this.primaryPhoto}}
<img
src={{or this.primaryPhoto.thumbUrl this.primaryPhoto.url}}
alt={{or this.primaryPhoto.alt "Place photo"}}
loading="lazy"
/>
{{/if}}
{{#if (gt this.extraPhotoCount 0)}}
<span
class="contribution-thumb-badge"
>+{{this.extraPhotoCount}}</span>
{{/if}}
</div>
<div class="contribution-info">
<div class="contribution-place">
{{#if this.placeName}}
{{this.placeName}}
{{else if this.isNameLoading}}
<span class="contribution-name-loading">Loading…</span>
{{else}}
<span class="contribution-name-loading">Unnamed place</span>
{{/if}}
</div>
<div class="contribution-meta">
{{formatRelativeDate @item.createdAt}}
{{#if (gt this.item.photos.length 1)}}
·
{{this.item.photos.length}}
photos
{{else}}
· 1 photo
{{/if}}
</div>
{{#if this.tags.length}}
<div class="contribution-tags">
{{#each this.tags as |tag|}}
<span class="contribution-tag">{{tag}}</span>
{{/each}}
</div>
{{/if}}
</div>
</button>
</li>
</template>
}
+86
View File
@@ -0,0 +1,86 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import Icon from './icon';
import ContributionPhoto from './contribution-photo';
import Modal from './modal';
import NostrConnect from './nostr-connect';
import eq from 'ember-truth-helpers/helpers/eq';
import not from 'ember-truth-helpers/helpers/not';
import restoreScroll from '../modifiers/restore-scroll';
export default class ContributionsTimelineComponent extends Component {
@tracked isNostrConnectModalOpen = false;
@action
openNostrConnectModal(event) {
event.preventDefault();
this.isNostrConnectModalOpen = true;
}
@action
closeNostrConnectModal() {
this.isNostrConnectModalOpen = false;
}
@action
onNostrConnected() {
this.closeNostrConnectModal();
this.args.onNostrConnected?.();
}
<template>
<div class="sidebar">
<div class="sidebar-header has-back-btn">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2 class="sidebar-header-text-centered">
<span class="sidebar-header-icon-wrapper">
<Icon @name="activity" @size={{20}} @color="#898989" />
</span>
My Contributions
</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else if (not @isConnected)}}
<p class="empty-state">
<a
href="#"
role="button"
tabindex="0"
{{on "click" this.openNostrConnectModal}}
>Connect your Nostr account</a>
to see your contributions.
</p>
{{else if (not @items.length)}}
<p class="empty-state">No contributions yet. Start by adding photos to
places.</p>
{{else}}
<ul class="contributions-list">
{{#each @items as |item|}}
{{#if (eq item.type "photo")}}
<ContributionPhoto @item={{item}} @onSelect={{@onSelect}} />
{{/if}}
{{/each}}
</ul>
{{/if}}
</div>
</div>
{{#if this.isNostrConnectModalOpen}}
<Modal @onClose={{this.closeNostrConnectModal}}>
<NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal>
{{/if}}
</template>
}
+21 -1
View File
@@ -8,6 +8,7 @@ import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
import Kinetic from 'ol/Kinetic.js';
import View from 'ol/View.js';
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
import { containsExtent } from 'ol/extent.js';
import Overlay from 'ol/Overlay.js';
import LayerGroup from 'ol/layer/Group.js';
import VectorLayer from 'ol/layer/Vector.js';
@@ -665,7 +666,12 @@ export default class MapComponent extends Component {
if (options.preventZoom) {
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
// This avoids unnecessary panning if the place is already visible.
this.handlePinVisibility(coords, { maintainZoom: true });
// But if the place has a bbox that doesn't fit in the current view, zoom out to fit it.
if (selected.bbox && !this.bboxFitsInView(selected.bbox)) {
this.zoomToBbox(selected.bbox);
} else {
this.handlePinVisibility(coords, { maintainZoom: true });
}
} else if (selected.bbox) {
this.zoomToBbox(selected.bbox);
} else {
@@ -678,6 +684,20 @@ export default class MapComponent extends Component {
}
});
bboxFitsInView(bbox) {
if (!this.mapInstance || !bbox) return true;
const view = this.mapInstance.getView();
const size = this.mapInstance.getSize();
const viewExtent = view.calculateExtent(size);
const min = fromLonLat([bbox.minLon, bbox.minLat]);
const max = fromLonLat([bbox.maxLon, bbox.maxLat]);
const bboxExtent = [...min, ...max];
return containsExtent(viewExtent, bboxExtent);
}
zoomToBbox(bbox) {
if (!this.mapInstance || !bbox) return;
+4 -4
View File
@@ -237,7 +237,7 @@ export default class PhotoCarousel extends Component {
data-src={{photo.url}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
alt={{photo.alt}}
{{fadeInImage photo.url}}
/>
{{else if this.isGalleryThumbnails}}
@@ -245,7 +245,7 @@ export default class PhotoCarousel extends Component {
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
alt={{photo.alt}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{else}}
@@ -260,7 +260,7 @@ export default class PhotoCarousel extends Component {
<img
data-src={{photo.url}}
class="place-header-photo landscape"
alt={{@name}}
alt={{photo.alt}}
{{fadeInImage photo.url}}
/>
</picture>
@@ -269,7 +269,7 @@ export default class PhotoCarousel extends Component {
<img
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo portrait"
alt={{@name}}
alt={{photo.alt}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{/if}}
+6 -10
View File
@@ -6,7 +6,7 @@ import { fn } from '@ember/helper';
import { service } from '@ember/service';
import { modifier } from 'ember-modifier';
import { task } from 'ember-concurrency';
import { EventFactory } from 'applesauce-factory';
import { EventFactory } from 'applesauce-core';
import config from 'marco/config/environment';
import DropdownMenu from './dropdown-menu';
import PhotoCarousel from './photo-carousel';
@@ -201,21 +201,17 @@ export default class PhotoGallery extends Component {
const eventId = this.currentPhoto.eventId;
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
const factory = new EventFactory({ signer: this.nostrAuth.signer });
const tags = [['e', eventId]];
if (this.currentPhoto.placeIdentifier) {
tags.push(['i', this.currentPhoto.placeIdentifier]);
}
const template = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
content: 'Deleted photo',
tags,
};
const event = await factory.sign(template);
const event = await EventFactory.fromKind(5)
.content('Deleted photo')
.modifyPublicTags(() => tags)
.as(this.nostrAuth.signer)
.sign();
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
// Remove from local store by adding the kind 5 to it
+1 -1
View File
@@ -232,7 +232,7 @@ export default class PlaceDetails extends Component {
return htmlSafe(
parts
.map((p) => {
const safeTel = p.replace(/[\s-]+/g, '');
const safeTel = p.replace(/[\s()+.-]/g, '');
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
})
.join('<br>')
+35 -21
View File
@@ -27,6 +27,7 @@ export default class PlacePhotoUpload extends Component {
@tracked isPublishing = false;
@tracked isDragging = false;
@tracked selectedTags = [];
@tracked altText = '';
get place() {
return this.args.place || {};
@@ -103,6 +104,7 @@ export default class PlacePhotoUpload extends Component {
this.file = null;
this.uploadedPhoto = null;
this.selectedTags = [];
this.altText = '';
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
}
@@ -118,6 +120,11 @@ export default class PlacePhotoUpload extends Component {
this.selectedTags = [tag];
}
@action
updateAltText(event) {
this.altText = event.target.value;
}
deletePhotoTask = task(async (photoData) => {
try {
if (photoData.hash) {
@@ -155,8 +162,6 @@ export default class PlacePhotoUpload extends Component {
this.isPublishing = true;
try {
const factory = new EventFactory({ signer: this.nostrAuth.signer });
const tags = [['i', `osm:${osmType}:${osmId}`]];
for (const tag of this.selectedTags) {
@@ -179,7 +184,10 @@ export default class PlacePhotoUpload extends Component {
imeta.push(`dim ${photo.dim}`);
}
imeta.push('alt A photo of a place');
const alt = this.altText.trim();
if (alt) {
imeta.push(`alt ${alt}`);
}
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
for (const fallbackUrl of photo.fallbackUrls) {
@@ -198,17 +206,11 @@ export default class PlacePhotoUpload extends Component {
tags.push(imeta);
// NIP-XX draft Place Photo event
const template = {
kind: 360,
content: '',
tags,
};
if (!template.created_at) {
template.created_at = Math.floor(Date.now() / 1000);
}
const event = await factory.sign(template);
const event = await EventFactory.fromKind(360)
.content('')
.modifyPublicTags(() => tags)
.as(this.nostrAuth.signer)
.sign();
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
this.nostrData.store.add(event);
@@ -217,6 +219,7 @@ export default class PlacePhotoUpload extends Component {
// Clear out the file so user can upload more or be done
this.file = null;
this.uploadedPhoto = null;
this.altText = '';
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
@@ -243,13 +246,11 @@ export default class PlacePhotoUpload extends Component {
{{/if}}
{{#if this.file}}
<div class="photo-grid">
<PlacePhotoUploadItem
@file={{this.file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
</div>
<PlacePhotoUploadItem
@file={{this.file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
{{#if this.suggestedTags.length}}
<div class="photo-tag-suggestions">
@@ -271,6 +272,19 @@ export default class PlacePhotoUpload extends Component {
</div>
{{/if}}
<div class="form-group">
<label for="photo-alt-input">Description (optional):</label>
<input
id="photo-alt-input"
type="text"
class="form-control"
placeholder="Describe this photo"
value={{this.altText}}
disabled={{this.isPublishing}}
{{on "input" this.updateAltText}}
/>
</div>
<button
type="button"
class="btn btn-primary btn-publish"
+60
View File
@@ -0,0 +1,60 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
export default class ContributionsController extends Controller {
@service router;
@service mapUi;
@service nostrAuth;
@service contributions;
@service storage;
loadContributionsTask = task({ restartable: true }, async (pubkey) => {
if (!pubkey) return;
await this.contributions.load(pubkey);
});
get scrollTop() {
return this.mapUi.getScrollPosition('contributions');
}
get items() {
return this.contributions.items;
}
get isConnected() {
return this.nostrAuth.isConnected;
}
@action
selectContribution(item) {
if (!item) return;
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition('contributions', sidebarContent.scrollTop);
}
this.mapUi.returnToRoute = { name: 'contributions' };
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
this.router.transitionTo(`/place/${item.placeIdentifier}`);
}
@action
onNostrConnected() {
this.loadContributionsTask.perform(this.nostrAuth.pubkey);
}
@action
backToMenu() {
this.router.transitionTo('menu');
}
@action
close() {
this.router.transitionTo('index');
}
}
+1 -3
View File
@@ -179,10 +179,8 @@ export default class SearchController extends Controller {
const targetName = params.selected || params.q;
if (targetName && pois.length > 0) {
let matchedPlace = null;
// 1. Exact Name Match
matchedPlace = pois.find(
let matchedPlace = pois.find(
(p) =>
p.osmTags &&
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
+6
View File
@@ -0,0 +1,6 @@
import { helper } from '@ember/component/helper';
import { formatRelativeDate as format } from '../utils/format-text';
export default helper(function formatRelativeDate([timestamp]) {
return format(timestamp);
});
+1
View File
@@ -14,6 +14,7 @@ Router.map(function () {
this.route('lists', function () {
this.route('list', { path: '/:list_id' });
});
this.route('contributions');
this.route('oauth', function () {
this.route('osm-callback', { path: '/osm/callback' });
});
+23
View File
@@ -0,0 +1,23 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ContributionsRoute extends Route {
@service mapUi;
@service nostrAuth;
@service contributions;
activate() {
this.mapUi.showSidebar();
}
setupController(controller, model) {
super.setupController(controller, model);
if (controller && controller.loadContributionsTask) {
controller.loadContributionsTask.perform(this.nostrAuth.pubkey);
}
}
deactivate() {
this.contributions.stop();
}
}
+39 -4
View File
@@ -1,6 +1,23 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
// Detects whether the fresh OSM data differs from the currently-shown model
// by more than ~1m of coordinate drift, or in any tag. Mirrors the relevant
// subset of `storage.refreshPlace`'s diff for the non-bookmark path.
function hasOsmChanges(place, fresh) {
const latDiff = Math.abs((place.lat ?? 0) - (fresh.lat ?? 0));
const lonDiff = Math.abs((place.lon ?? 0) - (fresh.lon ?? 0));
if (latDiff > 0.00001 || lonDiff > 0.00001) return true;
const oldTags = place.osmTags || {};
const newTags = fresh.osmTags || {};
const allKeys = new Set([...Object.keys(oldTags), ...Object.keys(newTags)]);
for (const key of allKeys) {
if (oldTags[key] !== newTags[key]) return true;
}
return false;
}
export default class PlaceRoute extends Route {
@service storage;
@service osm;
@@ -131,14 +148,32 @@ export default class PlaceRoute extends Route {
}
async checkUpdates(place) {
// Only check for updates if it's a saved place (has ID) and is an OSM object
if (place && place.id && place.osmId && place.osmType) {
if (!place || !place.osmId || !place.osmType) return;
// Bookmarked place — refresh via storage, which persists the update.
if (place.id) {
const updatedPlace = await this.storage.refreshPlace(place);
if (updatedPlace) {
// If an update occurred, refresh the map UI selection without moving the camera
// This ensures the sidebar shows the new data
// If an update occurred, refresh the map UI selection without moving
// the camera. This ensures the sidebar shows the new data.
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
}
return;
}
// Non-bookmarked explicit OSM place — fetch fresh and update the model
// in-place if anything changed, so the sidebar reflects the latest OSM
// data without requiring a re-open.
try {
const fresh = await this.osm.fetchOsmObject(place.osmId, place.osmType, {
forceFresh: true,
});
if (fresh && hasOsmChanges(place, fresh)) {
Object.assign(place, fresh);
this.mapUi.selectPlace(place, { preventZoom: true });
}
} catch (e) {
console.debug('[place] Fresh fetch failed for', place.osmId, e);
}
}
+7 -10
View File
@@ -35,23 +35,20 @@ export default class BlossomService extends Service {
}
async _getAuthHeader(action, hash, serverUrl) {
const factory = new EventFactory({ signer: this.nostrAuth.signer });
const now = Math.floor(Date.now() / 1000);
const serverHostname = new URL(serverUrl).hostname;
const authTemplate = {
kind: 24242,
created_at: now,
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
tags: [
const authEvent = await EventFactory.fromKind(24242)
.content(action === 'upload' ? 'Upload photo for place' : 'Delete photo')
.modifyPublicTags(() => [
['t', action],
['x', hash],
['expiration', String(now + 3600)],
['server', serverHostname],
],
};
const authEvent = await factory.sign(authTemplate);
])
.created(now)
.as(this.nostrAuth.signer)
.sign();
const base64 = btoa(JSON.stringify(authEvent));
const base64url = base64
.replace(/\+/g, '-')
+279
View File
@@ -0,0 +1,279 @@
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { groupPhotoContributions } from '../utils/contributions';
const NAME_CACHE_STORE = 'contributions-name-cache';
/**
* Orchestrates loading the user's own Nostr contributions, grouping them into
* timeline entries, and resolving place names from bookmarks or the OSM API.
*
* The flow is:
* 1. Subscribe to `nostrData.store.timeline(...)` for the user's kind 360 events.
* 2. Group events into contribution entries via `groupPhotoContributions`.
* 3. Resolve place names from bookmarks (sync, instant) on the first render,
* then asynchronously from the IndexedDB name cache and OSM cache.
* 4. For unresolved names, batch-fetch from the OSM API in the background.
* 5. Any items that can't be resolved get a fallback name so they don't stay
* stuck in a "Loading…" state forever.
* 6. Update `@tracked items` so the UI renders progressively.
*
* The persistent name cache is stored in IndexedDB (via the `localForage`
* service) with one key per `placeIdentifier`, so partial updates don't
* require re-serializing the whole map.
*/
export default class ContributionsService extends Service {
@service nostrData;
@service nostrAuth;
@service storage;
@service osm;
@service localForage;
@tracked items = [];
_sub = null;
_pendingBatchPromise = null;
_lastBatchSignature = '';
_unresolvable = new Set();
/**
* Async name resolution. Checks, in order:
* 1. Bookmarks (sync, instant).
* 2. The persistent IndexedDB name cache (per-entry key).
* 3. The OSM service's IndexedDB cache (from place detail visits).
*
* @param {object} entry A contribution entry with `osmType`, `osmId`, and `placeIdentifier`.
* @returns {Promise<string|null>}
*/
async _resolveCachedName(entry) {
// 1. Try bookmarks (instant)
const bookmark = this.storage.findPlaceById(entry.osmId);
if (bookmark?.title) return bookmark.title;
// 2. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
const cachedName = await this.localForage.get(
NAME_CACHE_STORE,
entry.placeIdentifier
);
if (cachedName) return cachedName;
// 3. Try OSM IndexedDB cache (from place detail visits)
const cached = await this.osm.getCachedOsmObject(
entry.osmType,
entry.osmId
);
return cached?.title || null;
}
/**
* Asynchronously resolves names for still-loading entries from the caches.
* Falls through to `_maybeBatchFetchNames` for anything that remains
* unresolved. Fire-and-forget from `_updateItems` so the list renders
* immediately with bookmark-resolved names.
*
* @param {Array} entries
* @returns {Promise<void>}
*/
async _resolveFromCache(entries) {
const pending = entries.filter((e) => e.placeNameLoading);
if (pending.length === 0) {
this._maybeBatchFetchNames(this.items);
return;
}
await Promise.all(
pending.map(async (entry) => {
const name = await this._resolveCachedName(entry);
if (name) {
entry.placeName = name;
entry.placeNameLoading = false;
}
})
);
// Re-render with whatever resolved, then kick off the network batch for
// entries that are still loading.
this.items = [...this.items];
this._maybeBatchFetchNames(this.items);
}
/**
* Loads the user's contributions and subscribes to live updates.
*
* @param {string} pubkey The user's Nostr pubkey
*/
async load(pubkey) {
if (!pubkey) {
this.items = [];
return;
}
// Subscribe to the user's own kind 360 events. Each update triggers a re-group
// and place-name resolution. This mirrors the `loadPhotosForPlace` pattern.
this._sub = this.nostrData.store
.timeline([{ kinds: [360, 5], authors: [pubkey] }])
.subscribe((events) => {
this._updateItems(events);
});
// Also kick off the network fetch via nostrData. This populates the store and
// causes the subscription above to fire with fresh events.
await this.nostrData.loadMyContributions(pubkey);
}
/**
* Stops the subscription and clears the timeline. Called when leaving the
* contributions route.
*/
stop() {
if (this._sub) {
this._sub.unsubscribe();
this._sub = null;
}
this.items = [];
this._lastBatchSignature = '';
this._pendingBatchPromise = null;
this._unresolvable.clear();
}
willDestroy() {
this.stop();
super.willDestroy(...arguments);
}
_updateItems(events) {
// 1. Group events into contribution entries (newest-first)
const entries = groupPhotoContributions(events);
// 2. Bookmark lookup is synchronous — resolve those immediately so the
// first render shows bookmarked place names without a "Loading…" flicker.
for (const entry of entries) {
const bookmark = this.storage.findPlaceById(entry.osmId);
if (bookmark?.title) {
entry.placeName = bookmark.title;
entry.placeNameLoading = false;
}
}
this.items = entries;
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
// cache, then fall through to the network batch for the rest.
void this._resolveFromCache(entries);
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
_maybeBatchFetchNames(entries) {
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
// Apply fallbacks for items already marked unresolvable in this session
for (const entry of entries) {
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
if (unresolved.length === 0) {
if (this.items.some((i) => !i.placeNameLoading)) {
this.items = [...this.items];
}
return;
}
// Build a stable signature so we only fire one batch request per unique set
const signature = unresolved
.map((e) => e.placeIdentifier)
.sort()
.join('|');
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved)
.then((nameMap) => {
// Merge resolved names back into the current `items`.
for (const item of this.items) {
if (!item.placeNameLoading) continue;
if (nameMap.has(item.placeIdentifier)) {
const name = nameMap.get(item.placeIdentifier);
item.placeName = name;
item.placeNameLoading = false;
} else {
// Could not be resolved (deleted object, network error for this item).
// Mark as unresolvable for this session and use a fallback.
this._unresolvable.add(item.placeIdentifier);
item.placeName = this._fallbackName(item);
item.placeNameLoading = false;
}
}
// Trigger a re-render
this.items = [...this.items];
})
.catch((e) => {
console.error('[contributions] Batch name resolution failed', e);
// On a total failure, mark all as unresolvable and apply fallbacks
for (const item of this.items) {
if (item.placeNameLoading) {
this._unresolvable.add(item.placeIdentifier);
item.placeName = this._fallbackName(item);
item.placeNameLoading = false;
}
}
this.items = [...this.items];
})
.finally(() => {
this._pendingBatchPromise = null;
});
}
_fallbackName(item) {
return `OSM ${item.osmType} ${item.osmId}`;
}
async _batchResolveNames(entries) {
const nameMap = new Map();
const toFetch = [];
// Re-check cache in case it was populated between the trigger and now
for (const entry of entries) {
const cached = await this._resolveCachedName(entry);
if (cached) {
nameMap.set(entry.placeIdentifier, cached);
} else {
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
}
}
if (toFetch.length === 0) return nameMap;
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
// Persist each newly-resolved name to the IndexedDB name cache as its own
// entry (per-placeIdentifier key) so we don't re-fetch next session.
const writePromises = [];
for (const entry of entries) {
if (nameMap.has(entry.placeIdentifier)) continue;
const cacheKey = `${entry.osmType}:${entry.osmId}`;
const place = places.get(cacheKey);
if (place?.title) {
nameMap.set(entry.placeIdentifier, place.title);
writePromises.push(
this.localForage.set(
NAME_CACHE_STORE,
entry.placeIdentifier,
place.title
)
);
}
}
await Promise.all(writePromises);
return nameMap;
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ export default class ImageProcessorService extends Service {
});
});
} catch (e) {
throw new Error(`Failed to process image: ${e.message}`);
throw new Error(`Failed to process image: ${e.message}`, { cause: e });
}
}
+55
View File
@@ -0,0 +1,55 @@
import localforage from 'localforage';
import Service from '@ember/service';
const DB_NAME = 'marco';
/**
* Thin async-only wrapper around `localforage` that exposes namespaced
* object stores under a single IndexedDB database (`marco`).
*
* Each "store" is a `localforage` instance created with a unique
* `storeName`, so callers can keep data isolated (e.g. OSM cache vs.
* contributions name cache) without managing their own connections.
*
* All methods return Promises — IndexedDB is inherently async, so callers
* must `await` every read/write.
*/
export default class LocalForageService extends Service {
_instances = new Map();
_instance(storeName) {
let instance = this._instances.get(storeName);
if (!instance) {
instance = localforage.createInstance({
name: DB_NAME,
storeName,
});
this._instances.set(storeName, instance);
}
return instance;
}
async get(storeName, key) {
return this._instance(storeName).getItem(key);
}
async set(storeName, key, value) {
return this._instance(storeName).setItem(key, value);
}
async remove(storeName, key) {
return this._instance(storeName).removeItem(key);
}
async keys(storeName) {
return this._instance(storeName).keys();
}
async clear(storeName) {
return this._instance(storeName).clear();
}
async iterate(storeName, fn) {
return this._instance(storeName).iterate(fn);
}
}
+1 -7
View File
@@ -12,7 +12,7 @@ const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key';
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
const DEFAULT_CONNECT_RELAY = 'wss://nostr.kosmos.org';
import { isMobile } from '../utils/device';
@@ -150,9 +150,6 @@ export default class NostrAuthService extends Service {
const relay = DEFAULT_CONNECT_RELAY;
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
// Override aggressive 10s EOSE timeout to allow time for QR scanning
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
this._signerInstance = new NostrConnectSigner({
pool: this.nostrRelay.pool,
relays: [relay],
@@ -235,9 +232,6 @@ export default class NostrAuthService extends Service {
const localSigner = this._getLocalSigner();
// Override aggressive 10s EOSE timeout to allow time for QR scanning
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
this._signerInstance = new NostrConnectSigner({
pool: this.nostrRelay.pool,
relays: [relay],
+57
View File
@@ -34,11 +34,13 @@ export default class NostrDataService extends Service {
@tracked mailboxes = null;
@tracked blossomServers = [];
@tracked placePhotos = [];
@tracked myContributionEvents = [];
_profileSub = null;
_mailboxesSub = null;
_blossomSub = null;
_photosSub = null;
_contributionsSub = null;
_requestSub = null;
_cachePromise = null;
@@ -278,6 +280,57 @@ export default class NostrDataService extends Service {
});
}
async loadMyContributions(pubkey) {
if (!pubkey) return;
// Reset state and unsubscribe from any previous subscription
this.myContributionEvents = [];
if (this._contributionsSub) {
this._contributionsSub.unsubscribe();
this._contributionsSub = null;
}
const filters = [{ kinds: [360, 5], authors: [pubkey] }];
// 1. Set up reactive store subscription so the timeline updates as events arrive
this._contributionsSub = this.store
.timeline(filters)
.subscribe((events) => {
this.myContributionEvents = events;
});
// 2. Populate the store from the local Nostr IDB cache (instant)
try {
await this._cachePromise;
const cachedEvents = await this.cache.query(filters);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn(
'[nostr-data] Failed to read my contributions from local Nostr IDB cache',
e
);
}
// 3. Request fresh events from the network in the background
this.nostrRelay.pool.request(this.activeReadRelays, filters).subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error(
'[nostr-data] Error fetching my contribution events:',
err
);
},
});
}
async loadProfile(pubkey) {
if (!pubkey) return;
@@ -409,6 +462,10 @@ export default class NostrDataService extends Service {
this._photosSub.unsubscribe();
this._photosSub = null;
}
if (this._contributionsSub) {
this._contributionsSub.unsubscribe();
this._contributionsSub = null;
}
}
willDestroy() {
+3
View File
@@ -45,6 +45,9 @@ export default class OsmAuthService extends Service {
clientId: clientId,
redirectUrl: redirectUrl,
storeRefreshToken: true,
onAccessTokenExpiry() {
return this.exchangeRefreshTokenForAccessToken();
},
},
new MarcoOsmAuthStorage()
);
+230 -20
View File
@@ -4,12 +4,110 @@ import { getCategoryById } from '../utils/poi-categories';
export default class OsmService extends Service {
@service settings;
@service localForage;
controller = null;
cachedResults = null;
lastQueryKey = null;
cachedPlaces = new Map();
// Long-term cache for OSM place metadata, persisted to IndexedDB (via the
// `localForage` service) so that names and basic info survive across
// sessions and can be rendered instantly without waiting on the OSM API.
// Entries are refreshed in the background.
static STORE_NAME = 'osm-cache';
static CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
static IN_MEMORY_TTL_MS = 10000; // 10 seconds
_buildCacheKey(osmType, osmId) {
return `${osmType}:${osmId}`;
}
async _readLocalCache(osmType, osmId) {
const key = this._buildCacheKey(osmType, osmId);
try {
const raw = await this.localForage.get(OsmService.STORE_NAME, key);
if (!raw) return null;
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (
!parsed ||
typeof parsed.timestamp !== 'number' ||
Date.now() - parsed.timestamp > OsmService.CACHE_TTL_MS
) {
await this.localForage.remove(OsmService.STORE_NAME, key);
return null;
}
return parsed.data;
} catch {
try {
await this.localForage.remove(OsmService.STORE_NAME, key);
} catch {
// ignore
}
return null;
}
}
async _writeLocalCache(osmType, osmId, data) {
if (!data) return;
const key = this._buildCacheKey(osmType, osmId);
try {
await this.localForage.set(OsmService.STORE_NAME, key, {
data,
timestamp: Date.now(),
});
} catch (e) {
console.debug('[osm] Failed to write IndexedDB cache entry', e);
}
}
/**
* Async lookup for an OSM place. Checks the short-lived in-memory cache
* first, then the persistent IndexedDB cache. Returns `null` if not
* cached.
*
* Use this for instant rendering (e.g. place names in a list) and fall
* back to `fetchOsmObject` for a fresh fetch + background refresh.
*
* @param {string} osmType 'node' | 'way' | 'relation'
* @param {string} osmId
* @returns {Promise<object|null>} Normalized OSM place data
*/
async getCachedOsmObject(osmType, osmId) {
if (!osmType || !osmId) return null;
const cacheKey = this._buildCacheKey(osmType, osmId);
const memoryEntry = this.cachedPlaces.get(cacheKey);
if (
memoryEntry &&
Date.now() - memoryEntry.timestamp < OsmService.IN_MEMORY_TTL_MS
) {
return memoryEntry.data;
}
return this._readLocalCache(osmType, osmId);
}
_storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, data) {
if (!data || !data.title) {
console.debug(
`[osm] Skipping cache write for ${cacheKey}: data has no title`
);
return;
}
this.cachedPlaces.set(cacheKey, { data, timestamp: Date.now() });
// Auto-evict from the short-lived in-memory cache so it doesn't grow unbounded.
setTimeout(() => {
this.cachedPlaces.delete(cacheKey);
}, OsmService.IN_MEMORY_TTL_MS);
// Fire-and-forget the persistent write — the in-memory cache covers the
// next immediate read, and the IndexedDB write happens in the background.
void this._writeLocalCache(osmType, osmId, data);
}
cancelAll() {
if (this.controller) {
this.controller.abort();
@@ -230,16 +328,57 @@ out center;
return this.normalizePoi(data.elements[0]);
}
async fetchOsmObject(osmId, osmType) {
async fetchOsmObject(osmId, osmType, { forceFresh = false } = {}) {
if (!osmId || !osmType) return null;
const cacheKey = `${osmType}:${osmId}`;
const cacheKey = this._buildCacheKey(osmType, osmId);
// Force a fresh fetch from the API, bypassing the cache. Used by callers
// that need genuinely current data (e.g. storage.refreshPlace, which
// diffs the bookmark against freshly-fetched OSM data).
if (forceFresh) {
return this._fetchAndCacheOsmObject(osmId, osmType, cacheKey);
}
// Cached-first path: return the in-memory entry if it's still warm.
const cached = this.cachedPlaces.get(cacheKey);
if (cached && Date.now() - cached.timestamp < 10000) {
if (cached && Date.now() - cached.timestamp < OsmService.IN_MEMORY_TTL_MS) {
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
return cached.data;
}
// Otherwise return the persistent IndexedDB entry and refresh in the
// background so the next visit is fresh.
const localCached = await this._readLocalCache(osmType, osmId);
if (localCached) {
console.debug(`Using IndexedDB cached OSM object for ${cacheKey}`);
// Refresh in the background, but don't block the caller.
this._refreshOsmObject(osmId, osmType, cacheKey).catch((e) => {
console.debug('[osm] Background refresh failed for', cacheKey, e);
});
return localCached;
}
return this._fetchAndCacheOsmObject(osmId, osmType, cacheKey);
}
async _refreshOsmObject(osmId, osmType, cacheKey) {
const fresh = await this._fetchOsmObjectFromApi(osmId, osmType);
if (fresh) {
this._storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, fresh);
}
return fresh;
}
async _fetchAndCacheOsmObject(osmId, osmType, cacheKey) {
const data = await this._fetchOsmObjectFromApi(osmId, osmType);
if (data) {
this._storeInMemoryAndLocalStorage(cacheKey, osmType, osmId, data);
}
return data;
}
async _fetchOsmObjectFromApi(osmId, osmType) {
let url;
if (osmType === 'node') {
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
@@ -263,29 +402,100 @@ out center;
}
const data = await res.json();
const normalizedData = this.normalizeOsmApiData(
data.elements,
osmId,
osmType
);
this.cachedPlaces.set(cacheKey, {
data: normalizedData,
timestamp: Date.now(),
});
// Cleanup cache entry automatically after 10 seconds
setTimeout(() => {
this.cachedPlaces.delete(cacheKey);
}, 10000);
return normalizedData;
return this.normalizeOsmApiData(data.elements, osmId, osmType);
} catch (e) {
console.error('Failed to fetch OSM object:', e);
return null;
}
}
/**
* Batch-fetches multiple OSM objects, using cached data where available and
* only making network requests for uncached IDs.
*
* @param {Array<{osmType: string, osmId: string}>} items
* @returns {Promise<Map<string, object>>} Map keyed by `${osmType}:${osmId}`
*/
async fetchOsmObjectsBatch(items) {
const result = new Map();
if (!items || items.length === 0) return result;
// 1. Collect whatever is already cached
const missing = [];
for (const { osmType, osmId } of items) {
if (!osmType || !osmId) continue;
const cacheKey = this._buildCacheKey(osmType, osmId);
const cached = await this.getCachedOsmObject(osmType, osmId);
if (cached) {
result.set(cacheKey, cached);
} else {
missing.push({ osmType, osmId, cacheKey });
}
}
if (missing.length === 0) return result;
// 2. Group missing items by OSM type for batched API requests
const byType = new Map();
for (const item of missing) {
if (!byType.has(item.osmType)) byType.set(item.osmType, []);
byType.get(item.osmType).push(item);
}
// 3. Fetch each type group from the OSM API (one request per type)
const fetchPromises = [];
for (const [osmType, group] of byType) {
fetchPromises.push(this._fetchOsmObjectsByType(osmType, group, result));
}
await Promise.allSettled(fetchPromises);
return result;
}
async _fetchOsmObjectsByType(osmType, group, result) {
// OSM API supports fetching multiple elements of the same type in a single
// request using comma-separated IDs (max ~50 per request).
//
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
// nodes, so normalized data may lack lat/lon and geometry. We intentionally
// do NOT write these results to the general OSM cache — only the returned
// Map is used by the caller (the contributions service manages its own
// name cache). This prevents incomplete data from breaking place detail
// navigation, which needs the full single-object endpoint.
const MAX_IDS_PER_REQUEST = 50;
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
const chunk = group.slice(i, i + MAX_IDS_PER_REQUEST);
const idsParam = chunk.map((c) => c.osmId).join(',');
const plural = osmType === 'way' ? 'ways' : osmType + 's';
const url = `https://www.openstreetmap.org/api/0.6/${plural}.json?${plural}=${idsParam}`;
try {
const res = await this.fetchWithRetry(url);
if (!res.ok) {
console.warn(
`[osm] Batch fetch failed for ${osmType} (${res.status})`,
idsParam
);
continue;
}
const data = await res.json();
for (const item of chunk) {
const normalized = this.normalizeOsmApiData(
data.elements,
item.osmId,
osmType
);
if (normalized) {
result.set(item.cacheKey, normalized);
}
}
} catch (e) {
console.error(`[osm] Batch fetch error for ${osmType}:`, e);
}
}
}
normalizeOsmApiData(elements, targetId, targetType) {
if (!elements || elements.length === 0) return null;
+2 -1
View File
@@ -426,7 +426,8 @@ export default class StorageService extends Service {
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
const freshData = await this.osm.fetchOsmObject(
place.osmId,
place.osmType
place.osmType,
{ forceFresh: true }
);
if (!freshData) {
+123 -9
View File
@@ -245,13 +245,6 @@ body {
display: none;
}
.photo-grid {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.photo-upload-item {
position: relative;
aspect-ratio: 4 / 3;
@@ -259,6 +252,7 @@ body {
overflow: hidden;
background: #1e262e;
width: 100%;
margin-bottom: 1.25rem;
}
.photo-upload-item img {
@@ -758,6 +752,25 @@ select.form-control {
text-decoration: underline;
}
.about-section p + ul {
margin-top: 0.25rem;
}
.about-section ul {
padding-inline-start: 1.2em;
margin: 0.5rem 0 0;
}
.about-section li {
padding-inline-start: 0.5rem;
margin-block: 0.15rem;
}
.about-section li::marker {
content: '♥';
color: #898989;
}
.btn-full {
width: 100%;
}
@@ -1530,7 +1543,6 @@ button.create-place {
color: #5f6368;
border-radius: 50%;
margin-left: 4px;
padding-left: 8px;
}
.search-submit-btn:hover {
@@ -1867,8 +1879,12 @@ button.create-place {
font-size: 1.2rem;
}
.place-photo-upload .form-group {
margin-bottom: 1.5rem;
}
.photo-tag-suggestions {
margin: 1rem 0 1.5rem;
margin-bottom: 1.25rem;
}
.photo-tag-suggestions-title {
@@ -2261,3 +2277,101 @@ button.create-place {
justify-content: center;
padding: 4rem 1rem;
}
/* Contributions Timeline */
.contributions-list {
list-style: none;
padding: 0;
margin: -1rem -1rem 0;
}
.contribution-item {
width: 100%;
text-align: left;
border: none;
border-bottom: 1px solid var(--divider-color);
background: var(--primary-background-color);
color: var(--body-text-color);
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
display: flex;
align-items: flex-start;
gap: 12px;
}
.contribution-item:hover {
background: var(--hover-bg);
}
.contribution-thumb {
position: relative;
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 6px;
overflow: hidden;
background: #f0f0f0;
}
.contribution-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.contribution-thumb-badge {
position: absolute;
bottom: 2px;
right: 2px;
background: rgb(0 0 0 / 65%);
color: #fff;
font-size: 0.7rem;
font-weight: bold;
padding: 1px 4px;
border-radius: 4px;
}
.contribution-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.contribution-place {
font-size: 1rem;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contribution-name-loading {
color: #999;
font-weight: normal;
font-style: italic;
}
.contribution-meta {
color: #666;
font-size: 0.85rem;
}
.contribution-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.contribution-tag {
background: #f0f0f0;
color: #555;
font-size: 0.72rem;
padding: 2px 6px;
border-radius: 10px;
}
+8 -2
View File
@@ -13,7 +13,7 @@ export default class ApplicationComponent extends Component {
@service router;
get isSidebarOpen() {
// We consider the sidebar "open" if we are in search, menu, lists or place routes AND it's visible.
// We consider the sidebar "open" if we are in search, menu, lists, contributions or place routes AND it's visible.
// This helps the map know if it should shift the center or adjust view.
const name = this.router.currentRouteName;
return (
@@ -22,6 +22,7 @@ export default class ApplicationComponent extends Component {
name === 'place.new' ||
name === 'search' ||
name === 'menu' ||
name === 'contributions' ||
name.startsWith('lists'))
);
}
@@ -49,11 +50,16 @@ export default class ApplicationComponent extends Component {
name === 'search' ||
name === 'place' ||
name === 'menu' ||
name === 'contributions' ||
name.startsWith('lists')
) {
this.mapUi.clearSelection();
this.mapUi.hideSidebar();
if (name === 'menu' || name.startsWith('lists')) {
if (
name === 'menu' ||
name === 'contributions' ||
name.startsWith('lists')
) {
this.router.transitionTo('index');
} else if (name === 'place') {
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
+16
View File
@@ -0,0 +1,16 @@
import ContributionsTimeline from '#components/contributions-timeline';
<template>
{{#if @controller.mapUi.isSidebarVisible}}
<ContributionsTimeline
@items={{@controller.items}}
@isLoading={{@controller.loadContributionsTask.isRunning}}
@isConnected={{@controller.isConnected}}
@scrollTop={{@controller.scrollTop}}
@onSelect={{@controller.selectContribution}}
@onBack={{@controller.backToMenu}}
@onClose={{@controller.close}}
@onNostrConnected={{@controller.onNostrConnected}}
/>
{{/if}}
</template>
+5 -1
View File
@@ -81,7 +81,11 @@ export default class PlaceTemplate extends Component {
if (this.mapUi.returnToRoute) {
this.mapUi.showSidebar();
const { name, model } = this.mapUi.returnToRoute;
this.router.transitionTo(name, model);
if (model !== undefined) {
this.router.transitionTo(name, model);
} else {
this.router.transitionTo(name);
}
}
// If we have an active search context, return to it (UP navigation)
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
+229
View File
@@ -0,0 +1,229 @@
/**
* Utilities for grouping the user's Nostr contribution events into timeline entries.
*
* A contribution "entry" groups one or more related Nostr events that are part of the
* same logical action (e.g. uploading several photos of the same place in quick
* succession). Entries are ordered newest-first.
*/
import { tracked } from '@glimmer/tracking';
const HOUR_IN_SECONDS = 60 * 60;
/**
* A single contribution timeline entry.
*
* `placeName` and `placeNameLoading` are tracked so that mutating them after
* the entry has been rendered (e.g. when the background OSM batch fetch
* resolves a place name) re-renders the consuming component. The remaining
* fields are static data and do not need to be tracked.
*/
export class ContributionEntry {
type = 'photo';
placeIdentifier;
osmType;
osmId;
photos;
createdAt;
eventCount;
@tracked placeName = null;
@tracked placeNameLoading = true;
constructor({
placeIdentifier,
osmType,
osmId,
photos,
createdAt,
eventCount,
}) {
this.placeIdentifier = placeIdentifier;
this.osmType = osmType;
this.osmId = osmId;
this.photos = photos;
this.createdAt = createdAt;
this.eventCount = eventCount;
}
}
/**
* Parses a single kind 360 (Place Photo) event's `imeta` tag into a photo object.
* Reuses the same field shape as `parsePlacePhotos` in `utils/nostr.js` but operates
* on a single event (the NIP mandates exactly one `imeta` tag per event).
*
* @param {object} event A NIP-360 (kind 360) Nostr event
* @returns {object|null} A photo object, or null if no usable `imeta` was found
*/
function parsePhotoFromEvent(event) {
const tags = event.tags || [];
const eventTags = tags
.filter((t) => t[0] === 't')
.map((t) => t[1])
.filter(Boolean);
const imeta = tags.find((t) => t[0] === 'imeta');
if (!imeta) return null;
let url = null;
let thumbUrl = null;
let blurhash = null;
let isLandscape = false;
let aspectRatio = 16 / 9;
let altText = null;
for (const tag of imeta.slice(1)) {
if (tag.startsWith('url ')) {
url = tag.substring(4);
} else if (tag.startsWith('thumb ')) {
thumbUrl = tag.substring(6);
} else if (tag.startsWith('blurhash ')) {
blurhash = tag.substring(9);
} else if (tag.startsWith('dim ')) {
const [width, height] = tag.substring(4).split('x').map(Number);
if (width && height) {
aspectRatio = width / height;
if (width > height) isLandscape = true;
}
} else if (tag.startsWith('alt ')) {
const alt = tag.substring(4).trim();
altText = alt === 'A photo of a place' ? null : alt || null;
}
}
if (!url) return null;
const placeIdentifier = tags.find((t) => t[0] === 'i')?.[1];
return {
eventId: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
url,
thumbUrl,
blurhash,
isLandscape,
aspectRatio,
placeIdentifier,
tags: eventTags,
alt: altText,
};
}
/**
* Applies kind 5 deletion events to a list of kind 360 events, returning the
* surviving kind 360 events.
*
* @param {Array} events Mixed kind 360 and kind 5 events
* @returns {Array} Surviving kind 360 events
*/
function applyDeletions(events) {
const deletedIds = new Set();
for (const event of events) {
if (event.kind === 5) {
for (const tag of event.tags || []) {
if (tag[0] === 'e' && tag[1]) deletedIds.add(tag[1]);
}
}
}
return events.filter(
(event) => event.kind === 360 && !deletedIds.has(event.id)
);
}
/**
* Groups kind 360 (Place Photo) events into contribution entries.
*
* Grouping rules:
* 1. Events are grouped by their OSM entity identifier (the `i` tag).
* 2. Within an entity, events are sub-grouped by time proximity: a new
* sub-group starts whenever the gap between two consecutive events
* exceeds `thresholdHours`.
* 3. Each sub-group becomes a single contribution entry with one or more
* photos, sorted by `created_at` descending (newest entry first).
*
* @param {Array} events Mixed kind 360 / kind 5 events from the user
* @param {number} [thresholdHours=3] Max gap in hours within a sub-group
* @returns {Array} Sorted contribution entries (newest first)
*/
export function groupPhotoContributions(events, thresholdHours = 3) {
if (!events || events.length === 0) return [];
const photoEvents = applyDeletions(events);
if (photoEvents.length === 0) return [];
// Group by OSM entity identifier
const byEntity = new Map();
for (const event of photoEvents) {
const entityTag = (event.tags || []).find((t) => t[0] === 'i');
const entityId = entityTag?.[1];
if (!entityId) continue;
if (!byEntity.has(entityId)) byEntity.set(entityId, []);
byEntity.get(entityId).push(event);
}
const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS;
const entries = [];
for (const [entityId, entityEvents] of byEntity) {
// Sort newest-first within the entity
const sorted = [...entityEvents].sort(
(a, b) => b.created_at - a.created_at
);
// Walk newest -> oldest, starting a new sub-group when the gap to the
// previous event exceeds the threshold.
let currentGroup = [];
let prevTime = null;
for (const event of sorted) {
if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) {
entries.push(buildEntry(entityId, currentGroup));
currentGroup = [];
}
currentGroup.push(event);
prevTime = event.created_at;
}
if (currentGroup.length > 0) {
entries.push(buildEntry(entityId, currentGroup));
}
}
// Sort entries by their newest event's created_at, descending. Entries with
// no usable photos (e.g. malformed imeta) are filtered out.
return entries
.filter((e) => e.photos.length > 0)
.sort((a, b) => b.createdAt - a.createdAt);
}
/**
* Builds a single contribution entry object from a group of events for the
* same OSM entity.
*
* @param {string} placeIdentifier e.g. "osm:node:123456"
* @param {Array} events Kind 360 events in this sub-group
* @returns {object} Contribution entry
*/
function buildEntry(placeIdentifier, events) {
const photos = events
.map(parsePhotoFromEvent)
.filter(Boolean)
.sort((a, b) => a.createdAt - b.createdAt);
const createdAt = events.reduce(
(max, e) => (e.created_at > max ? e.created_at : max),
0
);
const [, osmType, osmId] = placeIdentifier.split(':');
return new ContributionEntry({
placeIdentifier,
osmType,
osmId,
photos,
createdAt,
eventCount: events.length,
});
}
+20
View File
@@ -12,3 +12,23 @@ export function capitalize(text) {
if (typeof text !== 'string' || !text) return '';
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function formatRelativeDate(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now - date;
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin} min ago`;
if (diffHr < 24) return `${diffHr} hr ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
+2 -2
View File
@@ -102,7 +102,7 @@ import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
import parkingP from '@waysidemapping/pinhead/dist/icons/p_wide.svg?raw';
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
@@ -118,7 +118,7 @@ import policeOfficerWithStopArm from '@waysidemapping/pinhead/dist/icons/police_
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/open_scissors.svg?raw';
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
import steamTrainOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/steam_train_on_railway_track.svg?raw';
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
+6
View File
@@ -67,6 +67,7 @@ export function parsePlacePhotos(events) {
let blurhash = null;
let isLandscape = false;
let aspectRatio = 16 / 9; // default
let altText = null;
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
for (const tag of imeta.slice(1)) {
@@ -85,6 +86,10 @@ export function parsePlacePhotos(events) {
isLandscape = true;
}
}
} else if (tag.startsWith('alt ')) {
const alt = tag.substring(4).trim();
// Strip the legacy placeholder we used to write on every event
altText = alt === 'A photo of a place' ? null : alt || null;
}
}
@@ -100,6 +105,7 @@ export function parsePlacePhotos(events) {
aspectRatio,
placeIdentifier,
tags: eventTagValues,
alt: altText,
});
}
}
-2
View File
@@ -37,8 +37,6 @@ export default {
'@babel/plugin-transform-runtime',
{
absoluteRuntime: dirname(fileURLToPath(import.meta.url)),
useESModules: true,
regenerator: false,
},
],
...macros.babelMacros,
+1 -1
View File
@@ -22,7 +22,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
import qunit from 'eslint-plugin-qunit';
import n from 'eslint-plugin-n';
import babelParser from '@babel/eslint-parser/experimental-worker';
import babelParser from '@babel/eslint-parser';
const esmParserOptions = {
ecmaFeatures: { modules: true },
+54 -54
View File
@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.25.1",
"version": "1.27.1",
"private": true,
"description": "Unhosted maps app",
"repository": {
@@ -39,61 +39,61 @@
"version": "pnpm build && git add release/"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/eslint-parser": "^7.28.5",
"@babel/plugin-transform-runtime": "^7.28.5",
"@babel/runtime": "^7.28.4",
"@ember/test-helpers": "^5.4.1",
"@ember/test-waiters": "^4.1.1",
"@embroider/core": "^4.4.2",
"@babel/core": "^8.0.1",
"@babel/eslint-parser": "^8.0.1",
"@babel/plugin-transform-runtime": "^8.0.1",
"@babel/runtime": "^8.0.0",
"@ember/test-helpers": "^5.4.3",
"@ember/test-waiters": "^4.1.2",
"@embroider/core": "^4.6.3",
"@embroider/legacy-inspector-support": "^0.1.3",
"@embroider/macros": "^1.19.6",
"@embroider/macros": "^1.20.6",
"@embroider/router": "^3.0.6",
"@embroider/vite": "^1.5.0",
"@eslint/js": "^9.39.2",
"@glimmer/component": "^2.0.0",
"@embroider/vite": "^1.7.9",
"@eslint/js": "^10.0.1",
"@glimmer/component": "^2.1.1",
"@remotestorage/module-places": "~1.3.0",
"@rollup/plugin-babel": "^6.1.0",
"@warp-drive/core": "~5.8.0",
"@warp-drive/ember": "~5.8.0",
"@warp-drive/json-api": "~5.8.0",
"@warp-drive/legacy": "~5.8.0",
"@warp-drive/utilities": "~5.8.0",
"babel-plugin-ember-template-compilation": "^3.0.1",
"concurrently": "^9.2.1",
"decorator-transforms": "^2.3.1",
"ember-cli": "^6.10.0",
"ember-cli-deprecation-workflow": "^4.0.0",
"ember-modifier": "^4.2.2",
"@rollup/plugin-babel": "^7.1.0",
"@warp-drive/core": "~5.8.2",
"@warp-drive/ember": "~5.8.2",
"@warp-drive/json-api": "~5.8.2",
"@warp-drive/legacy": "~5.8.2",
"@warp-drive/utilities": "~5.8.2",
"babel-plugin-ember-template-compilation": "^4.0.0",
"concurrently": "^10.0.4",
"decorator-transforms": "^2.4.0",
"ember-cli": "^7.1.0",
"ember-cli-deprecation-workflow": "^4.0.1",
"ember-modifier": "^4.3.0",
"ember-page-title": "^9.0.3",
"ember-qunit": "^9.0.4",
"ember-resolver": "^13.1.1",
"ember-source": "~6.11.0-alpha.6",
"ember-qunit": "^9.1.0",
"ember-resolver": "^13.2.0",
"ember-source": "~7.2.0",
"ember-template-lint": "^7.9.3",
"ember-truth-helpers": "^5.0.0",
"ember-welcome-page": "^8.0.4",
"eslint": "^9.39.2",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-ember": "^12.7.5",
"eslint-plugin-n": "^17.23.1",
"eslint-plugin-qunit": "^8.2.5",
"eslint-plugin-warp-drive": "^5.8.0",
"eslint-plugin-ember": "^13.5.0",
"eslint-plugin-n": "^18.3.0",
"eslint-plugin-qunit": "^8.2.6",
"eslint-plugin-warp-drive": "^5.8.2",
"feather-icons": "^4.29.2",
"globals": "^16.5.0",
"globals": "^17.11.0",
"latlon-geohash": "^2.0.0",
"ol": "^10.7.0",
"ol-mapbox-style": "^13.2.0",
"prettier": "^3.7.4",
"prettier-plugin-ember-template-tag": "^2.1.2",
"qunit": "^2.25.0",
"qunit-dom": "^3.5.0",
"ol": "^10.10.0",
"ol-mapbox-style": "^13.4.2",
"prettier": "^3.9.6",
"prettier-plugin-ember-template-tag": "^2.1.7",
"qunit": "^2.26.0",
"qunit-dom": "^3.6.0",
"remotestorage-widget": "^1.8.1",
"remotestoragejs": "2.0.0-beta.9",
"sinon": "^21.0.1",
"stylelint": "^16.26.1",
"stylelint-config-standard": "^38.0.0",
"testem": "^3.17.0",
"vite": "^7.3.0"
"remotestoragejs": "2.0.0-beta.10",
"sinon": "^22.1.0",
"stylelint": "^17.14.1",
"stylelint-config-standard": "^40.0.0",
"testem": "^3.20.1",
"vite": "^8.0.0"
},
"engines": {
"node": ">= 22"
@@ -102,17 +102,17 @@
"edition": "octane"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"@waysidemapping/pinhead": "^15.20.0",
"applesauce-core": "^5.2.0",
"applesauce-factory": "^4.0.0",
"applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0",
"@noble/hashes": "^2.3.0",
"@waysidemapping/pinhead": "^15.25.0",
"applesauce-core": "^6.2.0",
"applesauce-relay": "^6.2.1",
"applesauce-signers": "^6.2.2",
"blurhash": "^2.0.5",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
"nostr-idb": "^5.0.0",
"oauth2-pkce": "^2.1.3",
"ember-lifeline": "^7.1.0",
"localforage": "^1.10.0",
"nostr-idb": "^5.1.0",
"oauth2-pkce": "^3.0.0",
"qrcode": "^1.5.4",
"rxjs": "^7.8.2"
}
+13
View File
@@ -0,0 +1,13 @@
diff --git a/async-arrow-task-transform.js b/async-arrow-task-transform.js
index 4eb856a2a7753ef583e10e07f2b602c0c1c0c21d..6a808d035834567dcfc491ecaa7f59e77d344887 100644
--- a/async-arrow-task-transform.js
+++ b/async-arrow-task-transform.js
@@ -331,7 +331,7 @@ function cleanupUnusedTaskFactoryImports(programPath, usedTaskFactories) {
}
module.exports = declare((api) => {
- api.assertVersion(7);
+ // api.assertVersion(7);
return {
name: 'transform-ember-concurrency-async-function-tasks',
+4334 -3179
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
core-js: true
esbuild: true
patchedDependencies:
ember-concurrency@5.2.0: patches/ember-concurrency@5.2.0.patch
@@ -1 +0,0 @@
!function(){"use strict";var t=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],e=(e,a)=>{var o="";for(let r=1;r<=a;r++){let h=Math.floor(e)/Math.pow(83,a-r)%83;o+=t[Math.floor(h)]}return o},a=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},o=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(12.92*e*255+.5):Math.trunc(255*(1.055*Math.pow(e,.4166666666666667)-.055)+.5)},r=(t,e)=>(t=>t<0?-1:1)(t)*Math.pow(Math.abs(t),e),h=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},i=(t,e,o,r)=>{let h=0,i=0,n=0,s=4*e;for(let g=0;g<e;g++){let e=4*g;for(let l=0;l<o;l++){let o=e+l*s,c=r(g,l);h+=c*a(t[o]),i+=c*a(t[o+1]),n+=c*a(t[o+2])}}let l=1/(e*o);return[h*l,i*l,n*l]};self.onmessage=async t=>{if("PROCESS_IMAGE"!==t.data?.type)return;const{id:a,file:n,targetWidth:s,targetHeight:l,quality:g,computeBlurhash:c}=t.data;try{let t,M;try{const e=await createImageBitmap(n,{resizeWidth:s,resizeHeight:l,resizeQuality:"high"});if(t=new OffscreenCanvas(s,l),M=t.getContext("2d"),!M)throw new Error("Failed to get 2d context from OffscreenCanvas");M.drawImage(e,0,0,s,l),e.close()}catch(f){console.warn("Hardware resize failed, falling back to stepped software scaling:",f);const e=await n.arrayBuffer(),a=new Blob([e],{type:n.type}),o=await createImageBitmap(a);let r=o.width,h=o.height,i=new OffscreenCanvas(r,h),g=i.getContext("2d");for(g.imageSmoothingEnabled=!0,g.imageSmoothingQuality="high",g.drawImage(o,0,0);.5*i.width>s&&.5*i.height>l;){const t=new OffscreenCanvas(Math.floor(.5*i.width),Math.floor(.5*i.height)),e=t.getContext("2d");e.imageSmoothingEnabled=!0,e.imageSmoothingQuality="high",e.drawImage(i,0,0,t.width,t.height),i=t}t=new OffscreenCanvas(s,l),M=t.getContext("2d"),M.imageSmoothingEnabled=!0,M.imageSmoothingQuality="high",M.drawImage(i,0,0,s,l),o.close()}let d=null;if(c)try{d=((t,a,n)=>{if(a*n*4!==t.length)throw new h("Width and height must match the pixels array");let s=[];for(let e=0;e<3;e++)for(let o=0;o<4;o++){let r=0==o&&0==e?1:2,h=i(t,a,n,(t,h)=>r*Math.cos(Math.PI*o*t/a)*Math.cos(Math.PI*e*h/n));s.push(h)}let l,g=s[0],c=s.slice(1),f="";if(f+=e(21,1),c.length>0){let t=Math.max(...c.map(t=>Math.max(...t))),a=Math.floor(Math.max(0,Math.min(82,Math.floor(166*t-.5))));l=(a+1)/166,f+=e(a,1)}else l=1,f+=e(0,1);return f+=e((t=>(o(t[0])<<16)+(o(t[1])<<8)+o(t[2]))(g),4),c.forEach(t=>{f+=e(((t,e)=>19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[0]/e,.5)+9.5))))*19+19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[1]/e,.5)+9.5))))+Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[2]/e,.5)+9.5)))))(t,l),2)}),f})(M.getImageData(0,0,s,l).data,s,l)}catch(m){console.warn("Could not generate blurhash (possible canvas fingerprinting protection):",m)}const u=await t.convertToBlob({type:"image/jpeg",quality:g}),w=`${s}x${l}`;self.postMessage({id:a,success:!0,blob:u,dim:w,blurhash:d})}catch(M){self.postMessage({id:a,success:!1,error:M.message})}}}();
+182
View File
@@ -0,0 +1,182 @@
var q = [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"#",
"$",
"%",
"*",
"+",
",",
"-",
".",
":",
";",
"=",
"?",
"@",
"[",
"]",
"^",
"_",
"{",
"|",
"}",
"~"
], p = (t, e) => {
var a = "";
for (let o = 1; o <= e; o++) {
let h = Math.floor(t) / Math.pow(83, e - o) % 83;
a += q[Math.floor(h)];
}
return a;
}, f = (t) => {
let e = t / 255;
return e <= .04045 ? e / 12.92 : Math.pow((e + .055) / 1.055, 2.4);
}, h = (t) => {
let e = Math.max(0, Math.min(1, t));
return e <= .0031308 ? Math.trunc(12.92 * e * 255 + .5) : Math.trunc(255 * (1.055 * Math.pow(e, .4166666666666667) - .055) + .5);
}, M = (t, e) => ((t) => t < 0 ? -1 : 1)(t) * Math.pow(Math.abs(t), e), d = class extends Error {
constructor(t) {
super(t), this.name = "ValidationError", this.message = t;
}
}, D = (t, e, a, o) => {
let h = 0, r = 0, n = 0, i = 4 * e;
for (let l = 0; l < e; l++) {
let e = 4 * l;
for (let s = 0; s < a; s++) {
let a = e + s * i, g = o(l, s);
h += g * f(t[a]), r += g * f(t[a + 1]), n += g * f(t[a + 2]);
}
}
let s = 1 / (e * a);
return [
h * s,
r * s,
n * s
];
}, S = (t, e, a, o, r) => {
if (o < 1 || o > 9 || r < 1 || r > 9) throw new d("BlurHash must have between 1 and 9 components");
if (e * a * 4 !== t.length) throw new d("Width and height must match the pixels array");
let n = [];
for (let h = 0; h < r; h++) for (let r = 0; r < o; r++) {
let o = 0 == r && 0 == h ? 1 : 2, i = D(t, e, a, (t, n) => o * Math.cos(Math.PI * r * t / e) * Math.cos(Math.PI * h * n / a));
n.push(i);
}
let i, s = n[0], l = n.slice(1), g = "";
if (g += p(o - 1 + 9 * (r - 1), 1), l.length > 0) {
let t = Math.max(...l.map((t) => Math.max(...t))), e = Math.floor(Math.max(0, Math.min(82, Math.floor(166 * t - .5))));
i = (e + 1) / 166, g += p(e, 1);
} else i = 1, g += p(0, 1);
return g += p(((t) => (h(t[0]) << 16) + (h(t[1]) << 8) + h(t[2]))(s), 4), l.forEach((t) => {
g += p(((t, e) => 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[0] / e, .5) + 9.5)))) * 19 + 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[1] / e, .5) + 9.5)))) + Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[2] / e, .5) + 9.5)))))(t, i), 2);
}), g;
};
self.onmessage = async (t) => {
if ("PROCESS_IMAGE" !== t.data?.type) return;
const { id: e, file: a, targetWidth: o, targetHeight: h, quality: r, computeBlurhash: n } = t.data;
try {
let t, l;
try {
const e = await createImageBitmap(a, {
resizeWidth: o,
resizeHeight: h,
resizeQuality: "high"
});
if (t = new OffscreenCanvas(o, h), l = t.getContext("2d"), !l) throw new Error("Failed to get 2d context from OffscreenCanvas");
l.drawImage(e, 0, 0, o, h), e.close();
} catch (i) {
console.warn("Hardware resize failed, falling back to stepped software scaling:", i);
const e = await a.arrayBuffer(), r = new Blob([e], { type: a.type }), n = await createImageBitmap(r);
let s = n.width, g = n.height, f = new OffscreenCanvas(s, g), c = f.getContext("2d");
for (c.imageSmoothingEnabled = !0, c.imageSmoothingQuality = "high", c.drawImage(n, 0, 0); .5 * f.width > o && .5 * f.height > h;) {
const t = new OffscreenCanvas(Math.floor(.5 * f.width), Math.floor(.5 * f.height)), e = t.getContext("2d");
e.imageSmoothingEnabled = !0, e.imageSmoothingQuality = "high", e.drawImage(f, 0, 0, t.width, t.height), f = t;
}
t = new OffscreenCanvas(o, h), l = t.getContext("2d"), l.imageSmoothingEnabled = !0, l.imageSmoothingQuality = "high", l.drawImage(f, 0, 0, o, h), n.close();
}
let g = null;
if (n) try {
const t = l.getImageData(0, 0, o, h);
g = S(t.data, o, h, 4, 3);
} catch (s) {
console.warn("Could not generate blurhash (possible canvas fingerprinting protection):", s);
}
const f = await t.convertToBlob({
type: "image/jpeg",
quality: r
}), c = `${o}x${h}`;
self.postMessage({
id: e,
success: !0,
blob: f,
dim: c,
blurhash: g
});
} catch (l) {
self.postMessage({
id: e,
success: !1,
error: l.message
});
}
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-DNM-h3Gw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BGF-Udec.css">
<script type="module" crossorigin src="/assets/main-D3dQ-CGf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-P9lvOtv_.css">
</head>
<body>
<div id="modal-portal"></div>
+208
View File
@@ -0,0 +1,208 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
class MockOsmService extends Service {
fetchOsmObjectCalls = [];
async fetchOsmObject(id, type) {
this.fetchOsmObjectCalls.push({ id, type });
return {
osmId: String(id),
osmType: type,
lat: 1,
lon: 1,
osmTags: { name: 'Test Place', amenity: 'cafe' },
title: 'Test Place',
};
}
getCachedOsmObject() {
return Promise.resolve(null);
}
}
class MockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [];
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
loadPlacesInBounds() {
return [];
}
get placesInView() {
return [];
}
rs = {
on: () => {},
};
}
class MockContributionsService extends Service {
@tracked items = [
{
type: 'photo',
placeIdentifier: 'osm:node:123',
osmType: 'node',
osmId: '123',
placeName: 'Test Place',
placeNameLoading: false,
createdAt: 2000,
photos: [
{
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
tags: ['food'],
},
],
},
];
async load() {}
stop() {
this.items = [];
}
}
class MockNostrAuthService extends Service {
@tracked pubkey = 'test-pubkey';
get isConnected() {
return true;
}
}
module('Acceptance | contributions navigation', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:osm', MockOsmService);
this.owner.register('service:storage', MockStorageService);
this.owner.register('service:contributions', MockContributionsService);
this.owner.register('service:nostrAuth', MockNostrAuthService);
});
test('navigating from menu to contributions and back to menu', async function (assert) {
await visit('/');
assert.strictEqual(currentURL(), '/');
// Open the app menu
await click('.menu-btn-integrated');
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
// Click "My Contributions"
const buttons = document.querySelectorAll('.app-menu button');
const contributionsBtn = Array.from(buttons).find((b) =>
b.textContent.includes('My Contributions')
);
await click(contributionsBtn);
assert.strictEqual(
currentURL(),
'/contributions',
'Transitions to /contributions'
);
assert
.dom('.sidebar-header-text-centered')
.includesText('My Contributions', 'Header shows the title');
// Contribution item should be visible
await waitFor('.contribution-item');
assert.dom('.contribution-place').includesText('Test Place');
// Click back button -> returns to menu
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/menu', 'Goes back to /menu');
});
test('clicking a contribution opens place details and back returns to contributions', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
await visit('/contributions');
await waitFor('.contribution-item');
// Click the contribution item
await click('.contribution-item');
assert.ok(
currentURL().includes('/place/osm:node:123'),
'Transitions to place details'
);
assert.deepEqual(
mapUi.returnToRoute,
{ name: 'contributions' },
'returnToRoute is set to contributions'
);
// Click back from place details
await click('.back-btn');
assert.strictEqual(
currentURL(),
'/contributions',
'Returns to contributions list'
);
});
test('closing the contributions sidebar returns to index', async function (assert) {
await visit('/contributions');
await waitFor('.sidebar');
await click('.sidebar-header .close-btn');
assert.strictEqual(currentURL(), '/', 'Returns to index');
});
test('contributions route saves scroll position when selecting a place', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
await visit('/contributions');
await waitFor('.contribution-item');
// The scroll position key should start at 0
assert.strictEqual(
mapUi.getScrollPosition('contributions'),
0,
'Initial scroll position is 0'
);
await click('.contribution-item');
// After navigating to a place, the scroll position should have been saved
// (0 in this case since the sidebar wasn't scrolled, but the key should exist)
assert.ok(
mapUi.returnToRoute,
'returnToRoute is set when selecting a contribution'
);
});
test('selecting a contribution runs the model hook so the place has lat/lon', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
const osm = this.owner.lookup('service:osm');
await visit('/contributions');
await waitFor('.contribution-item');
await click('.contribution-item');
assert.ok(
currentURL().includes('/place/osm:node:123'),
'Transitions to place details via URL'
);
// The model hook should have called fetchOsmObject (not skipped it)
assert.ok(
osm.fetchOsmObjectCalls.length > 0,
'OSM fetchOsmObject was called (model hook ran)'
);
// The selected place should have lat/lon so the map can pan
assert.ok(
mapUi.selectedPlace?.lat && mapUi.selectedPlace?.lon,
'Selected place has lat/lon coordinates'
);
});
});
+4
View File
@@ -4,6 +4,7 @@ import {
setupTest as upstreamSetupTest,
} from 'ember-qunit';
import { setupNostrMocks } from './mock-nostr';
import { setupLocalForageMock } from './mock-local-forage';
import sinon from 'sinon';
function setupMapStyleMocks(hooks) {
@@ -98,6 +99,7 @@ function setupMapStyleMocks(hooks) {
function setupApplicationTest(hooks, options) {
upstreamSetupApplicationTest(hooks, options);
setupNostrMocks(hooks);
setupLocalForageMock(hooks);
setupMapStyleMocks(hooks);
// Additional setup for application tests can be done here.
@@ -119,6 +121,7 @@ function setupApplicationTest(hooks, options) {
function setupRenderingTest(hooks, options) {
upstreamSetupRenderingTest(hooks, options);
setupNostrMocks(hooks);
setupLocalForageMock(hooks);
// Additional setup for rendering tests can be done here.
}
@@ -126,6 +129,7 @@ function setupRenderingTest(hooks, options) {
function setupTest(hooks, options) {
upstreamSetupTest(hooks, options);
setupNostrMocks(hooks);
setupLocalForageMock(hooks);
// Additional setup for unit tests can be done here.
}
+59
View File
@@ -0,0 +1,59 @@
import Service from '@ember/service';
/**
* In-memory mock of the `localForage` service for tests. Uses per-store
* `Map`s so tests stay isolated from real IndexedDB state and stay
* deterministic.
*
* Mirrors the real service's interface: `get/set/remove/keys/clear/iterate`.
*/
export class MockLocalForageService extends Service {
_stores = new Map();
_store(name) {
let store = this._stores.get(name);
if (!store) {
store = new Map();
this._stores.set(name, store);
}
return store;
}
async get(storeName, key) {
const store = this._store(storeName);
return store.has(key) ? store.get(key) : null;
}
async set(storeName, key, value) {
this._store(storeName).set(key, value);
return value;
}
async remove(storeName, key) {
this._store(storeName).delete(key);
}
async keys(storeName) {
return [...this._store(storeName).keys()];
}
async clear(storeName) {
this._store(storeName).clear();
}
async iterate(storeName, fn) {
let result;
let idx = 0;
for (const [key, value] of this._store(storeName)) {
result = fn(value, key, idx);
idx++;
}
return result;
}
}
export function setupLocalForageMock(hooks) {
hooks.beforeEach(function () {
this.owner.register('service:localForage', MockLocalForageService);
});
}
@@ -0,0 +1,185 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click, settled } from '@ember/test-helpers';
import ContributionPhoto from 'marco/components/contribution-photo';
import { ContributionEntry } from 'marco/utils/contributions';
function noop() {}
module('Integration | Component | contribution-photo', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.noop = noop;
});
test('it renders the place name and thumbnail', async function (assert) {
this.item = {
type: 'photo',
placeIdentifier: 'osm:node:12345',
osmType: 'node',
osmId: '12345',
placeName: 'Café Example',
placeNameLoading: false,
createdAt: Math.floor(Date.now() / 1000) - 60 * 60,
photos: [
{
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
alt: 'A nice café',
tags: ['food'],
},
],
};
await render(
<template>
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.contribution-place').hasText('Café Example');
assert
.dom('.contribution-thumb img')
.hasAttribute('src', 'https://x.com/thumb.jpg');
assert.dom('.contribution-tag').hasText('food');
assert.dom('.contribution-thumb-badge').doesNotExist();
});
test('it shows a +N badge when there are multiple photos', async function (assert) {
this.item = {
type: 'photo',
placeIdentifier: 'osm:node:12345',
osmType: 'node',
osmId: '12345',
placeName: 'Park',
placeNameLoading: false,
createdAt: 1000,
photos: [
{
url: 'https://x.com/1.jpg',
thumbUrl: 'https://x.com/t1.jpg',
tags: [],
},
{
url: 'https://x.com/2.jpg',
thumbUrl: 'https://x.com/t2.jpg',
tags: [],
},
{
url: 'https://x.com/3.jpg',
thumbUrl: 'https://x.com/t3.jpg',
tags: [],
},
],
};
await render(
<template>
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.contribution-thumb-badge').hasText('+2');
});
test('it shows a loading label when placeNameLoading is true', async function (assert) {
this.item = {
type: 'photo',
placeIdentifier: 'osm:node:12345',
osmType: 'node',
osmId: '12345',
placeName: null,
placeNameLoading: true,
createdAt: 1000,
photos: [
{
url: 'https://x.com/1.jpg',
thumbUrl: 'https://x.com/t1.jpg',
tags: [],
},
],
};
await render(
<template>
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.contribution-name-loading').hasText('Loading…');
});
test('clicking the item fires @onSelect with the item', async function (assert) {
this.item = {
type: 'photo',
placeIdentifier: 'osm:node:12345',
osmType: 'node',
osmId: '12345',
placeName: 'Test',
placeNameLoading: false,
createdAt: 1000,
photos: [
{
url: 'https://x.com/1.jpg',
thumbUrl: 'https://x.com/t1.jpg',
tags: [],
},
],
};
let selected = null;
this.handleSelect = (item) => {
selected = item;
};
await render(
<template>
<ContributionPhoto
@item={{this.item}}
@onSelect={{this.handleSelect}}
/>
</template>
);
await click('.contribution-item');
assert.strictEqual(selected, this.item);
});
test('it updates the place name when a tracked entry resolves after render', async function (assert) {
// Regression: when a place name is resolved by the background batch fetch
// after the entry has already been rendered as "Loading…", the component
// must re-render with the resolved name. This requires the entry's
// placeName/placeNameLoading to be tracked.
this.item = new ContributionEntry({
placeIdentifier: 'osm:node:12345',
osmType: 'node',
osmId: '12345',
createdAt: 1000,
photos: [
{
url: 'https://x.com/1.jpg',
thumbUrl: 'https://x.com/t1.jpg',
tags: [],
},
],
});
await render(
<template>
<ContributionPhoto @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.contribution-name-loading').hasText('Loading…');
// Simulate the contributions service resolving the name after the batch fetch
this.item.placeName = 'Resolved Café';
this.item.placeNameLoading = false;
await settled();
assert.dom('.contribution-place').hasText('Resolved Café');
assert.dom('.contribution-name-loading').doesNotExist();
});
});
@@ -0,0 +1,175 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import Service from '@ember/service';
import ContributionsTimeline from 'marco/components/contributions-timeline';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
function noop() {}
class MockToastService extends Service {
show() {}
}
module('Integration | Component | contributions-timeline', function (hooks) {
setupRenderingTest(hooks);
setupNostrMocks(hooks);
hooks.beforeEach(function () {
this.owner.register('service:toast', MockToastService);
this.noop = noop;
this.emptyItems = [];
});
test('it renders a loading state', async function (assert) {
await render(
<template>
<ContributionsTimeline
@items={{this.emptyItems}}
@isLoading={{true}}
@isConnected={{true}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onSelect={{this.noop}}
/>
</template>
);
assert.dom('.sidebar-loading').exists();
assert.dom('.contributions-list').doesNotExist();
});
test('it renders a not-connected state', async function (assert) {
await render(
<template>
<ContributionsTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onSelect={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('Connect your Nostr account');
});
test('clicking "Connect your Nostr account" opens the Nostr connect modal', async function (assert) {
await render(
<template>
<div id="modal-portal"></div>
<ContributionsTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onSelect={{this.noop}}
/>
</template>
);
assert.dom('.nostr-connect-modal').doesNotExist();
await click('.empty-state a');
assert.dom('.nostr-connect-modal').exists();
});
test('it renders an empty state when connected but no contributions', async function (assert) {
await render(
<template>
<ContributionsTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onSelect={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('No contributions yet');
});
test('it renders contribution items', async function (assert) {
this.items = [
{
type: 'photo',
placeIdentifier: 'osm:node:111',
osmType: 'node',
osmId: '111',
placeName: 'Café One',
placeNameLoading: false,
createdAt: 2000,
photos: [
{
url: 'https://x.com/1.jpg',
thumbUrl: 'https://x.com/t1.jpg',
tags: ['food'],
},
],
},
{
type: 'photo',
placeIdentifier: 'osm:node:222',
osmType: 'node',
osmId: '222',
placeName: 'Park Two',
placeNameLoading: false,
createdAt: 1000,
photos: [
{
url: 'https://x.com/2.jpg',
thumbUrl: 'https://x.com/t2.jpg',
tags: [],
},
],
},
];
await render(
<template>
<ContributionsTimeline
@items={{this.items}}
@isLoading={{false}}
@isConnected={{true}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onSelect={{this.noop}}
/>
</template>
);
assert.dom('.contribution-item').exists({ count: 2 });
assert.dom(this.element).includesText('Café One');
assert.dom(this.element).includesText('Park Two');
});
test('clicking the back button fires @onBack', async function (assert) {
let backClicked = false;
this.handleBack = () => {
backClicked = true;
};
await render(
<template>
<ContributionsTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@onBack={{this.handleBack}}
@onClose={{this.noop}}
@onSelect={{this.noop}}
/>
</template>
);
await click('.sidebar-header .back-btn');
assert.true(backClicked);
});
});
@@ -6,6 +6,9 @@ import PhotoGallery from 'marco/components/photo-gallery';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
import sinon from 'sinon';
const USER_A = 'a'.repeat(64);
const USER_B = 'b'.repeat(64);
class MockBlossomService extends Service {
async delete() {
return true;
@@ -34,7 +37,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
this.photos = [
{
eventId: 'event1',
pubkey: 'userA',
pubkey: USER_A,
placeIdentifier: 'osm:node:12345',
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
thumbUrl:
@@ -42,7 +45,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
},
{
eventId: 'event2',
pubkey: 'userB',
pubkey: USER_B,
placeIdentifier: 'osm:node:12345',
url: 'photo2.jpg',
},
@@ -55,7 +58,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
});
test('it does not show delete button if user is not creator', async function (assert) {
this.nostrAuth.pubkey = 'userB'; // Different from photo1's pubkey
this.nostrAuth.pubkey = USER_B; // Different from photo1's pubkey
this.selectedPhoto = this.photos[0];
await render(
@@ -80,7 +83,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
});
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
this.nostrAuth.pubkey = USER_A; // Matches photo1's pubkey
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
this.selectedPhoto = this.photos[0];
@@ -106,7 +109,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
});
test('it handles cancellation of deletion', async function (assert) {
this.nostrAuth.pubkey = 'userA';
this.nostrAuth.pubkey = USER_A;
this.settings.update('experimentalEnablePhotoDeletion', true);
this.selectedPhoto = this.photos[0];
@@ -133,7 +136,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
});
test('it performs full deletion flow when confirmed', async function (assert) {
this.nostrAuth.pubkey = 'userA';
this.nostrAuth.pubkey = USER_A;
this.settings.update('experimentalEnablePhotoDeletion', true);
// Override the mock's getter just for this test
Object.defineProperty(this.nostrAuth, 'signer', {
@@ -141,11 +144,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
get: () => ({
signEvent: async (e) => ({
...e,
id: 'signed-id',
sig: 'sig',
pubkey: 'userA',
id: 'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
sig: 'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
pubkey: USER_A,
}),
getPublicKey: async () => 'userA',
getPublicKey: async () => USER_A,
}),
});
this.selectedPhoto = this.photos[0];
@@ -227,7 +230,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
});
test('it copies event id to clipboard', async function (assert) {
this.nostrAuth.pubkey = 'userA';
this.nostrAuth.pubkey = USER_A;
this.selectedPhoto = this.photos[0];
const clipboardStub = sinon
@@ -320,14 +320,14 @@ module('Integration | Component | place-details', function (hooks) {
const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]');
assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links');
// Verify it stripped the dashes and spaces for the wa.me URL
// Verify it stripped the dashes, spaces and leading plus for the wa.me URL
assert.strictEqual(
links[0].getAttribute('href'),
'https://wa.me/+44987654321'
'https://wa.me/44987654321'
);
assert.strictEqual(
links[1].getAttribute('href'),
'https://wa.me/+12345678900'
'https://wa.me/12345678900'
);
// Verify it kept the dashes and spaces for the visible text
@@ -335,6 +335,33 @@ module('Integration | Component | place-details', function (hooks) {
assert.dom(links[1]).hasText('+1 234-567 8900');
});
test('it strips parentheses, dots and the leading plus from whatsapp hrefs', async function (assert) {
const place = {
title: 'Chat Shop',
osmTags: {
whatsapp: '+504-9850-3802;(504) 9850.3802;+1.234.567.8900',
},
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const links = this.element.querySelectorAll('a[href^="https://wa.me/"]');
assert.strictEqual(links.length, 3, 'Rendered exactly 3 WhatsApp links');
assert.strictEqual(
links[0].getAttribute('href'),
'https://wa.me/50498503802'
);
assert.strictEqual(
links[1].getAttribute('href'),
'https://wa.me/50498503802'
);
assert.strictEqual(
links[2].getAttribute('href'),
'https://wa.me/12345678900'
);
});
test('it renders correct OpenStreetMap link for an OSM place', async function (assert) {
const place = {
title: 'OSM Place',
@@ -97,4 +97,27 @@ module('Integration | Component | place-photo-upload', function (hooks) {
assert.dom('.photo-tag-suggestions').doesNotExist();
});
test('it renders an optional alt text input after upload selection', async function (assert) {
this.place = {
title: 'Cafe Alpha',
osmId: '123',
osmType: 'node',
osmTags: { amenity: 'cafe' },
};
await render(
<template><PlacePhotoUpload @place={{this.place}} /></template>
);
assert.dom('#photo-alt-input').doesNotExist();
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
await selectFile(this.element, file);
assert.dom('#photo-alt-input').exists();
assert
.dom('#photo-alt-input')
.hasAttribute('placeholder', 'Describe this photo');
});
});
+277
View File
@@ -0,0 +1,277 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import Service from '@ember/service';
function makePhotoEvent({
id,
created_at,
placeIdentifier = 'osm:node:123',
url = 'https://example.com/photo.jpg',
}) {
return {
id,
pubkey: 'pubkey-1',
kind: 360,
created_at,
tags: [
['i', placeIdentifier],
['imeta', `url ${url}`, 'dim 800x600'],
],
};
}
class MockStorageService extends Service {
savedPlaces = [];
findPlaceById() {
return null;
}
}
class MockNostrDataService extends Service {
store = {
timeline() {
return {
subscribe() {
return { unsubscribe() {} };
},
};
},
};
async loadMyContributions() {}
}
class MockOsmService extends Service {
async getCachedOsmObject() {
return null;
}
async fetchOsmObjectsBatch() {
return new Map();
}
}
const NAME_CACHE_STORE = 'contributions-name-cache';
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
module('Unit | Service | contributions', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:storage', MockStorageService);
this.owner.register('service:nostrData', MockNostrDataService);
this.owner.register('service:osm', MockOsmService);
});
test('_updateItems resolves names from bookmarks immediately', function (assert) {
const events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
const service = this.owner.lookup('service:contributions');
service.storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
service.storage.findPlaceById = (id) =>
id === '123' ? { id: '123', title: 'Bookmarked Café' } : null;
service._updateItems(events);
assert.strictEqual(service.items.length, 1);
assert.false(service.items[0].placeNameLoading, 'Not loading');
assert.strictEqual(
service.items[0].placeName,
'Bookmarked Café',
'Resolved from bookmark'
);
});
test('_updateItems resolves names from the persistent name cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:999',
}),
];
const service = this.owner.lookup('service:contributions');
await service.localForage.set(
NAME_CACHE_STORE,
'osm:node:999',
'Cached Park'
);
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items[0].placeName, 'Cached Park');
assert.false(service.items[0].placeNameLoading);
});
test('resolved names are persisted to the IndexedDB name cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:42',
}),
];
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:42', { title: 'Test Place' });
return map;
};
service._updateItems(events);
await flushPromises();
assert.strictEqual(
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:42'),
'Test Place',
'Name is persisted to the IndexedDB name cache'
);
});
test('names stored in a previous session are resolved from the IndexedDB cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:77',
}),
];
const service = this.owner.lookup('service:contributions');
await service.localForage.set(
NAME_CACHE_STORE,
'osm:node:77',
'Persisted Place'
);
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items[0].placeName, 'Persisted Place');
assert.false(service.items[0].placeNameLoading);
});
test('batch fetch failure applies a fallback name so items do not stay loading', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:555',
}),
];
const service = this.owner.lookup('service:contributions');
// OSM batch returns empty (simulating all-failed fetch)
service.osm.fetchOsmObjectsBatch = async () => new Map();
service._updateItems(events);
// Wait for the background batch to complete
await flushPromises();
assert.false(
service.items[0].placeNameLoading,
'Item is no longer loading after batch failure'
);
assert.strictEqual(
service.items[0].placeName,
'OSM node 555',
'Fallback name is applied'
);
});
test('fallback names are NOT persisted to the name cache (so they can be re-fetched next session)', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:666',
}),
];
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => new Map();
service._updateItems(events);
await flushPromises();
// Fallback should be shown but NOT cached
assert.strictEqual(service.items[0].placeName, 'OSM node 666');
assert.notOk(
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:666'),
'No fallback name persisted in the IndexedDB name cache'
);
assert.true(
service._unresolvable.has('osm:node:666'),
'Item is marked unresolvable for this session'
);
});
test('unresolvable items are not re-fetched within the same session', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:777',
}),
];
let fetchCount = 0;
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
return new Map();
};
// First update triggers a fetch
service._updateItems(events);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'First update triggers a fetch');
assert.strictEqual(service.items[0].placeName, 'OSM node 777');
// Second update should NOT trigger another fetch (already unresolvable)
service._updateItems(events);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'Second update does not re-fetch');
assert.strictEqual(
service.items[0].placeName,
'OSM node 777',
'Fallback is shown immediately'
);
});
test('successful batch resolution stores names in the name cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:111',
}),
];
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:111', { title: 'Resolved Café' });
return map;
};
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
assert.strictEqual(
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:111'),
'Resolved Café',
'Name is stored in the name cache'
);
});
});
+201
View File
@@ -292,4 +292,205 @@ module('Unit | Service | osm', function (hooks) {
'Call with different lat/lon should trigger fetch'
);
});
test('_storeInMemoryAndLocalStorage rejects entries without a title', function (assert) {
let service = this.owner.lookup('service:osm');
service._storeInMemoryAndLocalStorage('node:1', 'node', '1', {
title: null,
lat: 1,
lon: 1,
});
assert.false(
service.cachedPlaces.has('node:1'),
'Does not cache entries without a title'
);
service._storeInMemoryAndLocalStorage('node:2', 'node', '2', null);
assert.false(
service.cachedPlaces.has('node:2'),
'Does not cache null entries'
);
});
test('_storeInMemoryAndLocalStorage accepts entries with a title', function (assert) {
let service = this.owner.lookup('service:osm');
service._storeInMemoryAndLocalStorage('node:3', 'node', '3', {
title: 'Café Example',
lat: 52.5,
lon: 13.4,
});
assert.true(
service.cachedPlaces.has('node:3'),
'Caches entries with a title'
);
});
test('fetchOsmObjectsBatch does not write to the general OSM cache', async function (assert) {
let service = this.owner.lookup('service:osm');
service.fetchWithRetry = async () => ({
ok: true,
json: async () => ({
elements: [
{ id: 100, type: 'node', lat: 1, lon: 2, tags: { name: 'Node 100' } },
],
}),
});
await service.fetchOsmObjectsBatch([{ osmType: 'node', osmId: '100' }]);
assert.false(
service.cachedPlaces.has('node:100'),
'Batch fetch does not write to the in-memory OSM cache'
);
assert.notOk(
await service.localForage.get('osm-cache', 'node:100'),
'Batch fetch does not write to the persistent OSM cache'
);
});
test('fetchOsmObjectsBatch returns resolved places in the result Map', async function (assert) {
let service = this.owner.lookup('service:osm');
service.fetchWithRetry = async () => ({
ok: true,
json: async () => ({
elements: [
{ id: 200, type: 'node', lat: 1, lon: 2, tags: { name: 'Café 200' } },
],
}),
});
const result = await service.fetchOsmObjectsBatch([
{ osmType: 'node', osmId: '200' },
]);
assert.true(result.has('node:200'), 'Result contains the fetched place');
assert.strictEqual(
result.get('node:200').title,
'Café 200',
'Result has the correct title'
);
});
test('fetchOsmObjectsBatch uses .json suffix in the API URL', async function (assert) {
let service = this.owner.lookup('service:osm');
let capturedUrls = [];
service.fetchWithRetry = async (url) => {
capturedUrls.push(url);
return {
ok: true,
json: async () => ({
elements: [
{ id: 300, type: 'node', lat: 1, lon: 2, tags: { name: 'Test' } },
],
}),
};
};
await service.fetchOsmObjectsBatch([{ osmType: 'node', osmId: '300' }]);
assert.true(
capturedUrls.some((url) => url.includes('.json')),
'Batch fetch URL includes .json suffix'
);
});
test('fetchOsmObjectsBatch uses correct pluralized endpoints', async function (assert) {
let service = this.owner.lookup('service:osm');
let capturedUrls = [];
service.fetchWithRetry = async (url) => {
capturedUrls.push(url);
return {
ok: true,
json: async () => ({ elements: [] }),
};
};
await service.fetchOsmObjectsBatch([
{ osmType: 'node', osmId: '1' },
{ osmType: 'way', osmId: '2' },
{ osmType: 'relation', osmId: '3' },
]);
assert.true(
capturedUrls.some((u) => u.includes('/nodes.json?nodes=')),
'Uses nodes.json endpoint'
);
assert.true(
capturedUrls.some((u) => u.includes('/ways.json?ways=')),
'Uses ways.json endpoint'
);
assert.true(
capturedUrls.some((u) => u.includes('/relations.json?relations=')),
'Uses relations.json endpoint'
);
});
test('fetchOsmObject with forceFresh bypasses the cache and returns fresh data', async function (assert) {
let service = this.owner.lookup('service:osm');
// Seed both caches so we can prove forceFresh skips them.
service.cachedPlaces.set('node:5', {
data: { title: 'Stale In-Memory', lat: 1, lon: 1 },
timestamp: Date.now(),
});
await service.localForage.set('osm-cache', 'node:5', {
data: { title: 'Stale IndexedDB', lat: 1, lon: 1 },
timestamp: Date.now(),
});
service.fetchWithRetry = async () => ({
ok: true,
json: async () => ({
elements: [
{
id: 5,
type: 'node',
lat: 2,
lon: 3,
tags: { name: 'Fresh From API' },
},
],
}),
});
const result = await service.fetchOsmObject('5', 'node', {
forceFresh: true,
});
assert.strictEqual(
result.title,
'Fresh From API',
'Returns fresh API data, not cached data'
);
assert.strictEqual(result.lat, 2);
assert.strictEqual(result.lon, 3);
});
test('fetchOsmObject without forceFresh returns the in-memory cache entry without hitting the API', async function (assert) {
let service = this.owner.lookup('service:osm');
service.cachedPlaces.set('node:6', {
data: { title: 'Warm In-Memory', lat: 4, lon: 5 },
timestamp: Date.now(),
});
let fetchCalled = 0;
service.fetchWithRetry = async () => {
fetchCalled++;
return { ok: true, json: async () => ({ elements: [] }) };
};
const result = await service.fetchOsmObject('6', 'node');
assert.strictEqual(fetchCalled, 0, 'API was not hit');
assert.strictEqual(result.title, 'Warm In-Memory');
});
});
+7 -1
View File
@@ -15,8 +15,10 @@ module('Unit | Service | storage', function (hooks) {
let service = this.owner.lookup('service:storage');
// Stub OSM Service
let capturedOptions;
class OsmStub extends Service {
async fetchOsmObject(id, type) {
async fetchOsmObject(id, type, options) {
capturedOptions = options;
return {
osmId: id,
osmType: type,
@@ -49,6 +51,10 @@ module('Unit | Service | storage', function (hooks) {
assert.ok(updatePlaceCalled, 'updatePlace should be called');
assert.strictEqual(result.lat, 52.5201, 'Latitude updated');
assert.ok(
capturedOptions?.forceFresh,
'refreshPlace fetches fresh OSM data (forceFresh: true)'
);
});
test('refreshPlace ignores tiny coordinate drift', async function (assert) {
+262
View File
@@ -0,0 +1,262 @@
import { module, test } from 'qunit';
import { groupPhotoContributions } from 'marco/utils/contributions';
function makePhotoEvent({
id,
created_at,
placeIdentifier = 'osm:node:123',
url = 'https://example.com/photo.jpg',
tags = [],
}) {
return {
id,
pubkey: 'pubkey-1',
kind: 360,
created_at,
tags: [
['i', placeIdentifier],
['imeta', `url ${url}`, 'dim 800x600'],
...tags.map((t) => ['t', t]),
],
};
}
function makeDeletionEvent({ id, targetId }) {
return {
id,
pubkey: 'pubkey-1',
kind: 5,
created_at: 999,
tags: [['e', targetId]],
};
}
module('Unit | Utility | contributions', function () {
test('groupPhotoContributions returns empty for no events', function (assert) {
assert.deepEqual(groupPhotoContributions([]), []);
assert.deepEqual(groupPhotoContributions(null), []);
});
test('groupPhotoContributions creates one entry per event for the same place when spread out', function (assert) {
// Two events 10 hours apart for the same place -> two entries
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
url: 'https://x.com/1.jpg',
}),
makePhotoEvent({
id: 'e2',
created_at: 1000 + 10 * 60 * 60,
url: 'https://x.com/2.jpg',
}),
];
const entries = groupPhotoContributions(events);
assert.strictEqual(entries.length, 2);
// Newest first
assert.strictEqual(entries[0].photos[0].url, 'https://x.com/2.jpg');
assert.strictEqual(entries[1].photos[0].url, 'https://x.com/1.jpg');
});
test('groupPhotoContributions groups events for the same place within the threshold', function (assert) {
// Two events 30 minutes apart -> one entry with two photos
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
url: 'https://x.com/1.jpg',
}),
makePhotoEvent({
id: 'e2',
created_at: 1000 + 30 * 60,
url: 'https://x.com/2.jpg',
}),
];
const entries = groupPhotoContributions(events);
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].photos.length, 2);
assert.strictEqual(entries[0].eventCount, 2);
// createdAt = newest event's created_at
assert.strictEqual(entries[0].createdAt, 1000 + 30 * 60);
});
test('groupPhotoContributions orders photos within a group oldest-first', function (assert) {
// Three events within the threshold for the same place
const events = [
makePhotoEvent({
id: 'e3',
created_at: 3000,
url: 'https://x.com/3.jpg',
}),
makePhotoEvent({
id: 'e1',
created_at: 1000,
url: 'https://x.com/1.jpg',
}),
makePhotoEvent({
id: 'e2',
created_at: 2000,
url: 'https://x.com/2.jpg',
}),
];
const entries = groupPhotoContributions(events);
assert.strictEqual(entries.length, 1);
assert.strictEqual(
entries[0].photos[0].url,
'https://x.com/1.jpg',
'First photo is the oldest'
);
assert.strictEqual(
entries[0].photos[1].url,
'https://x.com/2.jpg',
'Second photo is the middle'
);
assert.strictEqual(
entries[0].photos[2].url,
'https://x.com/3.jpg',
'Third photo is the newest'
);
});
test('groupPhotoContributions groups by OSM entity identifier', function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:111',
url: 'https://x.com/a.jpg',
}),
makePhotoEvent({
id: 'e2',
created_at: 1000,
placeIdentifier: 'osm:node:222',
url: 'https://x.com/b.jpg',
}),
];
const entries = groupPhotoContributions(events);
assert.strictEqual(entries.length, 2);
});
test('groupPhotoContributions sorts entries newest-first', function (assert) {
const events = [
makePhotoEvent({
id: 'old',
created_at: 100,
placeIdentifier: 'osm:node:111',
}),
makePhotoEvent({
id: 'new',
created_at: 200,
placeIdentifier: 'osm:node:222',
}),
makePhotoEvent({
id: 'mid',
created_at: 150,
placeIdentifier: 'osm:node:333',
}),
];
const entries = groupPhotoContributions(events);
assert.strictEqual(entries[0].placeIdentifier, 'osm:node:222');
assert.strictEqual(entries[1].placeIdentifier, 'osm:node:333');
assert.strictEqual(entries[2].placeIdentifier, 'osm:node:111');
});
test('groupPhotoContributions respects custom threshold hours', function (assert) {
// Events 2 hours apart. With threshold 1, they are separate; with 3, they group.
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
url: 'https://x.com/1.jpg',
}),
makePhotoEvent({
id: 'e2',
created_at: 1000 + 2 * 60 * 60,
url: 'https://x.com/2.jpg',
}),
];
assert.strictEqual(groupPhotoContributions(events, 1).length, 2);
assert.strictEqual(groupPhotoContributions(events, 3).length, 1);
});
test('groupPhotoContributions parses place identifier into osmType and osmId', function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:way:98765',
}),
];
const entries = groupPhotoContributions(events);
assert.strictEqual(entries[0].osmType, 'way');
assert.strictEqual(entries[0].osmId, '98765');
});
test('groupPhotoContributions attaches t tags to photos', function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
tags: ['food', 'vibe'],
}),
];
const entries = groupPhotoContributions(events);
assert.deepEqual(entries[0].photos[0].tags, ['food', 'vibe']);
});
test('groupPhotoContributions excludes events deleted by kind 5', function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
url: 'https://x.com/1.jpg',
}),
makePhotoEvent({
id: 'e2',
created_at: 2000,
url: 'https://x.com/2.jpg',
}),
makeDeletionEvent({ id: 'd1', targetId: 'e1' }),
];
const entries = groupPhotoContributions(events);
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].photos[0].url, 'https://x.com/2.jpg');
});
test('groupPhotoContributions skips events without an i tag', function (assert) {
const events = [
{
id: 'no-i',
pubkey: 'pubkey-1',
kind: 360,
created_at: 1000,
tags: [['imeta', 'url https://x.com/1.jpg', 'dim 800x600']],
},
];
const entries = groupPhotoContributions(events);
assert.strictEqual(entries.length, 0);
});
test('groupPhotoContributions skips events without a usable imeta', function (assert) {
const events = [makePhotoEvent({ id: 'e1', created_at: 1000, url: null })];
// The helper always sets a URL, so override manually
events[0].tags = [
['i', 'osm:node:123'],
['imeta', 'dim 800x600'],
];
const entries = groupPhotoContributions(events);
assert.strictEqual(entries.length, 0);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { module, test } from 'qunit';
import {
formatRelativeDate,
humanizeOsmTag,
capitalize,
} from 'marco/utils/format-text';
module('Unit | Utility | format-text', function () {
test('humanizeOsmTag replaces underscores and dashes with spaces and title-cases', function (assert) {
assert.strictEqual(humanizeOsmTag('atm'), 'Atm');
assert.strictEqual(humanizeOsmTag('fast_food'), 'Fast Food');
assert.strictEqual(humanizeOsmTag('phone-repair'), 'Phone Repair');
assert.strictEqual(humanizeOsmTag(''), '');
assert.strictEqual(humanizeOsmTag(null), '');
});
test('capitalize uppercases the first letter', function (assert) {
assert.strictEqual(capitalize('hello'), 'Hello');
assert.strictEqual(capitalize(''), '');
assert.strictEqual(capitalize(null), '');
});
test('formatRelativeDate returns empty string for falsy timestamp', function (assert) {
assert.strictEqual(formatRelativeDate(null), '');
assert.strictEqual(formatRelativeDate(0), '');
assert.strictEqual(formatRelativeDate(undefined), '');
});
test('formatRelativeDate returns "just now" for timestamps within the last minute', function (assert) {
const now = Math.floor(Date.now() / 1000);
assert.strictEqual(formatRelativeDate(now), 'just now');
assert.strictEqual(formatRelativeDate(now - 10), 'just now');
});
test('formatRelativeDate returns "min ago" for timestamps within the last hour', function (assert) {
const now = Math.floor(Date.now() / 1000);
assert.strictEqual(formatRelativeDate(now - 60), '1 min ago');
assert.strictEqual(formatRelativeDate(now - 300), '5 min ago');
assert.strictEqual(formatRelativeDate(now - 3599), '59 min ago');
});
test('formatRelativeDate returns "hr ago" for timestamps within the last day', function (assert) {
const now = Math.floor(Date.now() / 1000);
assert.strictEqual(formatRelativeDate(now - 3600), '1 hr ago');
assert.strictEqual(formatRelativeDate(now - 7200), '2 hr ago');
assert.strictEqual(formatRelativeDate(now - 86399), '23 hr ago');
});
test('formatRelativeDate returns "day(s) ago" for timestamps within the last week', function (assert) {
const now = Math.floor(Date.now() / 1000);
assert.strictEqual(formatRelativeDate(now - 86400), '1 day ago');
assert.strictEqual(formatRelativeDate(now - 86400 * 2), '2 days ago');
assert.strictEqual(formatRelativeDate(now - 86400 * 6), '6 days ago');
});
test('formatRelativeDate returns an absolute date for timestamps older than a week', function (assert) {
const now = Math.floor(Date.now() / 1000);
const oldTimestamp = now - 86400 * 30; // 30 days ago
const result = formatRelativeDate(oldTimestamp);
// The exact format depends on the locale, but it should contain a year
assert.ok(
result.includes(String(new Date().getFullYear())),
'Contains the year'
);
});
});
+62
View File
@@ -148,6 +148,68 @@ module('Unit | Utility | nostr', function () {
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
});
test('parsePlacePhotos extracts alt text from imeta', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [
[
'imeta',
'url https://example.com/photo.jpg',
'dim 800x600',
'alt A sunny terrace overlooking the bay',
],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].alt, 'A sunny terrace overlooking the bay');
});
test('parsePlacePhotos strips the legacy placeholder alt text', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [
[
'imeta',
'url https://example.com/photo.jpg',
'dim 800x600',
'alt A photo of a place',
],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].alt, null);
});
test('parsePlacePhotos leaves alt null when not provided', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [['imeta', 'url https://example.com/photo.jpg', 'dim 800x600']],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].alt, null);
});
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
const relays = uniqNormalizedRelays([
'Relay.example.com',