Merge pull request 'Add timeline/history for signed-in user's contributions' (#74) from feature/contributions_timeline into master
Reviewed-on: #74
This commit was merged in pull request #74.
This commit is contained in:
@@ -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}} />
|
||||||
|
|||||||
@@ -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")}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user