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>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" @onContributions}}>
|
||||
<Icon @name="activity" @size={{20}} />
|
||||
<span>My Contributions</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
||||
<Icon @name="settings" @size={{20}} />
|
||||
|
||||
@@ -23,6 +23,11 @@ export default class AppMenu extends Component {
|
||||
this.router.transitionTo('lists.index');
|
||||
}
|
||||
|
||||
@action
|
||||
goToContributions() {
|
||||
this.router.transitionTo('contributions');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar app-menu-pane">
|
||||
{{#if (eq this.currentView "menu")}}
|
||||
@@ -30,6 +35,7 @@ export default class AppMenu extends Component {
|
||||
@onNavigate={{this.setView}}
|
||||
@onClose={{@onClose}}
|
||||
@onSavedPlaces={{this.goToSavedPlaces}}
|
||||
@onContributions={{this.goToContributions}}
|
||||
/>
|
||||
|
||||
{{else if (eq this.currentView "settings")}}
|
||||
|
||||
@@ -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 View from 'ol/View.js';
|
||||
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
|
||||
import { containsExtent } from 'ol/extent.js';
|
||||
import Overlay from 'ol/Overlay.js';
|
||||
import LayerGroup from 'ol/layer/Group.js';
|
||||
import VectorLayer from 'ol/layer/Vector.js';
|
||||
@@ -665,7 +666,12 @@ export default class MapComponent extends Component {
|
||||
if (options.preventZoom) {
|
||||
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
||||
// This avoids unnecessary panning if the place is already visible.
|
||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||
// But if the place has a bbox that doesn't fit in the current view, zoom out to fit it.
|
||||
if (selected.bbox && !this.bboxFitsInView(selected.bbox)) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||
}
|
||||
} else if (selected.bbox) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
@@ -678,6 +684,20 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
bboxFitsInView(bbox) {
|
||||
if (!this.mapInstance || !bbox) return true;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const size = this.mapInstance.getSize();
|
||||
const viewExtent = view.calculateExtent(size);
|
||||
|
||||
const min = fromLonLat([bbox.minLon, bbox.minLat]);
|
||||
const max = fromLonLat([bbox.maxLon, bbox.maxLat]);
|
||||
const bboxExtent = [...min, ...max];
|
||||
|
||||
return containsExtent(viewExtent, bboxExtent);
|
||||
}
|
||||
|
||||
zoomToBbox(bbox) {
|
||||
if (!this.mapInstance || !bbox) return;
|
||||
|
||||
|
||||
@@ -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('list', { path: '/:list_id' });
|
||||
});
|
||||
this.route('contributions');
|
||||
this.route('oauth', function () {
|
||||
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 blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked myContributionEvents = [];
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = null;
|
||||
@@ -278,6 +280,57 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
async loadMyContributions(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Reset state and unsubscribe from any previous subscription
|
||||
this.myContributionEvents = [];
|
||||
if (this._contributionsSub) {
|
||||
this._contributionsSub.unsubscribe();
|
||||
this._contributionsSub = null;
|
||||
}
|
||||
|
||||
const filters = [{ kinds: [360, 5], authors: [pubkey] }];
|
||||
|
||||
// 1. Set up reactive store subscription so the timeline updates as events arrive
|
||||
this._contributionsSub = this.store
|
||||
.timeline(filters)
|
||||
.subscribe((events) => {
|
||||
this.myContributionEvents = events;
|
||||
});
|
||||
|
||||
// 2. Populate the store from the local Nostr IDB cache (instant)
|
||||
try {
|
||||
await this._cachePromise;
|
||||
|
||||
const cachedEvents = await this.cache.query(filters);
|
||||
|
||||
if (cachedEvents && cachedEvents.length > 0) {
|
||||
for (const event of cachedEvents) {
|
||||
this.store.add(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[nostr-data] Failed to read my contributions from local Nostr IDB cache',
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Request fresh events from the network in the background
|
||||
this.nostrRelay.pool.request(this.activeReadRelays, filters).subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching my contribution events:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadProfile(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
@@ -409,6 +462,10 @@ export default class NostrDataService extends Service {
|
||||
this._photosSub.unsubscribe();
|
||||
this._photosSub = null;
|
||||
}
|
||||
if (this._contributionsSub) {
|
||||
this._contributionsSub.unsubscribe();
|
||||
this._contributionsSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
|
||||
+219
-19
@@ -10,6 +10,102 @@ export default class OsmService extends Service {
|
||||
lastQueryKey = null;
|
||||
cachedPlaces = new Map();
|
||||
|
||||
// Long-term cache for OSM place metadata, persisted to localStorage so that
|
||||
// names and basic info survive across sessions and can be rendered instantly
|
||||
// without waiting on the OSM API. Entries are refreshed in the background.
|
||||
static CACHE_KEY_PREFIX = 'marco:osm_cache:';
|
||||
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() {
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
@@ -233,13 +329,46 @@ out center;
|
||||
async fetchOsmObject(osmId, osmType) {
|
||||
if (!osmId || !osmType) return null;
|
||||
|
||||
const cacheKey = `${osmType}:${osmId}`;
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
const cached = this.cachedPlaces.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < 10000) {
|
||||
if (cached && Date.now() - cached.timestamp < OsmService.IN_MEMORY_TTL_MS) {
|
||||
console.debug(`Using in-memory cached OSM object for ${cacheKey}`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (osmType === 'node') {
|
||||
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
|
||||
@@ -263,29 +392,100 @@ out center;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const normalizedData = this.normalizeOsmApiData(
|
||||
data.elements,
|
||||
osmId,
|
||||
osmType
|
||||
);
|
||||
|
||||
this.cachedPlaces.set(cacheKey, {
|
||||
data: normalizedData,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Cleanup cache entry automatically after 10 seconds
|
||||
setTimeout(() => {
|
||||
this.cachedPlaces.delete(cacheKey);
|
||||
}, 10000);
|
||||
|
||||
return normalizedData;
|
||||
return this.normalizeOsmApiData(data.elements, osmId, osmType);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch OSM object:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetches multiple OSM objects, using cached data where available and
|
||||
* only making network requests for uncached IDs.
|
||||
*
|
||||
* @param {Array<{osmType: string, osmId: string}>} items
|
||||
* @returns {Promise<Map<string, object>>} Map keyed by `${osmType}:${osmId}`
|
||||
*/
|
||||
async fetchOsmObjectsBatch(items) {
|
||||
const result = new Map();
|
||||
if (!items || items.length === 0) return result;
|
||||
|
||||
// 1. Collect whatever is already cached
|
||||
const missing = [];
|
||||
for (const { osmType, osmId } of items) {
|
||||
if (!osmType || !osmId) continue;
|
||||
const cacheKey = this._buildCacheKey(osmType, osmId);
|
||||
const cached = this.getCachedOsmObject(osmType, osmId);
|
||||
if (cached) {
|
||||
result.set(cacheKey, cached);
|
||||
} else {
|
||||
missing.push({ osmType, osmId, cacheKey });
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0) return result;
|
||||
|
||||
// 2. Group missing items by OSM type for batched API requests
|
||||
const byType = new Map();
|
||||
for (const item of missing) {
|
||||
if (!byType.has(item.osmType)) byType.set(item.osmType, []);
|
||||
byType.get(item.osmType).push(item);
|
||||
}
|
||||
|
||||
// 3. Fetch each type group from the OSM API (one request per type)
|
||||
const fetchPromises = [];
|
||||
for (const [osmType, group] of byType) {
|
||||
fetchPromises.push(this._fetchOsmObjectsByType(osmType, group, result));
|
||||
}
|
||||
|
||||
await Promise.allSettled(fetchPromises);
|
||||
return result;
|
||||
}
|
||||
|
||||
async _fetchOsmObjectsByType(osmType, group, result) {
|
||||
// OSM API supports fetching multiple elements of the same type in a single
|
||||
// request using comma-separated IDs (max ~50 per request).
|
||||
//
|
||||
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
|
||||
// nodes, so normalized data may lack lat/lon and geometry. We intentionally
|
||||
// do NOT write these results to the general OSM cache — only the returned
|
||||
// Map is used by the caller (the contributions service manages its own
|
||||
// name cache). This prevents incomplete data from breaking place detail
|
||||
// navigation, which needs the full single-object endpoint.
|
||||
const MAX_IDS_PER_REQUEST = 50;
|
||||
|
||||
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
|
||||
const chunk = group.slice(i, i + MAX_IDS_PER_REQUEST);
|
||||
const idsParam = chunk.map((c) => c.osmId).join(',');
|
||||
const plural = osmType === 'way' ? 'ways' : osmType + 's';
|
||||
const url = `https://www.openstreetmap.org/api/0.6/${plural}.json?${plural}=${idsParam}`;
|
||||
|
||||
try {
|
||||
const res = await this.fetchWithRetry(url);
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[osm] Batch fetch failed for ${osmType} (${res.status})`,
|
||||
idsParam
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
for (const item of chunk) {
|
||||
const normalized = this.normalizeOsmApiData(
|
||||
data.elements,
|
||||
item.osmId,
|
||||
osmType
|
||||
);
|
||||
if (normalized) {
|
||||
result.set(item.cacheKey, normalized);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[osm] Batch fetch error for ${osmType}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizeOsmApiData(elements, targetId, targetType) {
|
||||
if (!elements || elements.length === 0) return null;
|
||||
|
||||
|
||||
@@ -2258,3 +2258,101 @@ button.create-place {
|
||||
justify-content: center;
|
||||
padding: 4rem 1rem;
|
||||
}
|
||||
|
||||
/* Contributions Timeline */
|
||||
.contributions-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: -1rem -1rem 0;
|
||||
}
|
||||
|
||||
.contribution-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
background: var(--primary-background-color);
|
||||
color: var(--body-text-color);
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contribution-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.contribution-thumb {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.contribution-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contribution-thumb-badge {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
background: rgb(0 0 0 / 65%);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.contribution-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.contribution-place {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contribution-name-loading {
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.contribution-meta {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.contribution-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.contribution-tag {
|
||||
background: #f0f0f0;
|
||||
color: #555;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export default class ApplicationComponent extends Component {
|
||||
@service router;
|
||||
|
||||
get isSidebarOpen() {
|
||||
// We consider the sidebar "open" if we are in search, menu, lists or place routes AND it's visible.
|
||||
// We consider the sidebar "open" if we are in search, menu, lists, contributions or place routes AND it's visible.
|
||||
// This helps the map know if it should shift the center or adjust view.
|
||||
const name = this.router.currentRouteName;
|
||||
return (
|
||||
@@ -22,6 +22,7 @@ export default class ApplicationComponent extends Component {
|
||||
name === 'place.new' ||
|
||||
name === 'search' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name.startsWith('lists'))
|
||||
);
|
||||
}
|
||||
@@ -49,11 +50,16 @@ export default class ApplicationComponent extends Component {
|
||||
name === 'search' ||
|
||||
name === 'place' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.mapUi.clearSelection();
|
||||
this.mapUi.hideSidebar();
|
||||
if (name === 'menu' || name.startsWith('lists')) {
|
||||
if (
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.router.transitionTo('index');
|
||||
} else if (name === 'place') {
|
||||
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
|
||||
@@ -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) {
|
||||
this.mapUi.showSidebar();
|
||||
const { name, model } = this.mapUi.returnToRoute;
|
||||
this.router.transitionTo(name, model);
|
||||
if (model !== undefined) {
|
||||
this.router.transitionTo(name, model);
|
||||
} else {
|
||||
this.router.transitionTo(name);
|
||||
}
|
||||
}
|
||||
// If we have an active search context, return to it (UP navigation)
|
||||
else if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||
|
||||
@@ -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 '';
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
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