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
This commit was merged in pull request #74.
This commit is contained in:
2026-08-18 23:01:26 +00:00
25 changed files with 2345 additions and 23 deletions
+6
View File
@@ -25,6 +25,12 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
<span>Collections</span> <span>Collections</span>
</button> </button>
</li> </li>
<li>
<button type="button" {{on "click" @onContributions}}>
<Icon @name="activity" @size={{20}} />
<span>My Contributions</span>
</button>
</li>
<li> <li>
<button type="button" {{on "click" (fn @onNavigate "settings")}}> <button type="button" {{on "click" (fn @onNavigate "settings")}}>
<Icon @name="settings" @size={{20}} /> <Icon @name="settings" @size={{20}} />
+6
View File
@@ -23,6 +23,11 @@ export default class AppMenu extends Component {
this.router.transitionTo('lists.index'); this.router.transitionTo('lists.index');
} }
@action
goToContributions() {
this.router.transitionTo('contributions');
}
<template> <template>
<div class="sidebar app-menu-pane"> <div class="sidebar app-menu-pane">
{{#if (eq this.currentView "menu")}} {{#if (eq this.currentView "menu")}}
@@ -30,6 +35,7 @@ export default class AppMenu extends Component {
@onNavigate={{this.setView}} @onNavigate={{this.setView}}
@onClose={{@onClose}} @onClose={{@onClose}}
@onSavedPlaces={{this.goToSavedPlaces}} @onSavedPlaces={{this.goToSavedPlaces}}
@onContributions={{this.goToContributions}}
/> />
{{else if (eq this.currentView "settings")}} {{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>
}
+47
View File
@@ -0,0 +1,47 @@
import { on } from '@ember/modifier';
import Icon from './icon';
import ContributionPhoto from './contribution-photo';
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>
</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>
+21 -1
View File
@@ -8,6 +8,7 @@ import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
import Kinetic from 'ol/Kinetic.js'; import Kinetic from 'ol/Kinetic.js';
import View from 'ol/View.js'; import View from 'ol/View.js';
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js'; import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
import { containsExtent } from 'ol/extent.js';
import Overlay from 'ol/Overlay.js'; import Overlay from 'ol/Overlay.js';
import LayerGroup from 'ol/layer/Group.js'; import LayerGroup from 'ol/layer/Group.js';
import VectorLayer from 'ol/layer/Vector.js'; import VectorLayer from 'ol/layer/Vector.js';
@@ -665,7 +666,12 @@ export default class MapComponent extends Component {
if (options.preventZoom) { if (options.preventZoom) {
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check. // 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 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) { } else if (selected.bbox) {
this.zoomToBbox(selected.bbox); this.zoomToBbox(selected.bbox);
} else { } 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) { zoomToBbox(bbox) {
if (!this.mapInstance || !bbox) return; if (!this.mapInstance || !bbox) return;
+55
View File
@@ -0,0 +1,55 @@
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
backToMenu() {
this.router.transitionTo('menu');
}
@action
close() {
this.router.transitionTo('index');
}
}
+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('lists', function () {
this.route('list', { path: '/:list_id' }); this.route('list', { path: '/:list_id' });
}); });
this.route('contributions');
this.route('oauth', function () { this.route('oauth', function () {
this.route('osm-callback', { path: '/osm/callback' }); 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();
}
}
+250
View File
@@ -0,0 +1,250 @@
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { groupPhotoContributions } from '../utils/contributions';
const NAME_CACHE_KEY = 'marco: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 immediately from bookmarks / name cache / 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.
*/
export default class ContributionsService extends Service {
@service nostrData;
@service nostrAuth;
@service storage;
@service osm;
@tracked items = [];
_sub = null;
_pendingBatchPromise = null;
_lastBatchSignature = '';
_nameCache = new Map();
_unresolvable = new Set();
constructor() {
super(...arguments);
this._loadNameCache();
}
_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));
}
}
} 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);
}
}
/**
* 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. Resolve place names: preserve previously-resolved names, then check
// bookmarks, the name cache, and the OSM service cache.
for (const entry of entries) {
const cached = this._resolveCachedName(entry);
if (cached) {
entry.placeName = cached;
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;
}
_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` and the name cache.
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;
}
}
this._saveNameCache();
// 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._saveNameCache();
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 = 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);
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);
}
}
this._saveNameCache();
return nameMap;
}
}
+57
View File
@@ -34,11 +34,13 @@ export default class NostrDataService extends Service {
@tracked mailboxes = null; @tracked mailboxes = null;
@tracked blossomServers = []; @tracked blossomServers = [];
@tracked placePhotos = []; @tracked placePhotos = [];
@tracked myContributionEvents = [];
_profileSub = null; _profileSub = null;
_mailboxesSub = null; _mailboxesSub = null;
_blossomSub = null; _blossomSub = null;
_photosSub = null; _photosSub = null;
_contributionsSub = null;
_requestSub = null; _requestSub = null;
_cachePromise = 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) { async loadProfile(pubkey) {
if (!pubkey) return; if (!pubkey) return;
@@ -409,6 +462,10 @@ export default class NostrDataService extends Service {
this._photosSub.unsubscribe(); this._photosSub.unsubscribe();
this._photosSub = null; this._photosSub = null;
} }
if (this._contributionsSub) {
this._contributionsSub.unsubscribe();
this._contributionsSub = null;
}
} }
willDestroy() { willDestroy() {
+219 -19
View File
@@ -10,6 +10,102 @@ export default class OsmService extends Service {
lastQueryKey = null; lastQueryKey = null;
cachedPlaces = new Map(); 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:';
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}`;
}
_readLocalCache(osmType, osmId) {
if (typeof localStorage === 'undefined') return null;
const key = `${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`;
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (
!parsed ||
typeof parsed.timestamp !== 'number' ||
Date.now() - parsed.timestamp > OsmService.CACHE_TTL_MS
) {
localStorage.removeItem(key);
return null;
}
return parsed.data;
} catch {
try {
localStorage.removeItem(
`${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`
);
} catch {
// ignore
}
return null;
}
}
_writeLocalCache(osmType, osmId, data) {
if (typeof localStorage === 'undefined' || !data) return;
const key = `${OsmService.CACHE_KEY_PREFIX}${osmType}:${osmId}`;
try {
localStorage.setItem(
key,
JSON.stringify({ data, timestamp: Date.now() })
);
} catch (e) {
console.debug('[osm] Failed to write localStorage 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.
*
* 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
*/
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);
this._writeLocalCache(osmType, osmId, data);
}
cancelAll() { cancelAll() {
if (this.controller) { if (this.controller) {
this.controller.abort(); this.controller.abort();
@@ -233,13 +329,46 @@ out center;
async fetchOsmObject(osmId, osmType) { async fetchOsmObject(osmId, osmType) {
if (!osmId || !osmType) return null; if (!osmId || !osmType) return null;
const cacheKey = `${osmType}:${osmId}`; const cacheKey = this._buildCacheKey(osmType, osmId);
const cached = this.cachedPlaces.get(cacheKey); 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}`); console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
return cached.data; 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);
if (localCached) {
console.debug(`Using localStorage 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; let url;
if (osmType === 'node') { if (osmType === 'node') {
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`; url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
@@ -263,29 +392,100 @@ out center;
} }
const data = await res.json(); const data = await res.json();
const normalizedData = this.normalizeOsmApiData( return this.normalizeOsmApiData(data.elements, osmId, osmType);
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;
} catch (e) { } catch (e) {
console.error('Failed to fetch OSM object:', e); console.error('Failed to fetch OSM object:', e);
return null; 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 = 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) { normalizeOsmApiData(elements, targetId, targetType) {
if (!elements || elements.length === 0) return null; if (!elements || elements.length === 0) return null;
+98
View File
@@ -2258,3 +2258,101 @@ button.create-place {
justify-content: center; justify-content: center;
padding: 4rem 1rem; 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; @service router;
get isSidebarOpen() { 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. // This helps the map know if it should shift the center or adjust view.
const name = this.router.currentRouteName; const name = this.router.currentRouteName;
return ( return (
@@ -22,6 +22,7 @@ export default class ApplicationComponent extends Component {
name === 'place.new' || name === 'place.new' ||
name === 'search' || name === 'search' ||
name === 'menu' || name === 'menu' ||
name === 'contributions' ||
name.startsWith('lists')) name.startsWith('lists'))
); );
} }
@@ -49,11 +50,16 @@ export default class ApplicationComponent extends Component {
name === 'search' || name === 'search' ||
name === 'place' || name === 'place' ||
name === 'menu' || name === 'menu' ||
name === 'contributions' ||
name.startsWith('lists') name.startsWith('lists')
) { ) {
this.mapUi.clearSelection(); this.mapUi.clearSelection();
this.mapUi.hideSidebar(); this.mapUi.hideSidebar();
if (name === 'menu' || name.startsWith('lists')) { if (
name === 'menu' ||
name === 'contributions' ||
name.startsWith('lists')
) {
this.router.transitionTo('index'); this.router.transitionTo('index');
} else if (name === 'place') { } else if (name === 'place') {
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) { if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
+15
View File
@@ -0,0 +1,15 @@
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}}
/>
{{/if}}
</template>
+5 -1
View File
@@ -81,7 +81,11 @@ export default class PlaceTemplate extends Component {
if (this.mapUi.returnToRoute) { if (this.mapUi.returnToRoute) {
this.mapUi.showSidebar(); this.mapUi.showSidebar();
const { name, model } = this.mapUi.returnToRoute; 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) // If we have an active search context, return to it (UP navigation)
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) { else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
+194
View File
@@ -0,0 +1,194 @@
/**
* 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.
*/
const HOUR_IN_SECONDS = 60 * 60;
/**
* 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 {
type: 'photo',
placeIdentifier,
osmType,
osmId,
placeName: null,
placeNameLoading: true,
photos,
createdAt,
eventCount: events.length,
};
}
+20
View File
@@ -12,3 +12,23 @@ export function capitalize(text) {
if (typeof text !== 'string' || !text) return ''; if (typeof text !== 'string' || !text) return '';
return text.charAt(0).toUpperCase() + text.slice(1); 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',
});
}
+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 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'
);
});
});
@@ -0,0 +1,148 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import ContributionPhoto from 'marco/components/contribution-photo';
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);
});
});
@@ -0,0 +1,144 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import ContributionsTimeline from 'marco/components/contributions-timeline';
function noop() {}
module('Integration | Component | contributions-timeline', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
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('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);
});
});
+260
View File
@@ -0,0 +1,260 @@
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 {
getCachedOsmObject() {
return null;
}
async fetchOsmObjectsBatch() {
return new Map();
}
}
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);
localStorage.removeItem('marco:contributions:name_cache');
});
hooks.afterEach(function () {
localStorage.removeItem('marco:contributions:name_cache');
});
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', function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:999',
}),
];
const service = this.owner.lookup('service:contributions');
service._nameCache.set('osm:node:999', 'Cached Park');
service._updateItems(events);
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();
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');
});
test('name cache is loaded from localStorage on construction', function (assert) {
localStorage.setItem(
'marco:contributions:name_cache',
JSON.stringify({ 'osm:node:77': 'Persisted Place' })
);
const service = this.owner.lookup('service:contributions');
assert.strictEqual(
service._nameCache.get('osm:node:77'),
'Persisted Place'
);
});
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 new Promise((resolve) => setTimeout(resolve, 50));
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 new Promise((resolve) => setTimeout(resolve, 50));
// 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.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) {
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 new Promise((resolve) => setTimeout(resolve, 50));
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));
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 new Promise((resolve) => setTimeout(resolve, 50));
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
assert.strictEqual(
service._nameCache.get('osm:node:111'),
'Resolved Café',
'Name is stored in the name cache'
);
});
});
+140
View File
@@ -292,4 +292,144 @@ module('Unit | Service | osm', function (hooks) {
'Call with different lat/lon should trigger fetch' '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(
localStorage.getItem('marco:osm_cache:node:100'),
'Batch fetch does not write to the localStorage 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'
);
});
}); });
+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'
);
});
});