Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d547dde87
|
||
|
|
85334ec15d
|
||
|
|
bfcf855020
|
||
|
|
0f0874a07a
|
||
|
|
c9a6304926
|
||
|
|
6fb4ecede3
|
||
|
|
77db625b11
|
||
|
|
6351cfbd07
|
||
|
|
b7e46da3e9
|
||
|
|
3233150cc4
|
||
|
|
822472a0e6
|
||
|
|
7ede81fe5f
|
||
|
|
3d2b723e05
|
||
|
|
10ecb745ee
|
||
|
|
59243d7703
|
||
|
|
5bdb90c6f0
|
||
|
|
55a4a7a2da
|
||
|
|
1de276fcc8
|
||
|
|
61242420d1
|
||
|
|
45f6f898fa
|
||
|
|
21d261ef17
|
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,47 +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';
|
||||
|
||||
<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>
|
||||
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>
|
||||
|
||||
<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">
|
||||
Connect your Nostr account to see your contributions.
|
||||
</p>
|
||||
{{else if (not @items.length)}}
|
||||
<p class="empty-state">No contributions yet.</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>
|
||||
</template>
|
||||
{{#if this.isNostrConnectModalOpen}}
|
||||
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||
</Modal>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
import Icon from './icon';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
|
||||
const GalleryContent = <template>
|
||||
<div
|
||||
@@ -25,26 +27,41 @@ const GalleryContent = <template>
|
||||
class="photo-gallery-content"
|
||||
data-current-event-id={{@currentPhoto.eventId}}
|
||||
>
|
||||
<div class="actions-btn-container">
|
||||
<DropdownMenu
|
||||
@iconSize={{24}}
|
||||
@triggerIcon="more-horizontal"
|
||||
@iconColor="white"
|
||||
as |closeMenu|
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
{{on "click" (fn @copyEventId closeMenu)}}
|
||||
>Copy Photo Event ID</button>
|
||||
{{#if @canDeletePhoto}}
|
||||
<div class="photo-gallery-header">
|
||||
<div class="actions-btn-container">
|
||||
<DropdownMenu
|
||||
@iconSize={{24}}
|
||||
@triggerIcon={{@triggerIcon}}
|
||||
@iconColor="white"
|
||||
as |closeMenu|
|
||||
>
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
{{on "click" (fn @copyEventId closeMenu)}}
|
||||
>Copy Photo Event ID</button>
|
||||
{{#if @canDeletePhoto}}
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
type="button"
|
||||
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{{#if (or @uploaderName @photoDate)}}
|
||||
<div class="photo-gallery-uploader-info">
|
||||
{{#if @uploaderName}}
|
||||
<span class="photo-gallery-uploader-name">{{@uploaderName}}</span>
|
||||
{{/if}}
|
||||
{{#if @photoDate}}
|
||||
<span class="photo-gallery-uploader-date">
|
||||
{{formatRelativeDate @photoDate}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -98,6 +115,13 @@ export default class PhotoGallery extends Component {
|
||||
|
||||
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
||||
|
||||
get triggerIcon() {
|
||||
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
|
||||
return 'more-vertical';
|
||||
}
|
||||
return 'more-horizontal';
|
||||
}
|
||||
|
||||
get isCreator() {
|
||||
return (
|
||||
this.currentPhoto?.pubkey &&
|
||||
@@ -112,6 +136,19 @@ export default class PhotoGallery extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
get uploaderName() {
|
||||
const pubkey = this.currentPhoto?.pubkey;
|
||||
if (!pubkey) return null;
|
||||
const profile = this.nostrData.getProfile?.(pubkey);
|
||||
if (!profile) return null;
|
||||
return profile.displayName || profile.display_name || profile.name || null;
|
||||
}
|
||||
|
||||
get photoDate() {
|
||||
const ts = this.currentPhoto?.publishedAt || this.currentPhoto?.createdAt;
|
||||
return ts || null;
|
||||
}
|
||||
|
||||
bindKeyboard = modifier((element, [handler]) => {
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
@@ -131,7 +168,7 @@ export default class PhotoGallery extends Component {
|
||||
e.target.closest('.thumbnail-strip-container') ||
|
||||
e.target.closest('.carousel-nav-btn') ||
|
||||
e.target.closest('.close-btn') ||
|
||||
e.target.closest('.actions-btn-container')
|
||||
e.target.closest('.photo-gallery-header')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -268,6 +305,9 @@ export default class PhotoGallery extends Component {
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
@uploaderName={{this.uploaderName}}
|
||||
@photoDate={{this.photoDate}}
|
||||
@triggerIcon={{this.triggerIcon}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#in-element this.destinationElement}}
|
||||
@@ -284,6 +324,9 @@ export default class PhotoGallery extends Component {
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
@uploaderName={{this.uploaderName}}
|
||||
@photoDate={{this.photoDate}}
|
||||
@triggerIcon={{this.triggerIcon}}
|
||||
/>
|
||||
{{/in-element}}
|
||||
{{/if}}
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -43,6 +43,11 @@ export default class ContributionsController extends Controller {
|
||||
this.router.transitionTo(`/place/${item.placeIdentifier}`);
|
||||
}
|
||||
|
||||
@action
|
||||
onNostrConnected() {
|
||||
this.loadContributionsTask.perform(this.nostrAuth.pubkey);
|
||||
}
|
||||
|
||||
@action
|
||||
backToMenu() {
|
||||
this.router.transitionTo('menu');
|
||||
|
||||
+39
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { groupPhotoContributions } from '../utils/contributions';
|
||||
|
||||
const NAME_CACHE_KEY = 'marco:contributions:name_cache';
|
||||
const NAME_CACHE_STORE = 'contributions-name-cache';
|
||||
|
||||
/**
|
||||
* Orchestrates loading the user's own Nostr contributions, grouping them into
|
||||
@@ -11,54 +11,90 @@ const NAME_CACHE_KEY = 'marco:contributions:name_cache';
|
||||
* 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 immediately from bookmarks / name cache / OSM cache.
|
||||
* 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 = '';
|
||||
_nameCache = new Map();
|
||||
_unresolvable = new Set();
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this._loadNameCache();
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
_loadNameCache() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
const raw = localStorage.getItem(NAME_CACHE_KEY);
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw);
|
||||
if (obj && typeof obj === 'object') {
|
||||
this._nameCache = new Map(Object.entries(obj));
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed cache
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
_saveNameCache() {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
const obj = Object.fromEntries(this._nameCache);
|
||||
localStorage.setItem(NAME_CACHE_KEY, JSON.stringify(obj));
|
||||
} catch (e) {
|
||||
console.debug('[contributions] Failed to persist name cache', e);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,36 +145,21 @@ export default class ContributionsService extends Service {
|
||||
// 1. Group events into contribution entries (newest-first)
|
||||
const entries = groupPhotoContributions(events);
|
||||
|
||||
// 2. Resolve place names: preserve previously-resolved names, then check
|
||||
// bookmarks, the name cache, and the OSM service cache.
|
||||
// 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 cached = this._resolveCachedName(entry);
|
||||
if (cached) {
|
||||
entry.placeName = cached;
|
||||
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||
if (bookmark?.title) {
|
||||
entry.placeName = bookmark.title;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.items = entries;
|
||||
|
||||
// 3. Trigger a background batch fetch for any still-unresolved names.
|
||||
// De-duplicate so we don't re-fetch the same set while a fetch is in-flight.
|
||||
this._maybeBatchFetchNames(entries);
|
||||
}
|
||||
|
||||
_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 (instant, survives across sessions)
|
||||
if (this._nameCache.has(entry.placeIdentifier)) {
|
||||
return this._nameCache.get(entry.placeIdentifier);
|
||||
}
|
||||
|
||||
// 3. Try OSM localStorage cache (instant, from place detail visits)
|
||||
const cached = this.osm.getCachedOsmObject(entry.osmType, entry.osmId);
|
||||
return cached?.title || null;
|
||||
// 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) {
|
||||
@@ -177,7 +198,7 @@ export default class ContributionsService extends Service {
|
||||
|
||||
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
||||
.then((nameMap) => {
|
||||
// Merge resolved names back into the current `items` and the name cache.
|
||||
// Merge resolved names back into the current `items`.
|
||||
for (const item of this.items) {
|
||||
if (!item.placeNameLoading) continue;
|
||||
if (nameMap.has(item.placeIdentifier)) {
|
||||
@@ -192,7 +213,6 @@ export default class ContributionsService extends Service {
|
||||
item.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
this._saveNameCache();
|
||||
// Trigger a re-render
|
||||
this.items = [...this.items];
|
||||
})
|
||||
@@ -206,7 +226,6 @@ export default class ContributionsService extends Service {
|
||||
item.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
this._saveNameCache();
|
||||
this.items = [...this.items];
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -224,7 +243,7 @@ export default class ContributionsService extends Service {
|
||||
|
||||
// Re-check cache in case it was populated between the trigger and now
|
||||
for (const entry of entries) {
|
||||
const cached = this._resolveCachedName(entry);
|
||||
const cached = await this._resolveCachedName(entry);
|
||||
if (cached) {
|
||||
nameMap.set(entry.placeIdentifier, cached);
|
||||
} else {
|
||||
@@ -235,16 +254,26 @@ export default class ContributionsService extends Service {
|
||||
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);
|
||||
this._nameCache.set(entry.placeIdentifier, place.title);
|
||||
writePromises.push(
|
||||
this.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
entry.placeIdentifier,
|
||||
place.title
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
this._saveNameCache();
|
||||
await Promise.all(writePromises);
|
||||
return nameMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +1,12 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { EMPTY, from } from 'rxjs';
|
||||
import { EventStore } from 'applesauce-core/event-store';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||
import { NostrIDB, openDB } from 'nostr-idb';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
@@ -15,9 +17,9 @@ import {
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
|
||||
const DIRECTORY_RELAYS = [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.damus.io',
|
||||
];
|
||||
|
||||
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
||||
@@ -35,20 +37,36 @@ export default class NostrDataService extends Service {
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked myContributionEvents = [];
|
||||
@tracked profiles = {};
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
_profileModelSubs = new Map();
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = null;
|
||||
_currentPlaceEntityId = null;
|
||||
loadedGeohashPrefixes = new Set();
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
// Set up the event loader synchronously so that any subscription
|
||||
// (e.g. loadProfiles from a route's afterModel) can auto-fetch even
|
||||
// before the IndexedDB cache has finished opening. The cacheRequest
|
||||
// is lazy — it returns EMPTY until `this.cache` is available, so the
|
||||
// loader falls through to relay hints → lookup relays in the meantime.
|
||||
createEventLoaderForStore(this.store, this.nostrRelay.pool, {
|
||||
cacheRequest: (filters) => {
|
||||
if (!this.cache) return EMPTY;
|
||||
return from(this.cache.query(filters));
|
||||
},
|
||||
lookupRelays: DIRECTORY_RELAYS,
|
||||
});
|
||||
|
||||
// Initialize the IndexedDB cache
|
||||
this._cachePromise = openDB('applesauce-events').then(async (db) => {
|
||||
this.cache = new NostrIDB(db, {
|
||||
@@ -212,19 +230,31 @@ export default class NostrDataService extends Service {
|
||||
}
|
||||
|
||||
async loadPhotosForPlace(place) {
|
||||
const entityId =
|
||||
place && place.osmId && place.osmType
|
||||
? `osm:${place.osmType}:${place.osmId}`
|
||||
: null;
|
||||
|
||||
// Skip the full reset if we're loading the same place again (e.g. from
|
||||
// checkUpdates calling selectPlace a second time). This prevents tearing
|
||||
// down timeline and profile subscriptions that are still in-flight.
|
||||
if (entityId && entityId === this._currentPlaceEntityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._photosSub) {
|
||||
this._photosSub.unsubscribe();
|
||||
this._photosSub = null;
|
||||
}
|
||||
|
||||
this.placePhotos = [];
|
||||
this._clearProfileSubs();
|
||||
this._currentPlaceEntityId = entityId;
|
||||
|
||||
if (!place || !place.osmId || !place.osmType) {
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entityId = `osm:${place.osmType}:${place.osmId}`;
|
||||
|
||||
// Setup reactive store query
|
||||
this._photosSub = this.store
|
||||
.timeline([
|
||||
@@ -235,6 +265,8 @@ export default class NostrDataService extends Service {
|
||||
])
|
||||
.subscribe((events) => {
|
||||
this.placePhotos = events;
|
||||
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
||||
this.loadProfiles(pubkeys);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -331,6 +363,33 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
loadProfiles(pubkeys) {
|
||||
const newPubkeys = pubkeys.filter(
|
||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||
);
|
||||
|
||||
for (const pubkey of newPubkeys) {
|
||||
const sub = this.store
|
||||
.model(ProfileModel, pubkey)
|
||||
.subscribe((profileContent) => {
|
||||
this.profiles = { ...this.profiles, [pubkey]: profileContent };
|
||||
});
|
||||
this._profileModelSubs.set(pubkey, sub);
|
||||
}
|
||||
}
|
||||
|
||||
getProfile(pubkey) {
|
||||
return this.profiles[pubkey];
|
||||
}
|
||||
|
||||
_clearProfileSubs() {
|
||||
for (const sub of this._profileModelSubs.values()) {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
this._profileModelSubs.clear();
|
||||
this.profiles = {};
|
||||
}
|
||||
|
||||
async loadProfile(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
@@ -471,6 +530,7 @@ export default class NostrDataService extends Service {
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this._cleanupSubscriptions();
|
||||
this._clearProfileSubs();
|
||||
|
||||
if (this._stopPersisting) {
|
||||
this._stopPersisting();
|
||||
|
||||
+45
-35
@@ -4,16 +4,18 @@ 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 localStorage 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 CACHE_KEY_PREFIX = 'marco:osm_cache:';
|
||||
// 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
|
||||
|
||||
@@ -21,27 +23,24 @@ export default class OsmService extends Service {
|
||||
return `${osmType}:${osmId}`;
|
||||
}
|
||||
|
||||
_readLocalCache(osmType, osmId) {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
const key = `${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`;
|
||||
async _readLocalCache(osmType, osmId) {
|
||||
const key = this._buildCacheKey(osmType, osmId);
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const raw = await this.localForage.get(OsmService.STORE_NAME, key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.timestamp !== 'number' ||
|
||||
Date.now() - parsed.timestamp > OsmService.CACHE_TTL_MS
|
||||
) {
|
||||
localStorage.removeItem(key);
|
||||
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
} catch {
|
||||
try {
|
||||
localStorage.removeItem(
|
||||
`${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`
|
||||
);
|
||||
await this.localForage.remove(OsmService.STORE_NAME, key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -49,31 +48,32 @@ export default class OsmService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
_writeLocalCache(osmType, osmId, data) {
|
||||
if (typeof localStorage === 'undefined' || !data) return;
|
||||
const key = `${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`;
|
||||
async _writeLocalCache(osmType, osmId, data) {
|
||||
if (!data) return;
|
||||
const key = this._buildCacheKey(osmType, osmId);
|
||||
try {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ data, timestamp: Date.now() })
|
||||
);
|
||||
await this.localForage.set(OsmService.STORE_NAME, key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug('[osm] Failed to write localStorage cache entry', e);
|
||||
console.debug('[osm] Failed to write IndexedDB cache entry', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous lookup for an OSM place. Checks the short-lived in-memory cache
|
||||
* first, then the persistent localStorage cache. Returns `null` if not cached.
|
||||
* 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.
|
||||
* 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 {object|null} Normalized OSM place data
|
||||
* @returns {Promise<object|null>} Normalized OSM place data
|
||||
*/
|
||||
getCachedOsmObject(osmType, osmId) {
|
||||
async getCachedOsmObject(osmType, osmId) {
|
||||
if (!osmType || !osmId) return null;
|
||||
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
@@ -103,7 +103,9 @@ export default class OsmService extends Service {
|
||||
this.cachedPlaces.delete(cacheKey);
|
||||
}, OsmService.IN_MEMORY_TTL_MS);
|
||||
|
||||
this._writeLocalCache(osmType, osmId, data);
|
||||
// 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() {
|
||||
@@ -326,22 +328,30 @@ 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 = 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 < OsmService.IN_MEMORY_TTL_MS) {
|
||||
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// If we have a persistent (localStorage) cache entry, return it immediately and
|
||||
// kick off a background refresh. This keeps the UI snappy while still ensuring
|
||||
// the cache is updated with the latest OSM data.
|
||||
const localCached = this._readLocalCache(osmType, osmId);
|
||||
// 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 localStorage cached OSM object for ${cacheKey}`);
|
||||
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);
|
||||
@@ -415,7 +425,7 @@ out center;
|
||||
for (const { osmType, osmId } of items) {
|
||||
if (!osmType || !osmId) continue;
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
const cached = this.getCachedOsmObject(osmType, osmId);
|
||||
const cached = await this.getCachedOsmObject(osmType, osmId);
|
||||
if (cached) {
|
||||
result.set(cacheKey, cached);
|
||||
} else {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+81
-6
@@ -752,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%;
|
||||
}
|
||||
@@ -2132,17 +2151,73 @@ button.create-place {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Actions button in photo gallery */
|
||||
.photo-gallery-overlay .actions-btn-container {
|
||||
/* Photo gallery header (actions button + uploader info) */
|
||||
.photo-gallery-overlay .photo-gallery-header {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-header .actions-btn-container {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Uploader info (name + date) in photo gallery */
|
||||
.photo-gallery-overlay .photo-gallery-uploader-info {
|
||||
color: rgb(255 255 255 / 90%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 60%);
|
||||
max-width: calc(100vw - 4rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-uploader-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-uploader-date {
|
||||
font-size: 0.8rem;
|
||||
color: rgb(255 255 255 / 70%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.photo-gallery-overlay .photo-gallery-header {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-header .actions-btn-container {
|
||||
width: 48px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-gallery-overlay .photo-gallery-uploader-info {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Snappy slide-in from left (Desktop) */
|
||||
|
||||
@@ -10,6 +10,7 @@ import ContributionsTimeline from '#components/contributions-timeline';
|
||||
@onSelect={{@controller.selectContribution}}
|
||||
@onBack={{@controller.backToMenu}}
|
||||
@onClose={{@controller.close}}
|
||||
@onNostrConnected={{@controller.onNostrConnected}}
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
|
||||
@@ -6,8 +6,46 @@
|
||||
* 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
|
||||
@@ -56,11 +94,14 @@ function parsePhotoFromEvent(event) {
|
||||
if (!url) return null;
|
||||
|
||||
const placeIdentifier = tags.find((t) => t[0] === 'i')?.[1];
|
||||
const publishedAtRaw = tags.find((t) => t[0] === 'published_at')?.[1];
|
||||
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
|
||||
url,
|
||||
thumbUrl,
|
||||
blurhash,
|
||||
@@ -180,15 +221,12 @@ function buildEntry(placeIdentifier, events) {
|
||||
|
||||
const [, osmType, osmId] = placeIdentifier.split(':');
|
||||
|
||||
return {
|
||||
type: 'photo',
|
||||
return new ContributionEntry({
|
||||
placeIdentifier,
|
||||
osmType,
|
||||
osmId,
|
||||
placeName: null,
|
||||
placeNameLoading: true,
|
||||
photos,
|
||||
createdAt,
|
||||
eventCount: events.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ export function parsePlacePhotos(events) {
|
||||
let aspectRatio = 16 / 9; // default
|
||||
let altText = null;
|
||||
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
||||
const publishedAtRaw = event.tags.find(
|
||||
(t) => t[0] === 'published_at'
|
||||
)?.[1];
|
||||
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
|
||||
|
||||
for (const tag of imeta.slice(1)) {
|
||||
if (tag.startsWith('url ')) {
|
||||
@@ -98,6 +102,7 @@ export function parsePlacePhotos(events) {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
|
||||
url,
|
||||
thumbUrl,
|
||||
blurhash,
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -105,11 +105,13 @@
|
||||
"@noble/hashes": "^2.3.0",
|
||||
"@waysidemapping/pinhead": "^15.25.0",
|
||||
"applesauce-core": "^6.2.0",
|
||||
"applesauce-loaders": "^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.1.0",
|
||||
"localforage": "^1.10.0",
|
||||
"nostr-idb": "^5.1.0",
|
||||
"oauth2-pkce": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
Generated
+1132
-1094
File diff suppressed because it is too large
Load Diff
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
@@ -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-MT6QR2E9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-sIiovt6q.css">
|
||||
<script type="module" crossorigin src="/assets/main-C4QF_Hr3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Cal_p7lY.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
@@ -20,7 +20,7 @@ class MockOsmService extends Service {
|
||||
}
|
||||
|
||||
getCachedOsmObject() {
|
||||
return null;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -36,11 +36,16 @@ export class MockNostrDataService extends Service {
|
||||
@tracked mailboxes = null;
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked profiles = {};
|
||||
|
||||
store = {
|
||||
add: () => {},
|
||||
};
|
||||
|
||||
getProfile(pubkey) {
|
||||
return this.profiles[pubkey];
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { render, click, settled } from '@ember/test-helpers';
|
||||
import ContributionPhoto from 'marco/components/contribution-photo';
|
||||
import { ContributionEntry } from 'marco/utils/contributions';
|
||||
|
||||
function noop() {}
|
||||
|
||||
@@ -145,4 +146,40 @@ module('Integration | Component | contribution-photo', function (hooks) {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
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 = [];
|
||||
});
|
||||
@@ -48,6 +57,28 @@ module('Integration | Component | contributions-timeline', function (hooks) {
|
||||
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>
|
||||
|
||||
@@ -339,4 +339,143 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
await triggerKeyEvent(document, 'keydown', 'Escape');
|
||||
assert.ok(closed, 'gallery was closed on escape key');
|
||||
});
|
||||
|
||||
test('it renders uploader name and date when profile is loaded', async function (assert) {
|
||||
const displayName = 'Alice';
|
||||
const publishedAt = Math.floor(Date.now() / 1000) - 60 * 60 * 24; // 1 day ago
|
||||
|
||||
this.nostrData.profiles = {
|
||||
[USER_A]: { displayName, display_name: 'ignored', name: 'ignored' },
|
||||
};
|
||||
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: USER_A,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'https://example.com/photo.jpg',
|
||||
publishedAt,
|
||||
createdAt: publishedAt + 10,
|
||||
},
|
||||
];
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.photo-gallery-uploader-name')
|
||||
.hasText(displayName, 'uploader name is rendered');
|
||||
assert
|
||||
.dom('.photo-gallery-uploader-date')
|
||||
.exists('date element is rendered');
|
||||
});
|
||||
|
||||
test('it prefers published_at over created_at for the date', async function (assert) {
|
||||
this.nostrData.profiles = {
|
||||
[USER_A]: { displayName: 'Alice' },
|
||||
};
|
||||
|
||||
const publishedAt = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 3; // 3 days ago
|
||||
const createdAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
|
||||
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: USER_A,
|
||||
url: 'https://example.com/photo.jpg',
|
||||
publishedAt,
|
||||
createdAt,
|
||||
},
|
||||
];
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
||||
// Should show "3 days ago" (publishedAt), not "just now" (createdAt)
|
||||
assert.dom('.photo-gallery-uploader-date').hasText('3 days ago');
|
||||
});
|
||||
|
||||
test('it does not render uploader info when profile is missing', async function (assert) {
|
||||
this.nostrData.profiles = {};
|
||||
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: USER_A,
|
||||
url: 'https://example.com/photo.jpg',
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
];
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.photo-gallery-uploader-name')
|
||||
.doesNotExist('uploader name is not rendered without profile');
|
||||
// Date should still render since createdAt is present
|
||||
assert.dom('.photo-gallery-uploader-date').exists('date is still rendered');
|
||||
});
|
||||
|
||||
test('it falls back through displayName -> display_name -> name', async function (assert) {
|
||||
this.nostrData.profiles = {
|
||||
[USER_A]: { display_name: 'Bob', name: 'Robert' },
|
||||
};
|
||||
|
||||
this.photos = [
|
||||
{
|
||||
eventId: 'event1',
|
||||
pubkey: USER_A,
|
||||
url: 'https://example.com/photo.jpg',
|
||||
createdAt: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
];
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<div id="test-container">
|
||||
<div id="modal-portal"></div>
|
||||
<PhotoGallery
|
||||
@photos={{this.photos}}
|
||||
@selectedPhoto={{this.selectedPhoto}}
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.photo-gallery-uploader-name')
|
||||
.hasText('Bob', 'falls back to display_name when displayName is missing');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -42,7 +42,7 @@ class MockNostrDataService extends Service {
|
||||
}
|
||||
|
||||
class MockOsmService extends Service {
|
||||
getCachedOsmObject() {
|
||||
async getCachedOsmObject() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ class MockOsmService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
const NAME_CACHE_STORE = 'contributions-name-cache';
|
||||
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
module('Unit | Service | contributions', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
@@ -58,12 +64,6 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
localStorage.removeItem('marco:contributions:name_cache');
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
localStorage.removeItem('marco:contributions:name_cache');
|
||||
});
|
||||
|
||||
test('_updateItems resolves names from bookmarks immediately', function (assert) {
|
||||
@@ -85,7 +85,7 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('_updateItems resolves names from the persistent name cache', function (assert) {
|
||||
test('_updateItems resolves names from the persistent name cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
@@ -95,36 +95,66 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service._nameCache.set('osm:node:999', 'Cached Park');
|
||||
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('name cache is persisted to localStorage', function (assert) {
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service._nameCache.set('osm:node:42', 'Test Place');
|
||||
service._saveNameCache();
|
||||
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 raw = localStorage.getItem('marco:contributions:name_cache');
|
||||
assert.ok(raw, 'Cache entry exists in localStorage');
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.strictEqual(parsed['osm:node:42'], 'Test Place');
|
||||
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('name cache is loaded from localStorage on construction', function (assert) {
|
||||
localStorage.setItem(
|
||||
'marco:contributions:name_cache',
|
||||
JSON.stringify({ 'osm:node:77': 'Persisted Place' })
|
||||
);
|
||||
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');
|
||||
assert.strictEqual(
|
||||
service._nameCache.get('osm:node:77'),
|
||||
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) {
|
||||
@@ -143,7 +173,7 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
service._updateItems(events);
|
||||
|
||||
// Wait for the background batch to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushPromises();
|
||||
|
||||
assert.false(
|
||||
service.items[0].placeNameLoading,
|
||||
@@ -169,31 +199,18 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
service.osm.fetchOsmObjectsBatch = async () => new Map();
|
||||
|
||||
service._updateItems(events);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushPromises();
|
||||
|
||||
// Fallback should be shown but NOT cached
|
||||
assert.strictEqual(service.items[0].placeName, 'OSM node 666');
|
||||
assert.false(
|
||||
service._nameCache.has('osm:node:666'),
|
||||
'Fallback is not stored in the name cache'
|
||||
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'
|
||||
);
|
||||
|
||||
// Verify the fallback was NOT persisted to localStorage
|
||||
service._saveNameCache();
|
||||
const raw = localStorage.getItem('marco:contributions:name_cache');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.notOk(
|
||||
parsed['osm:node:666'],
|
||||
'No fallback name persisted in localStorage'
|
||||
);
|
||||
} else {
|
||||
assert.true(true, 'No cache entry was persisted');
|
||||
}
|
||||
});
|
||||
|
||||
test('unresolvable items are not re-fetched within the same session', async function (assert) {
|
||||
@@ -214,14 +231,14 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
|
||||
// First update triggers a fetch
|
||||
service._updateItems(events);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
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 new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(fetchCount, 1, 'Second update does not re-fetch');
|
||||
assert.strictEqual(
|
||||
@@ -248,11 +265,11 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
};
|
||||
|
||||
service._updateItems(events);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
|
||||
assert.strictEqual(
|
||||
service._nameCache.get('osm:node:111'),
|
||||
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:111'),
|
||||
'Resolved Café',
|
||||
'Name is stored in the name cache'
|
||||
);
|
||||
|
||||
@@ -348,8 +348,8 @@ module('Unit | Service | osm', function (hooks) {
|
||||
'Batch fetch does not write to the in-memory OSM cache'
|
||||
);
|
||||
assert.notOk(
|
||||
localStorage.getItem('marco:osm_cache:node:100'),
|
||||
'Batch fetch does not write to the localStorage OSM cache'
|
||||
await service.localForage.get('osm-cache', 'node:100'),
|
||||
'Batch fetch does not write to the persistent OSM cache'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -432,4 +432,65 @@ module('Unit | Service | osm', function (hooks) {
|
||||
'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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -210,6 +210,66 @@ module('Unit | Utility | nostr', function () {
|
||||
assert.strictEqual(photos[0].alt, null);
|
||||
});
|
||||
|
||||
test('parsePlacePhotos extracts published_at when present', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
pubkey: 'pubkey-1',
|
||||
created_at: 200,
|
||||
tags: [
|
||||
['i', 'osm:node:123'],
|
||||
['published_at', '100'],
|
||||
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const photos = parsePlacePhotos(events);
|
||||
|
||||
assert.strictEqual(photos.length, 1);
|
||||
assert.strictEqual(photos[0].publishedAt, 100);
|
||||
assert.strictEqual(photos[0].createdAt, 200);
|
||||
});
|
||||
|
||||
test('parsePlacePhotos sets publishedAt to null when not present', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
pubkey: 'pubkey-1',
|
||||
created_at: 200,
|
||||
tags: [
|
||||
['i', 'osm:node:123'],
|
||||
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const photos = parsePlacePhotos(events);
|
||||
|
||||
assert.strictEqual(photos.length, 1);
|
||||
assert.strictEqual(photos[0].publishedAt, null);
|
||||
});
|
||||
|
||||
test('parsePlacePhotos ignores invalid published_at values', function (assert) {
|
||||
const events = [
|
||||
{
|
||||
id: 'event-1',
|
||||
pubkey: 'pubkey-1',
|
||||
created_at: 200,
|
||||
tags: [
|
||||
['i', 'osm:node:123'],
|
||||
['published_at', 'not-a-number'],
|
||||
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const photos = parsePlacePhotos(events);
|
||||
|
||||
assert.strictEqual(photos.length, 1);
|
||||
assert.strictEqual(photos[0].publishedAt, null);
|
||||
});
|
||||
|
||||
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
|
||||
const relays = uniqNormalizedRelays([
|
||||
'Relay.example.com',
|
||||
|
||||
Reference in New Issue
Block a user