Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d578177ab
|
||
|
|
df9831b7c3
|
||
|
|
78687b63ff
|
||
|
|
d6e47a1b27
|
||
|
|
42cd620c48
|
||
|
|
b9e5213c27
|
||
|
|
d6fddddd4e
|
||
|
|
335ff1a9d8
|
||
|
|
9dc080b86e
|
||
|
|
a457e1521b
|
||
|
|
39ab5e2cd1
|
||
|
|
ce53d42581
|
||
|
|
387fd6efed
|
||
|
|
aaf0d8c3e9
|
||
|
|
9807a2b822
|
||
|
|
ac9870240f
|
||
|
|
1b185d1033
|
||
|
|
4d4ed8c1ea
|
||
|
|
1613ebf0bf
|
||
|
|
4cb8c94c9c
|
||
|
|
1f87c802e1
|
||
|
|
a6576fcda8
|
||
|
|
1822c4893f
|
||
|
|
39dca446ac
|
||
|
|
2e6827f0d0
|
||
|
|
8c3a805684
|
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export default class PhotoCarousel extends Component {
|
|||||||
data-src={{photo.url}}
|
data-src={{photo.url}}
|
||||||
class="place-header-photo
|
class="place-header-photo
|
||||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||||
alt={{@name}}
|
alt={{photo.alt}}
|
||||||
{{fadeInImage photo.url}}
|
{{fadeInImage photo.url}}
|
||||||
/>
|
/>
|
||||||
{{else if this.isGalleryThumbnails}}
|
{{else if this.isGalleryThumbnails}}
|
||||||
@@ -245,7 +245,7 @@ export default class PhotoCarousel extends Component {
|
|||||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||||
class="place-header-photo
|
class="place-header-photo
|
||||||
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
{{if photo.isLandscape 'landscape' 'portrait'}}"
|
||||||
alt={{@name}}
|
alt={{photo.alt}}
|
||||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -260,7 +260,7 @@ export default class PhotoCarousel extends Component {
|
|||||||
<img
|
<img
|
||||||
data-src={{photo.url}}
|
data-src={{photo.url}}
|
||||||
class="place-header-photo landscape"
|
class="place-header-photo landscape"
|
||||||
alt={{@name}}
|
alt={{photo.alt}}
|
||||||
{{fadeInImage photo.url}}
|
{{fadeInImage photo.url}}
|
||||||
/>
|
/>
|
||||||
</picture>
|
</picture>
|
||||||
@@ -269,7 +269,7 @@ export default class PhotoCarousel extends Component {
|
|||||||
<img
|
<img
|
||||||
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
|
||||||
class="place-header-photo portrait"
|
class="place-header-photo portrait"
|
||||||
alt={{@name}}
|
alt={{photo.alt}}
|
||||||
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { fn } from '@ember/helper';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { modifier } from 'ember-modifier';
|
import { modifier } from 'ember-modifier';
|
||||||
import { task } from 'ember-concurrency';
|
import { task } from 'ember-concurrency';
|
||||||
import { EventFactory } from 'applesauce-factory';
|
import { EventFactory } from 'applesauce-core';
|
||||||
import config from 'marco/config/environment';
|
import config from 'marco/config/environment';
|
||||||
import DropdownMenu from './dropdown-menu';
|
import DropdownMenu from './dropdown-menu';
|
||||||
import PhotoCarousel from './photo-carousel';
|
import PhotoCarousel from './photo-carousel';
|
||||||
@@ -201,21 +201,17 @@ export default class PhotoGallery extends Component {
|
|||||||
const eventId = this.currentPhoto.eventId;
|
const eventId = this.currentPhoto.eventId;
|
||||||
|
|
||||||
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
||||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
|
||||||
const tags = [['e', eventId]];
|
const tags = [['e', eventId]];
|
||||||
|
|
||||||
if (this.currentPhoto.placeIdentifier) {
|
if (this.currentPhoto.placeIdentifier) {
|
||||||
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = {
|
const event = await EventFactory.fromKind(5)
|
||||||
kind: 5,
|
.content('Deleted photo')
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
.modifyPublicTags(() => tags)
|
||||||
content: 'Deleted photo',
|
.as(this.nostrAuth.signer)
|
||||||
tags,
|
.sign();
|
||||||
};
|
|
||||||
|
|
||||||
const event = await factory.sign(template);
|
|
||||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||||
|
|
||||||
// Remove from local store by adding the kind 5 to it
|
// Remove from local store by adding the kind 5 to it
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
@tracked isPublishing = false;
|
@tracked isPublishing = false;
|
||||||
@tracked isDragging = false;
|
@tracked isDragging = false;
|
||||||
@tracked selectedTags = [];
|
@tracked selectedTags = [];
|
||||||
|
@tracked altText = '';
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
return this.args.place || {};
|
return this.args.place || {};
|
||||||
@@ -103,6 +104,7 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
this.file = null;
|
this.file = null;
|
||||||
this.uploadedPhoto = null;
|
this.uploadedPhoto = null;
|
||||||
this.selectedTags = [];
|
this.selectedTags = [];
|
||||||
|
this.altText = '';
|
||||||
if (this.args.onUploadStateChange) {
|
if (this.args.onUploadStateChange) {
|
||||||
this.args.onUploadStateChange(false);
|
this.args.onUploadStateChange(false);
|
||||||
}
|
}
|
||||||
@@ -118,6 +120,11 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
this.selectedTags = [tag];
|
this.selectedTags = [tag];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateAltText(event) {
|
||||||
|
this.altText = event.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
deletePhotoTask = task(async (photoData) => {
|
deletePhotoTask = task(async (photoData) => {
|
||||||
try {
|
try {
|
||||||
if (photoData.hash) {
|
if (photoData.hash) {
|
||||||
@@ -155,8 +162,6 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
this.isPublishing = true;
|
this.isPublishing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
|
||||||
|
|
||||||
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
||||||
|
|
||||||
for (const tag of this.selectedTags) {
|
for (const tag of this.selectedTags) {
|
||||||
@@ -179,7 +184,10 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
imeta.push(`dim ${photo.dim}`);
|
imeta.push(`dim ${photo.dim}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
imeta.push('alt A photo of a place');
|
const alt = this.altText.trim();
|
||||||
|
if (alt) {
|
||||||
|
imeta.push(`alt ${alt}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||||
for (const fallbackUrl of photo.fallbackUrls) {
|
for (const fallbackUrl of photo.fallbackUrls) {
|
||||||
@@ -198,17 +206,11 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
tags.push(imeta);
|
tags.push(imeta);
|
||||||
|
|
||||||
// NIP-XX draft Place Photo event
|
// NIP-XX draft Place Photo event
|
||||||
const template = {
|
const event = await EventFactory.fromKind(360)
|
||||||
kind: 360,
|
.content('')
|
||||||
content: '',
|
.modifyPublicTags(() => tags)
|
||||||
tags,
|
.as(this.nostrAuth.signer)
|
||||||
};
|
.sign();
|
||||||
|
|
||||||
if (!template.created_at) {
|
|
||||||
template.created_at = Math.floor(Date.now() / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await factory.sign(template);
|
|
||||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||||
this.nostrData.store.add(event);
|
this.nostrData.store.add(event);
|
||||||
|
|
||||||
@@ -217,6 +219,7 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
// Clear out the file so user can upload more or be done
|
// Clear out the file so user can upload more or be done
|
||||||
this.file = null;
|
this.file = null;
|
||||||
this.uploadedPhoto = null;
|
this.uploadedPhoto = null;
|
||||||
|
this.altText = '';
|
||||||
|
|
||||||
if (this.args.onUploadStateChange) {
|
if (this.args.onUploadStateChange) {
|
||||||
this.args.onUploadStateChange(false);
|
this.args.onUploadStateChange(false);
|
||||||
@@ -243,13 +246,11 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.file}}
|
{{#if this.file}}
|
||||||
<div class="photo-grid">
|
<PlacePhotoUploadItem
|
||||||
<PlacePhotoUploadItem
|
@file={{this.file}}
|
||||||
@file={{this.file}}
|
@onSuccess={{this.handleUploadSuccess}}
|
||||||
@onSuccess={{this.handleUploadSuccess}}
|
@onRemove={{this.removeFile}}
|
||||||
@onRemove={{this.removeFile}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{#if this.suggestedTags.length}}
|
{{#if this.suggestedTags.length}}
|
||||||
<div class="photo-tag-suggestions">
|
<div class="photo-tag-suggestions">
|
||||||
@@ -271,6 +272,19 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="photo-alt-input">Description (optional):</label>
|
||||||
|
<input
|
||||||
|
id="photo-alt-input"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Describe this photo"
|
||||||
|
value={{this.altText}}
|
||||||
|
disabled={{this.isPublishing}}
|
||||||
|
{{on "input" this.updateAltText}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-publish"
|
class="btn btn-primary btn-publish"
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,16 @@ import { action } from '@ember/object';
|
|||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { task } from 'ember-concurrency';
|
import { task } from 'ember-concurrency';
|
||||||
|
|
||||||
|
function getPlaceTime(place) {
|
||||||
|
const dateVal = place.createdAt;
|
||||||
|
if (!dateVal) return 0;
|
||||||
|
if (typeof dateVal === 'number') {
|
||||||
|
return dateVal;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(dateVal);
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export default class ListsListController extends Controller {
|
export default class ListsListController extends Controller {
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@@ -73,7 +83,7 @@ export default class ListsListController extends Controller {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return merged;
|
return merged.sort((a, b) => getPlaceTime(b) - getPlaceTime(a));
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|||||||
@@ -179,10 +179,8 @@ export default class SearchController extends Controller {
|
|||||||
const targetName = params.selected || params.q;
|
const targetName = params.selected || params.q;
|
||||||
|
|
||||||
if (targetName && pois.length > 0) {
|
if (targetName && pois.length > 0) {
|
||||||
let matchedPlace = null;
|
|
||||||
|
|
||||||
// 1. Exact Name Match
|
// 1. Exact Name Match
|
||||||
matchedPlace = pois.find(
|
let matchedPlace = pois.find(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.osmTags &&
|
p.osmTags &&
|
||||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-10
@@ -35,23 +35,20 @@ export default class BlossomService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _getAuthHeader(action, hash, serverUrl) {
|
async _getAuthHeader(action, hash, serverUrl) {
|
||||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const serverHostname = new URL(serverUrl).hostname;
|
const serverHostname = new URL(serverUrl).hostname;
|
||||||
|
|
||||||
const authTemplate = {
|
const authEvent = await EventFactory.fromKind(24242)
|
||||||
kind: 24242,
|
.content(action === 'upload' ? 'Upload photo for place' : 'Delete photo')
|
||||||
created_at: now,
|
.modifyPublicTags(() => [
|
||||||
content: action === 'upload' ? 'Upload photo for place' : 'Delete photo',
|
|
||||||
tags: [
|
|
||||||
['t', action],
|
['t', action],
|
||||||
['x', hash],
|
['x', hash],
|
||||||
['expiration', String(now + 3600)],
|
['expiration', String(now + 3600)],
|
||||||
['server', serverHostname],
|
['server', serverHostname],
|
||||||
],
|
])
|
||||||
};
|
.created(now)
|
||||||
|
.as(this.nostrAuth.signer)
|
||||||
const authEvent = await factory.sign(authTemplate);
|
.sign();
|
||||||
const base64 = btoa(JSON.stringify(authEvent));
|
const base64 = btoa(JSON.stringify(authEvent));
|
||||||
const base64url = base64
|
const base64url = base64
|
||||||
.replace(/\+/g, '-')
|
.replace(/\+/g, '-')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ export default class ImageProcessorService extends Service {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to process image: ${e.message}`);
|
throw new Error(`Failed to process image: ${e.message}`, { cause: e });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key';
|
|||||||
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
|
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
|
||||||
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
||||||
|
|
||||||
const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
|
const DEFAULT_CONNECT_RELAY = 'wss://nostr.kosmos.org';
|
||||||
|
|
||||||
import { isMobile } from '../utils/device';
|
import { isMobile } from '../utils/device';
|
||||||
|
|
||||||
@@ -150,9 +150,6 @@ export default class NostrAuthService extends Service {
|
|||||||
const relay = DEFAULT_CONNECT_RELAY;
|
const relay = DEFAULT_CONNECT_RELAY;
|
||||||
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
|
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
|
||||||
|
|
||||||
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
|
||||||
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
|
||||||
|
|
||||||
this._signerInstance = new NostrConnectSigner({
|
this._signerInstance = new NostrConnectSigner({
|
||||||
pool: this.nostrRelay.pool,
|
pool: this.nostrRelay.pool,
|
||||||
relays: [relay],
|
relays: [relay],
|
||||||
@@ -235,9 +232,6 @@ export default class NostrAuthService extends Service {
|
|||||||
|
|
||||||
const localSigner = this._getLocalSigner();
|
const localSigner = this._getLocalSigner();
|
||||||
|
|
||||||
// Override aggressive 10s EOSE timeout to allow time for QR scanning
|
|
||||||
this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes
|
|
||||||
|
|
||||||
this._signerInstance = new NostrConnectSigner({
|
this._signerInstance = new NostrConnectSigner({
|
||||||
pool: this.nostrRelay.pool,
|
pool: this.nostrRelay.pool,
|
||||||
relays: [relay],
|
relays: [relay],
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export default class OsmAuthService extends Service {
|
|||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
redirectUrl: redirectUrl,
|
redirectUrl: redirectUrl,
|
||||||
storeRefreshToken: true,
|
storeRefreshToken: true,
|
||||||
|
onAccessTokenExpiry() {
|
||||||
|
return this.exchangeRefreshTokenForAccessToken();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
new MarcoOsmAuthStorage()
|
new MarcoOsmAuthStorage()
|
||||||
);
|
);
|
||||||
|
|||||||
+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;
|
||||||
|
|
||||||
|
|||||||
+104
-9
@@ -245,13 +245,6 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo-upload-item {
|
.photo-upload-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 4 / 3;
|
||||||
@@ -259,6 +252,7 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #1e262e;
|
background: #1e262e;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-upload-item img {
|
.photo-upload-item img {
|
||||||
@@ -1530,7 +1524,6 @@ button.create-place {
|
|||||||
color: #5f6368;
|
color: #5f6368;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
padding-left: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-submit-btn:hover {
|
.search-submit-btn:hover {
|
||||||
@@ -1867,8 +1860,12 @@ button.create-place {
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.place-photo-upload .form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.photo-tag-suggestions {
|
.photo-tag-suggestions {
|
||||||
margin: 1rem 0 1.5rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-tag-suggestions-title {
|
.photo-tag-suggestions-title {
|
||||||
@@ -2261,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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -102,7 +102,7 @@ import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
|||||||
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||||
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||||
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||||
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
|
import parkingP from '@waysidemapping/pinhead/dist/icons/p_wide.svg?raw';
|
||||||
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
||||||
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
||||||
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
|
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
|
||||||
@@ -118,7 +118,7 @@ import policeOfficerWithStopArm from '@waysidemapping/pinhead/dist/icons/police_
|
|||||||
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
|
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
|
||||||
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
|
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
|
||||||
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
|
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
|
||||||
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
|
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/open_scissors.svg?raw';
|
||||||
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
|
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
|
||||||
import steamTrainOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/steam_train_on_railway_track.svg?raw';
|
import steamTrainOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/steam_train_on_railway_track.svg?raw';
|
||||||
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
|
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function parsePlacePhotos(events) {
|
|||||||
let blurhash = null;
|
let blurhash = null;
|
||||||
let isLandscape = false;
|
let isLandscape = false;
|
||||||
let aspectRatio = 16 / 9; // default
|
let aspectRatio = 16 / 9; // default
|
||||||
|
let altText = null;
|
||||||
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
||||||
|
|
||||||
for (const tag of imeta.slice(1)) {
|
for (const tag of imeta.slice(1)) {
|
||||||
@@ -85,6 +86,10 @@ export function parsePlacePhotos(events) {
|
|||||||
isLandscape = true;
|
isLandscape = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (tag.startsWith('alt ')) {
|
||||||
|
const alt = tag.substring(4).trim();
|
||||||
|
// Strip the legacy placeholder we used to write on every event
|
||||||
|
altText = alt === 'A photo of a place' ? null : alt || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +105,7 @@ export function parsePlacePhotos(events) {
|
|||||||
aspectRatio,
|
aspectRatio,
|
||||||
placeIdentifier,
|
placeIdentifier,
|
||||||
tags: eventTagValues,
|
tags: eventTagValues,
|
||||||
|
alt: altText,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ export default {
|
|||||||
'@babel/plugin-transform-runtime',
|
'@babel/plugin-transform-runtime',
|
||||||
{
|
{
|
||||||
absoluteRuntime: dirname(fileURLToPath(import.meta.url)),
|
absoluteRuntime: dirname(fileURLToPath(import.meta.url)),
|
||||||
useESModules: true,
|
|
||||||
regenerator: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
...macros.babelMacros,
|
...macros.babelMacros,
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
|
|||||||
import qunit from 'eslint-plugin-qunit';
|
import qunit from 'eslint-plugin-qunit';
|
||||||
import n from 'eslint-plugin-n';
|
import n from 'eslint-plugin-n';
|
||||||
|
|
||||||
import babelParser from '@babel/eslint-parser/experimental-worker';
|
import babelParser from '@babel/eslint-parser';
|
||||||
|
|
||||||
const esmParserOptions = {
|
const esmParserOptions = {
|
||||||
ecmaFeatures: { modules: true },
|
ecmaFeatures: { modules: true },
|
||||||
|
|||||||
+53
-54
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.25.0",
|
"version": "1.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -39,61 +39,61 @@
|
|||||||
"version": "pnpm build && git add release/"
|
"version": "pnpm build && git add release/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "^8.0.1",
|
||||||
"@babel/eslint-parser": "^7.28.5",
|
"@babel/eslint-parser": "^8.0.1",
|
||||||
"@babel/plugin-transform-runtime": "^7.28.5",
|
"@babel/plugin-transform-runtime": "^8.0.1",
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^8.0.0",
|
||||||
"@ember/test-helpers": "^5.4.1",
|
"@ember/test-helpers": "^5.4.3",
|
||||||
"@ember/test-waiters": "^4.1.1",
|
"@ember/test-waiters": "^4.1.2",
|
||||||
"@embroider/core": "^4.4.2",
|
"@embroider/core": "^4.6.3",
|
||||||
"@embroider/legacy-inspector-support": "^0.1.3",
|
"@embroider/legacy-inspector-support": "^0.1.3",
|
||||||
"@embroider/macros": "^1.19.6",
|
"@embroider/macros": "^1.20.6",
|
||||||
"@embroider/router": "^3.0.6",
|
"@embroider/router": "^3.0.6",
|
||||||
"@embroider/vite": "^1.5.0",
|
"@embroider/vite": "^1.7.9",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@glimmer/component": "^2.0.0",
|
"@glimmer/component": "^2.1.1",
|
||||||
"@remotestorage/module-places": "~1.3.0",
|
"@remotestorage/module-places": "~1.3.0",
|
||||||
"@rollup/plugin-babel": "^6.1.0",
|
"@rollup/plugin-babel": "^7.1.0",
|
||||||
"@warp-drive/core": "~5.8.0",
|
"@warp-drive/core": "~5.8.2",
|
||||||
"@warp-drive/ember": "~5.8.0",
|
"@warp-drive/ember": "~5.8.2",
|
||||||
"@warp-drive/json-api": "~5.8.0",
|
"@warp-drive/json-api": "~5.8.2",
|
||||||
"@warp-drive/legacy": "~5.8.0",
|
"@warp-drive/legacy": "~5.8.2",
|
||||||
"@warp-drive/utilities": "~5.8.0",
|
"@warp-drive/utilities": "~5.8.2",
|
||||||
"babel-plugin-ember-template-compilation": "^3.0.1",
|
"babel-plugin-ember-template-compilation": "^4.0.0",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^10.0.4",
|
||||||
"decorator-transforms": "^2.3.1",
|
"decorator-transforms": "^2.4.0",
|
||||||
"ember-cli": "^6.10.0",
|
"ember-cli": "^7.1.0",
|
||||||
"ember-cli-deprecation-workflow": "^4.0.0",
|
"ember-cli-deprecation-workflow": "^4.0.1",
|
||||||
"ember-modifier": "^4.2.2",
|
"ember-modifier": "^4.3.0",
|
||||||
"ember-page-title": "^9.0.3",
|
"ember-page-title": "^9.0.3",
|
||||||
"ember-qunit": "^9.0.4",
|
"ember-qunit": "^9.1.0",
|
||||||
"ember-resolver": "^13.1.1",
|
"ember-resolver": "^13.2.0",
|
||||||
"ember-source": "~6.11.0-alpha.6",
|
"ember-source": "~7.2.0",
|
||||||
"ember-template-lint": "^7.9.3",
|
"ember-template-lint": "^7.9.3",
|
||||||
"ember-truth-helpers": "^5.0.0",
|
"ember-truth-helpers": "^5.0.0",
|
||||||
"ember-welcome-page": "^8.0.4",
|
"ember-welcome-page": "^8.0.4",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^10.8.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-ember": "^12.7.5",
|
"eslint-plugin-ember": "^13.5.0",
|
||||||
"eslint-plugin-n": "^17.23.1",
|
"eslint-plugin-n": "^18.3.0",
|
||||||
"eslint-plugin-qunit": "^8.2.5",
|
"eslint-plugin-qunit": "^8.2.6",
|
||||||
"eslint-plugin-warp-drive": "^5.8.0",
|
"eslint-plugin-warp-drive": "^5.8.2",
|
||||||
"feather-icons": "^4.29.2",
|
"feather-icons": "^4.29.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^17.11.0",
|
||||||
"latlon-geohash": "^2.0.0",
|
"latlon-geohash": "^2.0.0",
|
||||||
"ol": "^10.7.0",
|
"ol": "^10.10.0",
|
||||||
"ol-mapbox-style": "^13.2.0",
|
"ol-mapbox-style": "^13.4.2",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-ember-template-tag": "^2.1.2",
|
"prettier-plugin-ember-template-tag": "^2.1.7",
|
||||||
"qunit": "^2.25.0",
|
"qunit": "^2.26.0",
|
||||||
"qunit-dom": "^3.5.0",
|
"qunit-dom": "^3.6.0",
|
||||||
"remotestorage-widget": "^1.8.1",
|
"remotestorage-widget": "^1.8.1",
|
||||||
"remotestoragejs": "2.0.0-beta.9",
|
"remotestoragejs": "2.0.0-beta.10",
|
||||||
"sinon": "^21.0.1",
|
"sinon": "^22.1.0",
|
||||||
"stylelint": "^16.26.1",
|
"stylelint": "^17.14.1",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^40.0.0",
|
||||||
"testem": "^3.17.0",
|
"testem": "^3.20.1",
|
||||||
"vite": "^7.3.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 22"
|
"node": ">= 22"
|
||||||
@@ -102,17 +102,16 @@
|
|||||||
"edition": "octane"
|
"edition": "octane"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.3.0",
|
||||||
"@waysidemapping/pinhead": "^15.20.0",
|
"@waysidemapping/pinhead": "^15.25.0",
|
||||||
"applesauce-core": "^5.2.0",
|
"applesauce-core": "^6.2.0",
|
||||||
"applesauce-factory": "^4.0.0",
|
"applesauce-relay": "^6.2.1",
|
||||||
"applesauce-relay": "^5.2.0",
|
"applesauce-signers": "^6.2.2",
|
||||||
"applesauce-signers": "^5.2.0",
|
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"ember-concurrency": "^5.2.0",
|
"ember-concurrency": "^5.2.0",
|
||||||
"ember-lifeline": "^7.0.0",
|
"ember-lifeline": "^7.1.0",
|
||||||
"nostr-idb": "^5.0.0",
|
"nostr-idb": "^5.1.0",
|
||||||
"oauth2-pkce": "^2.1.3",
|
"oauth2-pkce": "^3.0.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
diff --git a/async-arrow-task-transform.js b/async-arrow-task-transform.js
|
||||||
|
index 4eb856a2a7753ef583e10e07f2b602c0c1c0c21d..6a808d035834567dcfc491ecaa7f59e77d344887 100644
|
||||||
|
--- a/async-arrow-task-transform.js
|
||||||
|
+++ b/async-arrow-task-transform.js
|
||||||
|
@@ -331,7 +331,7 @@ function cleanupUnusedTaskFactoryImports(programPath, usedTaskFactories) {
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = declare((api) => {
|
||||||
|
- api.assertVersion(7);
|
||||||
|
+ // api.assertVersion(7);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'transform-ember-concurrency-async-function-tasks',
|
||||||
Generated
+3718
-2586
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
core-js: true
|
||||||
|
esbuild: true
|
||||||
|
patchedDependencies:
|
||||||
|
ember-concurrency@5.2.0: patches/ember-concurrency@5.2.0.patch
|
||||||
@@ -1 +0,0 @@
|
|||||||
!function(){"use strict";var t=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","#","$","%","*","+",",","-",".",":",";","=","?","@","[","]","^","_","{","|","}","~"],e=(e,a)=>{var o="";for(let r=1;r<=a;r++){let h=Math.floor(e)/Math.pow(83,a-r)%83;o+=t[Math.floor(h)]}return o},a=t=>{let e=t/255;return e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)},o=t=>{let e=Math.max(0,Math.min(1,t));return e<=.0031308?Math.trunc(12.92*e*255+.5):Math.trunc(255*(1.055*Math.pow(e,.4166666666666667)-.055)+.5)},r=(t,e)=>(t=>t<0?-1:1)(t)*Math.pow(Math.abs(t),e),h=class extends Error{constructor(t){super(t),this.name="ValidationError",this.message=t}},i=(t,e,o,r)=>{let h=0,i=0,n=0,s=4*e;for(let g=0;g<e;g++){let e=4*g;for(let l=0;l<o;l++){let o=e+l*s,c=r(g,l);h+=c*a(t[o]),i+=c*a(t[o+1]),n+=c*a(t[o+2])}}let l=1/(e*o);return[h*l,i*l,n*l]};self.onmessage=async t=>{if("PROCESS_IMAGE"!==t.data?.type)return;const{id:a,file:n,targetWidth:s,targetHeight:l,quality:g,computeBlurhash:c}=t.data;try{let t,M;try{const e=await createImageBitmap(n,{resizeWidth:s,resizeHeight:l,resizeQuality:"high"});if(t=new OffscreenCanvas(s,l),M=t.getContext("2d"),!M)throw new Error("Failed to get 2d context from OffscreenCanvas");M.drawImage(e,0,0,s,l),e.close()}catch(f){console.warn("Hardware resize failed, falling back to stepped software scaling:",f);const e=await n.arrayBuffer(),a=new Blob([e],{type:n.type}),o=await createImageBitmap(a);let r=o.width,h=o.height,i=new OffscreenCanvas(r,h),g=i.getContext("2d");for(g.imageSmoothingEnabled=!0,g.imageSmoothingQuality="high",g.drawImage(o,0,0);.5*i.width>s&&.5*i.height>l;){const t=new OffscreenCanvas(Math.floor(.5*i.width),Math.floor(.5*i.height)),e=t.getContext("2d");e.imageSmoothingEnabled=!0,e.imageSmoothingQuality="high",e.drawImage(i,0,0,t.width,t.height),i=t}t=new OffscreenCanvas(s,l),M=t.getContext("2d"),M.imageSmoothingEnabled=!0,M.imageSmoothingQuality="high",M.drawImage(i,0,0,s,l),o.close()}let d=null;if(c)try{d=((t,a,n)=>{if(a*n*4!==t.length)throw new h("Width and height must match the pixels array");let s=[];for(let e=0;e<3;e++)for(let o=0;o<4;o++){let r=0==o&&0==e?1:2,h=i(t,a,n,(t,h)=>r*Math.cos(Math.PI*o*t/a)*Math.cos(Math.PI*e*h/n));s.push(h)}let l,g=s[0],c=s.slice(1),f="";if(f+=e(21,1),c.length>0){let t=Math.max(...c.map(t=>Math.max(...t))),a=Math.floor(Math.max(0,Math.min(82,Math.floor(166*t-.5))));l=(a+1)/166,f+=e(a,1)}else l=1,f+=e(0,1);return f+=e((t=>(o(t[0])<<16)+(o(t[1])<<8)+o(t[2]))(g),4),c.forEach(t=>{f+=e(((t,e)=>19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[0]/e,.5)+9.5))))*19+19*Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[1]/e,.5)+9.5))))+Math.floor(Math.max(0,Math.min(18,Math.floor(9*r(t[2]/e,.5)+9.5)))))(t,l),2)}),f})(M.getImageData(0,0,s,l).data,s,l)}catch(m){console.warn("Could not generate blurhash (possible canvas fingerprinting protection):",m)}const u=await t.convertToBlob({type:"image/jpeg",quality:g}),w=`${s}x${l}`;self.postMessage({id:a,success:!0,blob:u,dim:w,blurhash:d})}catch(M){self.postMessage({id:a,success:!1,error:M.message})}}}();
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
var q = [
|
||||||
|
"0",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
|
"7",
|
||||||
|
"8",
|
||||||
|
"9",
|
||||||
|
"A",
|
||||||
|
"B",
|
||||||
|
"C",
|
||||||
|
"D",
|
||||||
|
"E",
|
||||||
|
"F",
|
||||||
|
"G",
|
||||||
|
"H",
|
||||||
|
"I",
|
||||||
|
"J",
|
||||||
|
"K",
|
||||||
|
"L",
|
||||||
|
"M",
|
||||||
|
"N",
|
||||||
|
"O",
|
||||||
|
"P",
|
||||||
|
"Q",
|
||||||
|
"R",
|
||||||
|
"S",
|
||||||
|
"T",
|
||||||
|
"U",
|
||||||
|
"V",
|
||||||
|
"W",
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
"d",
|
||||||
|
"e",
|
||||||
|
"f",
|
||||||
|
"g",
|
||||||
|
"h",
|
||||||
|
"i",
|
||||||
|
"j",
|
||||||
|
"k",
|
||||||
|
"l",
|
||||||
|
"m",
|
||||||
|
"n",
|
||||||
|
"o",
|
||||||
|
"p",
|
||||||
|
"q",
|
||||||
|
"r",
|
||||||
|
"s",
|
||||||
|
"t",
|
||||||
|
"u",
|
||||||
|
"v",
|
||||||
|
"w",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"#",
|
||||||
|
"$",
|
||||||
|
"%",
|
||||||
|
"*",
|
||||||
|
"+",
|
||||||
|
",",
|
||||||
|
"-",
|
||||||
|
".",
|
||||||
|
":",
|
||||||
|
";",
|
||||||
|
"=",
|
||||||
|
"?",
|
||||||
|
"@",
|
||||||
|
"[",
|
||||||
|
"]",
|
||||||
|
"^",
|
||||||
|
"_",
|
||||||
|
"{",
|
||||||
|
"|",
|
||||||
|
"}",
|
||||||
|
"~"
|
||||||
|
], p = (t, e) => {
|
||||||
|
var a = "";
|
||||||
|
for (let o = 1; o <= e; o++) {
|
||||||
|
let h = Math.floor(t) / Math.pow(83, e - o) % 83;
|
||||||
|
a += q[Math.floor(h)];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}, f = (t) => {
|
||||||
|
let e = t / 255;
|
||||||
|
return e <= .04045 ? e / 12.92 : Math.pow((e + .055) / 1.055, 2.4);
|
||||||
|
}, h = (t) => {
|
||||||
|
let e = Math.max(0, Math.min(1, t));
|
||||||
|
return e <= .0031308 ? Math.trunc(12.92 * e * 255 + .5) : Math.trunc(255 * (1.055 * Math.pow(e, .4166666666666667) - .055) + .5);
|
||||||
|
}, M = (t, e) => ((t) => t < 0 ? -1 : 1)(t) * Math.pow(Math.abs(t), e), d = class extends Error {
|
||||||
|
constructor(t) {
|
||||||
|
super(t), this.name = "ValidationError", this.message = t;
|
||||||
|
}
|
||||||
|
}, D = (t, e, a, o) => {
|
||||||
|
let h = 0, r = 0, n = 0, i = 4 * e;
|
||||||
|
for (let l = 0; l < e; l++) {
|
||||||
|
let e = 4 * l;
|
||||||
|
for (let s = 0; s < a; s++) {
|
||||||
|
let a = e + s * i, g = o(l, s);
|
||||||
|
h += g * f(t[a]), r += g * f(t[a + 1]), n += g * f(t[a + 2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let s = 1 / (e * a);
|
||||||
|
return [
|
||||||
|
h * s,
|
||||||
|
r * s,
|
||||||
|
n * s
|
||||||
|
];
|
||||||
|
}, S = (t, e, a, o, r) => {
|
||||||
|
if (o < 1 || o > 9 || r < 1 || r > 9) throw new d("BlurHash must have between 1 and 9 components");
|
||||||
|
if (e * a * 4 !== t.length) throw new d("Width and height must match the pixels array");
|
||||||
|
let n = [];
|
||||||
|
for (let h = 0; h < r; h++) for (let r = 0; r < o; r++) {
|
||||||
|
let o = 0 == r && 0 == h ? 1 : 2, i = D(t, e, a, (t, n) => o * Math.cos(Math.PI * r * t / e) * Math.cos(Math.PI * h * n / a));
|
||||||
|
n.push(i);
|
||||||
|
}
|
||||||
|
let i, s = n[0], l = n.slice(1), g = "";
|
||||||
|
if (g += p(o - 1 + 9 * (r - 1), 1), l.length > 0) {
|
||||||
|
let t = Math.max(...l.map((t) => Math.max(...t))), e = Math.floor(Math.max(0, Math.min(82, Math.floor(166 * t - .5))));
|
||||||
|
i = (e + 1) / 166, g += p(e, 1);
|
||||||
|
} else i = 1, g += p(0, 1);
|
||||||
|
return g += p(((t) => (h(t[0]) << 16) + (h(t[1]) << 8) + h(t[2]))(s), 4), l.forEach((t) => {
|
||||||
|
g += p(((t, e) => 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[0] / e, .5) + 9.5)))) * 19 + 19 * Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[1] / e, .5) + 9.5)))) + Math.floor(Math.max(0, Math.min(18, Math.floor(9 * M(t[2] / e, .5) + 9.5)))))(t, i), 2);
|
||||||
|
}), g;
|
||||||
|
};
|
||||||
|
self.onmessage = async (t) => {
|
||||||
|
if ("PROCESS_IMAGE" !== t.data?.type) return;
|
||||||
|
const { id: e, file: a, targetWidth: o, targetHeight: h, quality: r, computeBlurhash: n } = t.data;
|
||||||
|
try {
|
||||||
|
let t, l;
|
||||||
|
try {
|
||||||
|
const e = await createImageBitmap(a, {
|
||||||
|
resizeWidth: o,
|
||||||
|
resizeHeight: h,
|
||||||
|
resizeQuality: "high"
|
||||||
|
});
|
||||||
|
if (t = new OffscreenCanvas(o, h), l = t.getContext("2d"), !l) throw new Error("Failed to get 2d context from OffscreenCanvas");
|
||||||
|
l.drawImage(e, 0, 0, o, h), e.close();
|
||||||
|
} catch (i) {
|
||||||
|
console.warn("Hardware resize failed, falling back to stepped software scaling:", i);
|
||||||
|
const e = await a.arrayBuffer(), r = new Blob([e], { type: a.type }), n = await createImageBitmap(r);
|
||||||
|
let s = n.width, g = n.height, f = new OffscreenCanvas(s, g), c = f.getContext("2d");
|
||||||
|
for (c.imageSmoothingEnabled = !0, c.imageSmoothingQuality = "high", c.drawImage(n, 0, 0); .5 * f.width > o && .5 * f.height > h;) {
|
||||||
|
const t = new OffscreenCanvas(Math.floor(.5 * f.width), Math.floor(.5 * f.height)), e = t.getContext("2d");
|
||||||
|
e.imageSmoothingEnabled = !0, e.imageSmoothingQuality = "high", e.drawImage(f, 0, 0, t.width, t.height), f = t;
|
||||||
|
}
|
||||||
|
t = new OffscreenCanvas(o, h), l = t.getContext("2d"), l.imageSmoothingEnabled = !0, l.imageSmoothingQuality = "high", l.drawImage(f, 0, 0, o, h), n.close();
|
||||||
|
}
|
||||||
|
let g = null;
|
||||||
|
if (n) try {
|
||||||
|
const t = l.getImageData(0, 0, o, h);
|
||||||
|
g = S(t.data, o, h, 4, 3);
|
||||||
|
} catch (s) {
|
||||||
|
console.warn("Could not generate blurhash (possible canvas fingerprinting protection):", s);
|
||||||
|
}
|
||||||
|
const f = await t.convertToBlob({
|
||||||
|
type: "image/jpeg",
|
||||||
|
quality: r
|
||||||
|
}), c = `${o}x${h}`;
|
||||||
|
self.postMessage({
|
||||||
|
id: e,
|
||||||
|
success: !0,
|
||||||
|
blob: f,
|
||||||
|
dim: c,
|
||||||
|
blurhash: g
|
||||||
|
});
|
||||||
|
} catch (l) {
|
||||||
|
self.postMessage({
|
||||||
|
id: e,
|
||||||
|
success: !1,
|
||||||
|
error: l.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -39,8 +39,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-BVNM87jL.js"></script>
|
<script type="module" crossorigin src="/assets/main-MT6QR2E9.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BGF-Udec.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-sIiovt6q.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="modal-portal"></div>
|
<div id="modal-portal"></div>
|
||||||
|
|||||||
@@ -135,4 +135,85 @@ module('Acceptance | collections navigation', function (hooks) {
|
|||||||
'Returns gracefully back to lists/to-go list view'
|
'Returns gracefully back to lists/to-go list view'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('places inside a collection are sorted by createdAt descending', async function (assert) {
|
||||||
|
class SortedMockStorageService extends Service {
|
||||||
|
initialSyncDone = true;
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
id: 'place-oldest',
|
||||||
|
title: 'Oldest Place',
|
||||||
|
geohash: 'u33dc0',
|
||||||
|
createdAt: '2023-01-01T12:00:00.000Z',
|
||||||
|
osmTags: { name: 'Oldest Place' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'place-newest',
|
||||||
|
title: 'Newest Place',
|
||||||
|
geohash: 'u33dc0',
|
||||||
|
createdAt: '2023-01-03T12:00:00.000Z',
|
||||||
|
osmTags: { name: 'Newest Place' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'place-middle',
|
||||||
|
title: 'Middle Place',
|
||||||
|
geohash: 'u33dc0',
|
||||||
|
createdAt: '2023-01-02T12:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-04T12:00:00.000Z',
|
||||||
|
osmTags: { name: 'Middle Place' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
lists = [
|
||||||
|
{
|
||||||
|
id: 'to-go',
|
||||||
|
title: 'Want to go',
|
||||||
|
color: '#2e9e4f',
|
||||||
|
placeRefs: [
|
||||||
|
{ id: 'place-oldest', geohash: 'u33dc0' },
|
||||||
|
{ id: 'place-newest', geohash: 'u33dc0' },
|
||||||
|
{ id: 'place-middle', geohash: 'u33dc0' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
findPlaceById(id) {
|
||||||
|
return this.savedPlaces.find((p) => p.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaceSaved() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlacesInList(listId) {
|
||||||
|
if (listId === 'to-go') {
|
||||||
|
return Promise.resolve(this.savedPlaces);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
rs = {
|
||||||
|
on: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.unregister('service:storage');
|
||||||
|
this.owner.register('service:storage', SortedMockStorageService);
|
||||||
|
|
||||||
|
await visit('/lists/to-go');
|
||||||
|
await waitFor('.places-list');
|
||||||
|
|
||||||
|
const placeNames = Array.from(
|
||||||
|
document.querySelectorAll('.places-list .place-name')
|
||||||
|
).map((el) => el.textContent.trim());
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
placeNames,
|
||||||
|
['Newest Place', 'Middle Place', 'Oldest Place'],
|
||||||
|
'Places are ordered by createdAt in descending order'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,9 @@ import PhotoGallery from 'marco/components/photo-gallery';
|
|||||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
const USER_A = 'a'.repeat(64);
|
||||||
|
const USER_B = 'b'.repeat(64);
|
||||||
|
|
||||||
class MockBlossomService extends Service {
|
class MockBlossomService extends Service {
|
||||||
async delete() {
|
async delete() {
|
||||||
return true;
|
return true;
|
||||||
@@ -34,7 +37,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
this.photos = [
|
this.photos = [
|
||||||
{
|
{
|
||||||
eventId: 'event1',
|
eventId: 'event1',
|
||||||
pubkey: 'userA',
|
pubkey: USER_A,
|
||||||
placeIdentifier: 'osm:node:12345',
|
placeIdentifier: 'osm:node:12345',
|
||||||
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
|
||||||
thumbUrl:
|
thumbUrl:
|
||||||
@@ -42,7 +45,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
eventId: 'event2',
|
eventId: 'event2',
|
||||||
pubkey: 'userB',
|
pubkey: USER_B,
|
||||||
placeIdentifier: 'osm:node:12345',
|
placeIdentifier: 'osm:node:12345',
|
||||||
url: 'photo2.jpg',
|
url: 'photo2.jpg',
|
||||||
},
|
},
|
||||||
@@ -55,7 +58,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it does not show delete button if user is not creator', async function (assert) {
|
test('it does not show delete button if user is not creator', async function (assert) {
|
||||||
this.nostrAuth.pubkey = 'userB'; // Different from photo1's pubkey
|
this.nostrAuth.pubkey = USER_B; // Different from photo1's pubkey
|
||||||
this.selectedPhoto = this.photos[0];
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
@@ -80,7 +83,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
|
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
|
||||||
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
|
this.nostrAuth.pubkey = USER_A; // Matches photo1's pubkey
|
||||||
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
|
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
|
||||||
this.selectedPhoto = this.photos[0];
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it handles cancellation of deletion', async function (assert) {
|
test('it handles cancellation of deletion', async function (assert) {
|
||||||
this.nostrAuth.pubkey = 'userA';
|
this.nostrAuth.pubkey = USER_A;
|
||||||
this.settings.update('experimentalEnablePhotoDeletion', true);
|
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||||
this.selectedPhoto = this.photos[0];
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
@@ -133,7 +136,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it performs full deletion flow when confirmed', async function (assert) {
|
test('it performs full deletion flow when confirmed', async function (assert) {
|
||||||
this.nostrAuth.pubkey = 'userA';
|
this.nostrAuth.pubkey = USER_A;
|
||||||
this.settings.update('experimentalEnablePhotoDeletion', true);
|
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||||
// Override the mock's getter just for this test
|
// Override the mock's getter just for this test
|
||||||
Object.defineProperty(this.nostrAuth, 'signer', {
|
Object.defineProperty(this.nostrAuth, 'signer', {
|
||||||
@@ -141,11 +144,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
get: () => ({
|
get: () => ({
|
||||||
signEvent: async (e) => ({
|
signEvent: async (e) => ({
|
||||||
...e,
|
...e,
|
||||||
id: 'signed-id',
|
id: 'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
sig: 'sig',
|
sig: 'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
pubkey: 'userA',
|
pubkey: USER_A,
|
||||||
}),
|
}),
|
||||||
getPublicKey: async () => 'userA',
|
getPublicKey: async () => USER_A,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
this.selectedPhoto = this.photos[0];
|
this.selectedPhoto = this.photos[0];
|
||||||
@@ -227,7 +230,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it copies event id to clipboard', async function (assert) {
|
test('it copies event id to clipboard', async function (assert) {
|
||||||
this.nostrAuth.pubkey = 'userA';
|
this.nostrAuth.pubkey = USER_A;
|
||||||
this.selectedPhoto = this.photos[0];
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
const clipboardStub = sinon
|
const clipboardStub = sinon
|
||||||
|
|||||||
@@ -97,4 +97,27 @@ module('Integration | Component | place-photo-upload', function (hooks) {
|
|||||||
|
|
||||||
assert.dom('.photo-tag-suggestions').doesNotExist();
|
assert.dom('.photo-tag-suggestions').doesNotExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it renders an optional alt text input after upload selection', async function (assert) {
|
||||||
|
this.place = {
|
||||||
|
title: 'Cafe Alpha',
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
osmTags: { amenity: 'cafe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('#photo-alt-input').doesNotExist();
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
assert.dom('#photo-alt-input').exists();
|
||||||
|
assert
|
||||||
|
.dom('#photo-alt-input')
|
||||||
|
.hasAttribute('placeholder', 'Describe this photo');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -148,6 +148,68 @@ module('Unit | Utility | nostr', function () {
|
|||||||
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
|
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos extracts alt text from imeta', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
[
|
||||||
|
'imeta',
|
||||||
|
'url https://example.com/photo.jpg',
|
||||||
|
'dim 800x600',
|
||||||
|
'alt A sunny terrace overlooking the bay',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 1);
|
||||||
|
assert.strictEqual(photos[0].alt, 'A sunny terrace overlooking the bay');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos strips the legacy placeholder alt text', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
[
|
||||||
|
'imeta',
|
||||||
|
'url https://example.com/photo.jpg',
|
||||||
|
'dim 800x600',
|
||||||
|
'alt A photo of a place',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 1);
|
||||||
|
assert.strictEqual(photos[0].alt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos leaves alt null when not provided', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [['imeta', 'url https://example.com/photo.jpg', 'dim 800x600']],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 1);
|
||||||
|
assert.strictEqual(photos[0].alt, null);
|
||||||
|
});
|
||||||
|
|
||||||
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
|
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
|
||||||
const relays = uniqNormalizedRelays([
|
const relays = uniqNormalizedRelays([
|
||||||
'Relay.example.com',
|
'Relay.example.com',
|
||||||
|
|||||||
Reference in New Issue
Block a user