Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d17f4e1a9
|
||
|
|
cf253098fe
|
||
|
|
b127a2b0d6
|
||
|
|
9200e6f5c8
|
||
|
|
bf09101a24
|
||
|
|
df720d1b21
|
||
|
|
e74c01b1d1
|
||
|
|
0f6272006c
|
||
|
|
85de25032e
|
||
|
|
b9103b28a9
|
||
|
|
b8a9c71657
|
||
|
|
a7b0b52a58
|
||
|
|
12728f449b
|
||
|
|
fe5e85b029
|
||
|
|
c6a0b0da5e
|
||
|
|
553faa5edd
|
||
|
|
dda0a9846d
|
||
|
|
4f423c8abc
|
||
|
|
231a08c2f9
|
||
|
|
7fccc56c92
|
||
|
|
878387b9a8
|
||
|
|
0f6bfb7e74
|
||
|
|
faea3d26cd
|
||
|
|
a0eea6024a
|
||
|
|
ec0751de14
|
||
|
|
b295636799
|
||
|
|
7a8d4531f7
|
||
|
|
12e746c2a3
|
||
|
|
cb60e1d9b5
|
||
|
|
75c0862134
|
||
|
|
6ba2f1d132
|
||
|
|
a41ad8c99c
|
No files matched your search
@@ -8,8 +8,11 @@
|
||||
<br>
|
||||
|
||||
Marco (as in [Marco Polo][1]) is an unhosted maps application that respects
|
||||
your privacy and choices. It allows users to connect their own remote storage
|
||||
to sync place bookmarks across apps and devices.
|
||||
your privacy and choices.
|
||||
|
||||
It allows users to connect their own remote storage to sync place bookmarks
|
||||
across apps and devices. It also supports the decentralized sharing of place
|
||||
photos (and soon reviews) via [Nostr][2].
|
||||
|
||||
This README outlines the details of collaborating on this Ember application, or
|
||||
just building and deploying it for yourself.
|
||||
@@ -79,3 +82,4 @@ To run the script, you need `imagemagick` and `librsvg` installed:
|
||||
- [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
|
||||
|
||||
[1]: https://en.wikipedia.org/wiki/Marco_Polo
|
||||
[2]: https://nostr.org
|
||||
@@ -9,7 +9,7 @@ import SearchBox from '#components/search-box';
|
||||
import CategoryChips from '#components/category-chips';
|
||||
import { and } from 'ember-truth-helpers';
|
||||
import cachedImage from '../modifiers/cached-image';
|
||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||
import { getCategoryById } from '../utils/poi-categories';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@@ -43,7 +43,7 @@ export default class AppHeaderComponent extends Component {
|
||||
if (qp?.q) {
|
||||
this.searchQuery = qp.q;
|
||||
} else if (qp?.category) {
|
||||
const category = POI_CATEGORIES.find((c) => c.id === qp.category);
|
||||
const category = getCategoryById(qp.category);
|
||||
this.searchQuery = category ? category.label : qp.category;
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
|
||||
@@ -20,6 +20,7 @@ const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||
export default class AppMenuSettingsNostr extends Component {
|
||||
@service settings;
|
||||
@service nostrData;
|
||||
@service nostrAuth;
|
||||
@service toast;
|
||||
|
||||
@tracked newReadRelay = '';
|
||||
@@ -367,109 +368,111 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-write-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newWriteRelay}}
|
||||
{{on "input" this.updateNewWriteRelay}}
|
||||
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addWriteRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.hasWriteOverrides}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetWriteRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nostr-media-server">Media server</label>
|
||||
<select
|
||||
id="nostr-media-server"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrMediaServer")}}
|
||||
>
|
||||
{{#each this.mediaServerOptions as |server|}}
|
||||
<option
|
||||
value={{server}}
|
||||
selected={{if
|
||||
(eq server this.settings.nostrMediaServer)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{server}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{#if this.hasMultipleMediaServers}}
|
||||
{{#if this.nostrAuth.isConnected}}
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||
servers</label>
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-write-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newWriteRelay}}
|
||||
{{on "input" this.updateNewWriteRelay}}
|
||||
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addWriteRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.hasWriteOverrides}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetWriteRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nostr-media-server">Media server</label>
|
||||
<select
|
||||
id="nostr-photo-fallback-uploads"
|
||||
id="nostr-media-server"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||
{{on "change" (fn @onChange "nostrMediaServer")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
{{#each this.mediaServerOptions as |server|}}
|
||||
<option
|
||||
value={{server}}
|
||||
selected={{if
|
||||
(eq server this.settings.nostrMediaServer)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{server}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{#if this.hasMultipleMediaServers}}
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to
|
||||
fallback servers</label>
|
||||
<select
|
||||
id="nostr-photo-fallback-uploads"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
|
||||
import { apply } from 'ol-mapbox-style';
|
||||
import { getIcon } from '../utils/icons';
|
||||
import { getIconNameForTags } from '../utils/osm-icons';
|
||||
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||
|
||||
export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@@ -1369,7 +1370,7 @@ export default class MapComponent extends Component {
|
||||
lat: lat.toFixed(6),
|
||||
lon: lon.toFixed(6),
|
||||
q: null, // Clear q to force spatial search
|
||||
category: null, // Clear category to force spatial search
|
||||
category: NEARBY_CATEGORY.id, // Represent nearby as a first-class category
|
||||
selected: selectedFeatureName || null,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import { encodePointer } from 'applesauce-core/helpers/pointers';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
@@ -15,6 +16,8 @@ import ZapPhotoModal from './zap-photo-modal';
|
||||
import Icon from './icon';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
|
||||
const MAX_NEVENT_RELAY_HINTS = 3;
|
||||
|
||||
const GalleryContent = <template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
@@ -236,8 +239,23 @@ export default class PhotoGallery extends Component {
|
||||
@action
|
||||
async copyEventId(closeMenu) {
|
||||
if (this.currentPhoto?.eventId) {
|
||||
let value = this.currentPhoto.eventId;
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.currentPhoto.eventId);
|
||||
const nevent = encodePointer({
|
||||
id: this.currentPhoto.eventId,
|
||||
relays: this.nostrData
|
||||
.getEventRelays(this.currentPhoto.eventId)
|
||||
.slice(0, MAX_NEVENT_RELAY_HINTS),
|
||||
author: this.currentPhoto.pubkey,
|
||||
kind: 360,
|
||||
});
|
||||
if (nevent) value = nevent;
|
||||
} catch (err) {
|
||||
console.warn('Failed to encode nevent, copying raw event ID:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
this.toast.show('Event ID copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy event ID:', err);
|
||||
@@ -290,7 +308,14 @@ export default class PhotoGallery extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
const responses = await this.nostrRelay.publish(
|
||||
this.nostrData.activeWriteRelays,
|
||||
event
|
||||
);
|
||||
this.nostrData.recordPublishResult(event.id, responses);
|
||||
if (!responses?.some((res) => res.ok)) {
|
||||
throw new Error('Failed to publish deletion event.');
|
||||
}
|
||||
|
||||
// Remove from local store by adding the kind 5 to it
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
@@ -316,6 +316,70 @@ export default class PlaceDetails extends Component {
|
||||
return this.formatMultiLine(val);
|
||||
}
|
||||
|
||||
get operatorNames() {
|
||||
const raw = this.tags.operator;
|
||||
if (!raw) return null;
|
||||
const values = raw
|
||||
.split(';')
|
||||
.map((value) => value.trim())
|
||||
.filter(
|
||||
(value) =>
|
||||
value && value.toLowerCase() !== 'yes' && value.toLowerCase() !== 'no'
|
||||
);
|
||||
if (values.length === 0) return null;
|
||||
return this.formatMultiLine(values.join(';'));
|
||||
}
|
||||
|
||||
get operatorWebsite() {
|
||||
const val = this.tags['operator:website'];
|
||||
return val ? val.trim() : null;
|
||||
}
|
||||
|
||||
get operatorWebsiteLabel() {
|
||||
return this.operatorWebsite ? this.getDomain(this.operatorWebsite) : null;
|
||||
}
|
||||
|
||||
get operatorWikidata() {
|
||||
const val = this.tags['operator:wikidata'];
|
||||
if (!val) return null;
|
||||
return (
|
||||
val
|
||||
.split(';')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)[0] || null
|
||||
);
|
||||
}
|
||||
|
||||
get operatorWikipedia() {
|
||||
const val = this.tags['operator:wikipedia'];
|
||||
if (!val) return null;
|
||||
return (
|
||||
val
|
||||
.split(';')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)[0] || null
|
||||
);
|
||||
}
|
||||
|
||||
get operatorWikipediaUrl() {
|
||||
const val = this.operatorWikipedia;
|
||||
if (!val) return null;
|
||||
const match = val.match(/^([a-z-]+):(.+)$/i);
|
||||
if (match) {
|
||||
return `https://${match[1]}.wikipedia.org/wiki/${match[2]}`;
|
||||
}
|
||||
return `https://wikipedia.org/wiki/${val}`;
|
||||
}
|
||||
|
||||
get showOperator() {
|
||||
return !!(
|
||||
this.operatorNames ||
|
||||
this.operatorWebsite ||
|
||||
this.operatorWikidata ||
|
||||
this.operatorWikipedia
|
||||
);
|
||||
}
|
||||
|
||||
get cuisine() {
|
||||
if (!this.tags.cuisine) return null;
|
||||
return this.tags.cuisine
|
||||
@@ -571,6 +635,45 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.showOperator}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="briefcase" @title="Operator" />
|
||||
<span>
|
||||
{{this.operatorNames}}
|
||||
{{#if this.operatorWebsite}}
|
||||
<br />
|
||||
<a
|
||||
href={{this.operatorWebsite}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{this.operatorWebsiteLabel}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if this.operatorWikidata}}
|
||||
<br />
|
||||
<a
|
||||
href="https://www.wikidata.org/wiki/{{this.operatorWikidata}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Wikidata
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if this.operatorWikipedia}}
|
||||
<br />
|
||||
<a
|
||||
href={{this.operatorWikipediaUrl}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Wikipedia
|
||||
</a>
|
||||
{{/if}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
<div class="meta-info">
|
||||
|
||||
|
||||
@@ -211,7 +211,14 @@ export default class PlacePhotoUpload extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
const responses = await this.nostrRelay.publish(
|
||||
this.nostrData.activeWriteRelays,
|
||||
event
|
||||
);
|
||||
this.nostrData.recordPublishResult(event.id, responses);
|
||||
if (!responses?.some((res) => res.ok)) {
|
||||
throw new Error('Failed to publish event.');
|
||||
}
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
this.toast.show('Photo published successfully');
|
||||
|
||||
@@ -11,6 +11,7 @@ import PlaceDetails from './place-details';
|
||||
import Icon from './icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||
import restoreScroll from '../modifiers/restore-scroll';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@@ -150,7 +151,11 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
get isNearbySearch() {
|
||||
const qp = this.router.currentRoute.queryParams;
|
||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||
// New searches use ?category=nearby; keep supporting legacy lat/lon-only URLs
|
||||
return (
|
||||
qp.category === NEARBY_CATEGORY.id ||
|
||||
(!qp.q && !qp.category && qp.lat && qp.lon)
|
||||
);
|
||||
}
|
||||
|
||||
get hasHeaderPhoto() {
|
||||
|
||||
@@ -270,6 +270,7 @@ export default class SearchBoxComponent extends Component {
|
||||
(or
|
||||
(eq this.mapUi.loadingState.type "text")
|
||||
(eq this.mapUi.loadingState.type "category")
|
||||
(eq this.mapUi.loadingState.type "nearby")
|
||||
)
|
||||
}}
|
||||
<Icon @name="loading-ring" @size={{20}} />
|
||||
|
||||
@@ -23,18 +23,34 @@ export default class ListsListController extends Controller {
|
||||
|
||||
@tracked model;
|
||||
@tracked loadedPlaces = [];
|
||||
@tracked loadedListId = null;
|
||||
|
||||
get listId() {
|
||||
return this.model?.list_id;
|
||||
}
|
||||
|
||||
get isLoading() {
|
||||
// Only show the spinner when there is nothing to display yet. On re-entry
|
||||
// the previously loaded places (plus live storage state) render instantly,
|
||||
// so we avoid a loading flash and preserve restored scroll position.
|
||||
return this.loadPlacesTask.isRunning && this.places.length === 0;
|
||||
}
|
||||
|
||||
loadPlacesTask = task({ restartable: true }, async (listId) => {
|
||||
// Already loaded this list: keep the existing places so returning from
|
||||
// place details is instant (mirrors the activity service's load guard).
|
||||
if (this.loadedListId === listId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadedListId = listId;
|
||||
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
|
||||
try {
|
||||
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load places in list', listId, e);
|
||||
this.loadedPlaces = [];
|
||||
this.loadedListId = null; // Allow a retry on next entry
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+64
-40
@@ -2,6 +2,7 @@ import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { getDistance } from '../utils/geo';
|
||||
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||
|
||||
export default class SearchController extends Controller {
|
||||
@service osm;
|
||||
@@ -19,6 +20,40 @@ export default class SearchController extends Controller {
|
||||
selected = null;
|
||||
category = null;
|
||||
|
||||
async fetchNearbyPois(lat, lon) {
|
||||
const searchRadius = 50;
|
||||
|
||||
// Fetch POIs from Overpass
|
||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by distance from the search center
|
||||
return pois
|
||||
.map((p) => ({
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
|
||||
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||
// 1. Check if the incoming parameters match our currently loaded search
|
||||
const isSameSearch =
|
||||
@@ -50,8 +85,22 @@ export default class SearchController extends Controller {
|
||||
let loadingValue = null;
|
||||
|
||||
try {
|
||||
// Case 0: Category Search (category parameter present)
|
||||
if (params.category && lat && lon) {
|
||||
// Case 0: Nearby Search (map click or ?category=nearby)
|
||||
const isNearbySearch =
|
||||
lat &&
|
||||
lon &&
|
||||
!params.q &&
|
||||
(!params.category || params.category === NEARBY_CATEGORY.id);
|
||||
|
||||
if (isNearbySearch) {
|
||||
loadingType = 'nearby';
|
||||
loadingValue = 'nearby';
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
|
||||
pois = await this.fetchNearbyPois(lat, lon);
|
||||
}
|
||||
// Case 1: Category Search (category parameter present)
|
||||
else if (params.category && lat && lon) {
|
||||
loadingType = 'category';
|
||||
loadingValue = params.category;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
@@ -88,7 +137,7 @@ export default class SearchController extends Controller {
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
// Case 1: Text Search (q parameter present)
|
||||
// Case 2: Text Search (q parameter present)
|
||||
else if (params.q) {
|
||||
loadingType = 'text';
|
||||
loadingValue = params.q;
|
||||
@@ -121,43 +170,6 @@ export default class SearchController extends Controller {
|
||||
}
|
||||
});
|
||||
}
|
||||
// Case 2: Nearby Search (lat/lon present, no q)
|
||||
else if (lat && lon) {
|
||||
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||
const searchRadius = 50; // Default radius
|
||||
|
||||
// Fetch POIs from Overpass
|
||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
return {
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search request failed.', error);
|
||||
this.toast.show('Search request failed. Please try again.');
|
||||
@@ -176,6 +188,18 @@ export default class SearchController extends Controller {
|
||||
return saved || p;
|
||||
});
|
||||
|
||||
// A search with results is a restorable context (e.g. for "up" navigation
|
||||
// from place details). Empty searches are intentionally not restorable, so
|
||||
// they don't reopen an invisible "no results" drawer later on.
|
||||
if (pois.length > 0) {
|
||||
this.mapUi.currentSearch = {
|
||||
q: params.q,
|
||||
category: params.category,
|
||||
lat: params.lat,
|
||||
lon: params.lon,
|
||||
};
|
||||
}
|
||||
|
||||
const targetName = params.selected || params.q;
|
||||
|
||||
if (targetName && pois.length > 0) {
|
||||
|
||||
@@ -8,6 +8,10 @@ export default class ActivityRoute extends Route {
|
||||
|
||||
activate() {
|
||||
this.mapUi.showSidebar();
|
||||
// Re-request profiles for senders that couldn't be resolved when the feed
|
||||
// was first loaded (e.g. metadata published since then). No-op on the first
|
||||
// entry, when there are no items yet.
|
||||
void this.activity.retryUnresolvedProfiles();
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
|
||||
@@ -24,10 +24,6 @@ export default class SearchRoute extends Route {
|
||||
|
||||
// Trigger the background task to fetch results
|
||||
controller.fetchResultsTask.perform(model);
|
||||
|
||||
// Store current search params to allow "Up" navigation from place details
|
||||
const { q, category, lat, lon } = this.paramsFor('search');
|
||||
this.mapUi.currentSearch = { q, category, lat, lon };
|
||||
}
|
||||
|
||||
resetController(controller, isExiting) {
|
||||
|
||||
@@ -119,6 +119,27 @@ export default class ActivityService extends Service {
|
||||
this.isLoading = false;
|
||||
this._cacheReadyCallback = null;
|
||||
}
|
||||
|
||||
void this.retryUnresolvedProfiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-requests kind 0 metadata for senders whose profiles could not be
|
||||
* resolved. Called when the activity view is re-entered or the source mode
|
||||
* changes, so a profile published after the feed was first loaded can still
|
||||
* show up in the same session.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async retryUnresolvedProfiles() {
|
||||
const pubkeys = new Set();
|
||||
for (const item of this.items) {
|
||||
if (item.senderPubkey && item.senderProfileLoading) {
|
||||
pubkeys.add(item.senderPubkey);
|
||||
}
|
||||
}
|
||||
if (pubkeys.size === 0) return;
|
||||
await this.nostrData.refreshProfiles([...pubkeys]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,6 +95,10 @@ export default class NostrDataService extends Service {
|
||||
_contributionsSub = null;
|
||||
_deletionsSub = null;
|
||||
_profileModelSubs = new Map();
|
||||
// Pubkeys whose profiles have already been requested at least once. Used to
|
||||
// distinguish a first lookup (handled by the ProfileModel loader) from a
|
||||
// retry on place re-entry, so we don't duplicate the initial request.
|
||||
_attemptedProfilePubkeys = new Set();
|
||||
|
||||
_zapReceiptsSub = null;
|
||||
_zapReceiptsNetworkSub = null;
|
||||
@@ -335,6 +339,33 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
// Public getter for the normalized relays an event is known to have been
|
||||
// seen on. Used to build shareable `nevent` pointers with relay hints.
|
||||
getEventRelays(eventId) {
|
||||
const relays = this._eventRelays.get(eventId);
|
||||
return relays ? uniqNormalizedRelays([...relays]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the result of publishing an event to relays.
|
||||
*
|
||||
* Successful relays are merged into the provenance map so the event gets
|
||||
* correct relay hints (e.g. in `nevent` pointers) and trust checks. The full
|
||||
* per-relay responses are only debug-logged for now; this is the intended
|
||||
* hook for a future publishing-status store/UI.
|
||||
*
|
||||
* @param {string} eventId The published event id
|
||||
* @param {Array<{ok: boolean, message?: string, from: string}>} responses
|
||||
*/
|
||||
recordPublishResult(eventId, responses = []) {
|
||||
console.debug('[nostr-data] Publish result', eventId, responses);
|
||||
for (const res of responses || []) {
|
||||
if (res?.ok && res.from) {
|
||||
this._recordProvenance(eventId, res.from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request content events from relays while capturing full provenance.
|
||||
*
|
||||
@@ -922,6 +953,8 @@ export default class NostrDataService extends Service {
|
||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||
);
|
||||
|
||||
const retryPubkeys = [];
|
||||
|
||||
for (const pubkey of newPubkeys) {
|
||||
const sub = this.store
|
||||
.model(ProfileModel, pubkey)
|
||||
@@ -929,7 +962,60 @@ export default class NostrDataService extends Service {
|
||||
this.profiles = { ...this.profiles, [pubkey]: profileContent };
|
||||
});
|
||||
this._profileModelSubs.set(pubkey, sub);
|
||||
|
||||
// If we already tried this pubkey before (e.g. selecting the same place
|
||||
// again) and it still isn't in the store, explicitly re-request it. The
|
||||
// ProfileModel loader only runs once per cached model, so without this a
|
||||
// profile published after the first lookup would never be retried.
|
||||
if (
|
||||
this._attemptedProfilePubkeys.has(pubkey) &&
|
||||
!this.store.getReplaceable(0, pubkey)
|
||||
) {
|
||||
retryPubkeys.push(pubkey);
|
||||
}
|
||||
this._attemptedProfilePubkeys.add(pubkey);
|
||||
}
|
||||
|
||||
if (retryPubkeys.length > 0) {
|
||||
void this.refreshProfiles(retryPubkeys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-requests kind 0 metadata for the given pubkeys from the directory and
|
||||
* active read relays, adding any results to the event store.
|
||||
*
|
||||
* Used to retry profiles that were not available on a first lookup (e.g. the
|
||||
* author published their metadata after their content was loaded, or a
|
||||
* cached applesauce model prevented the fallback loader from running again).
|
||||
* Inserting the events into the store notifies any active `ProfileModel`
|
||||
* subscriptions, so callers don't need to manage subscriptions themselves.
|
||||
*
|
||||
* @param {string[]} pubkeys Pubkeys to refresh
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshProfiles(pubkeys) {
|
||||
const unique = [...new Set(pubkeys)].filter(Boolean);
|
||||
if (unique.length === 0) return;
|
||||
|
||||
const relays = uniqNormalizedRelays([
|
||||
...DIRECTORY_RELAYS,
|
||||
...this.activeReadRelays,
|
||||
]);
|
||||
const filters = this._batchAuthorFilters(unique, [0]);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
this.nostrRelay.pool
|
||||
.request(relays, filters)
|
||||
.pipe(timeout({ first: 15_000 }))
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: () => resolve(),
|
||||
complete: () => resolve(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getProfile(pubkey) {
|
||||
@@ -1257,6 +1343,7 @@ export default class NostrDataService extends Service {
|
||||
super.willDestroy(...arguments);
|
||||
this._cleanupSubscriptions();
|
||||
this._clearProfileSubs();
|
||||
this._attemptedProfilePubkeys.clear();
|
||||
|
||||
if (this._deletionsSub) {
|
||||
this._deletionsSub.unsubscribe();
|
||||
|
||||
@@ -8,18 +8,11 @@ export default class NostrRelayService extends Service {
|
||||
if (!relays || relays.length === 0) {
|
||||
throw new Error('No relays provided to publish the event.');
|
||||
}
|
||||
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||
// and automatically handles reconnecting and retrying.
|
||||
const responses = await this.pool.publish(relays, event);
|
||||
|
||||
// Check if at least one relay accepted the event
|
||||
const success = responses.some((res) => res.ok);
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||
);
|
||||
}
|
||||
|
||||
return responses;
|
||||
// The publish method is a wrapper around the event method that returns a
|
||||
// Promise<PublishResponse[]> and automatically handles reconnecting and
|
||||
// retrying. It resolves with the per-relay responses even when no relay
|
||||
// accepted the event; callers are responsible for checking `ok` so that
|
||||
// failed attempts can still be recorded.
|
||||
return await this.pool.publish(relays, event);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -196,7 +196,7 @@ out center;
|
||||
|
||||
async getCategoryPois(bounds, categoryId, lat, lon) {
|
||||
const category = getCategoryById(categoryId);
|
||||
if (!category || !bounds) return [];
|
||||
if (!category || !Array.isArray(category.filter) || !bounds) return [];
|
||||
|
||||
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export default class SettingsService extends Service {
|
||||
name: 'private.coffee (AT)',
|
||||
url: 'https://overpass.private.coffee/api/interpreter',
|
||||
},
|
||||
{
|
||||
name: 'overpass.btcmap.org (SG)',
|
||||
url: 'https://overpass.btcmap.org/api/interpreter',
|
||||
},
|
||||
// {
|
||||
// name: 'overpass.openstreetmap.us (US)',
|
||||
// url: 'https://overpass.openstreetmap.us/api/interpreter'
|
||||
|
||||
@@ -72,6 +72,9 @@ export default class ApplicationComponent extends Component {
|
||||
} else {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
} else if (name === 'search' && this.mapUi.searchResults.length === 0) {
|
||||
// An empty search has no markers worth keeping, so clear the route
|
||||
this.router.replaceWith('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import restoreScroll from '../../modifiers/restore-scroll';
|
||||
|
||||
export default class ListsIndexTemplate extends Component {
|
||||
@service storage;
|
||||
@@ -26,8 +27,16 @@ export default class ListsIndexTemplate extends Component {
|
||||
return htmlSafe(`background-color: ${finalColor}`);
|
||||
}
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition('lists-index');
|
||||
}
|
||||
|
||||
@action
|
||||
selectList(listId) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition('lists-index', sidebarContent.scrollTop);
|
||||
}
|
||||
this.router.transitionTo('lists.list', listId);
|
||||
}
|
||||
|
||||
@@ -59,7 +68,7 @@ export default class ListsIndexTemplate extends Component {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-content" {{restoreScroll this.scrollTop}}>
|
||||
<ul class="places-list">
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -7,7 +7,7 @@ import PlacesSidebar from '#components/places-sidebar';
|
||||
@title={{@controller.listTitle}}
|
||||
@color={{@controller.listColor}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
||||
@isLoading={{@controller.isLoading}}
|
||||
@onSelect={{@controller.selectPlace}}
|
||||
@onClose={{@controller.close}}
|
||||
@onBack={{@controller.backToLists}}
|
||||
|
||||
@@ -18,14 +18,20 @@ export default class SearchTemplate extends Component {
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
// We don't need to manually set currentSearch here because
|
||||
// it was already set in the route's setupController
|
||||
// it was already set in the search controller
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.mapUi.hideSidebar();
|
||||
// With no results there is nothing to keep around (no map markers), so
|
||||
// dismissing the drawer clears the search route entirely.
|
||||
if (this.mapUi.searchResults.length === 0) {
|
||||
this.router.replaceWith('index');
|
||||
} else {
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import briefcase from 'feather-icons/dist/icons/briefcase.svg?raw';
|
||||
import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
|
||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||
@@ -53,6 +54,8 @@ import anchor from '@waysidemapping/pinhead/dist/icons/anchor.svg?raw';
|
||||
import airportTerminal from '@waysidemapping/pinhead/dist/icons/airport_terminal.svg?raw';
|
||||
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||
import barrelWithBunghole from '@waysidemapping/pinhead/dist/icons/barrel_with_bunghole.svg?raw';
|
||||
import bicycle from '@waysidemapping/pinhead/dist/icons/bicycle.svg?raw';
|
||||
import bulldozer from '@waysidemapping/pinhead/dist/icons/bulldozer.svg?raw';
|
||||
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
||||
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
|
||||
import banknoteInSlot from '@waysidemapping/pinhead/dist/icons/banknote_in_slot.svg?raw';
|
||||
@@ -95,7 +98,9 @@ import fish from '@waysidemapping/pinhead/dist/icons/fish.svg?raw';
|
||||
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
|
||||
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
|
||||
import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
|
||||
import fortress from '@waysidemapping/pinhead/dist/icons/fortress.svg?raw';
|
||||
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
||||
import gableRoofedBuilding from '@waysidemapping/pinhead/dist/icons/gable_roofed_building.svg?raw';
|
||||
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
||||
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
|
||||
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||
@@ -139,6 +144,8 @@ import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?r
|
||||
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
|
||||
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
|
||||
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
||||
import townBuildings from '@waysidemapping/pinhead/dist/icons/town_buildings.svg?raw';
|
||||
import transitVehicleOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/transit_vehicle_on_railway_track.svg?raw';
|
||||
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||
@@ -164,6 +171,8 @@ const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
barbell,
|
||||
'barrel-with-bunghole': barrelWithBunghole,
|
||||
bicycle,
|
||||
bulldozer,
|
||||
banknote,
|
||||
'banknote-in-slot': banknoteInSlot,
|
||||
'badge-shield-with-fire': badgeShieldWithFire,
|
||||
@@ -174,6 +183,7 @@ const ICONS = {
|
||||
'boxing-glove-up': boxingGloveUp,
|
||||
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||
bridge,
|
||||
briefcase,
|
||||
bus,
|
||||
camera,
|
||||
'feather-camera': featherCamera,
|
||||
@@ -216,6 +226,8 @@ const ICONS = {
|
||||
'flower-bouquet': flowerBouquet,
|
||||
'fork-and-knife': forkAndKnife,
|
||||
fort,
|
||||
fortress,
|
||||
'gable-roofed-building': gableRoofedBuilding,
|
||||
'gasoline-pump': gasolinePump,
|
||||
gift,
|
||||
globe,
|
||||
@@ -284,6 +296,8 @@ const ICONS = {
|
||||
target,
|
||||
'trash-2': trash2,
|
||||
'upload-cloud': uploadCloud,
|
||||
'town-buildings': townBuildings,
|
||||
'transit-vehicle-on-railway-track': transitVehicleOnRailwayTrack,
|
||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||
user,
|
||||
'user-check': userCheck,
|
||||
|
||||
+27
-13
@@ -99,18 +99,9 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
||||
{ tags: { leisure: 'marina' }, icon: 'anchor' },
|
||||
{ tags: { landuse: 'vineyard' }, icon: 'grapes' },
|
||||
|
||||
// Transport
|
||||
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
|
||||
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
||||
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
||||
{ tags: { bus: true }, icon: 'bus' },
|
||||
{
|
||||
tags: { railway: 'tram_stop' },
|
||||
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
||||
},
|
||||
{ tags: { landuse: 'cemetery' }, icon: 'memorial-stone-with-inscription' },
|
||||
{ tags: { landuse: 'construction' }, icon: 'bulldozer' },
|
||||
{ tags: { landuse: 'residential' }, icon: 'town-buildings' },
|
||||
|
||||
// Tourism
|
||||
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
|
||||
@@ -131,9 +122,11 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
|
||||
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'aqueduct' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
|
||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||
{ tags: { historic: 'monastery' }, icon: 'fortress' },
|
||||
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
|
||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||
@@ -153,7 +146,23 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
||||
{ tags: { historic: 'ruin' }, icon: 'camera' },
|
||||
{ tags: { historic: 'yes' }, icon: 'camera' },
|
||||
|
||||
// Transport
|
||||
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
|
||||
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
||||
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
||||
{ tags: { highway: 'cycleway' }, icon: 'bicycle' },
|
||||
{ tags: { bus: true }, icon: 'bus' },
|
||||
{
|
||||
tags: { railway: 'tram_stop' },
|
||||
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
||||
},
|
||||
{ tags: { light_rail: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||
{ tags: { train: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||
{ tags: { tram: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||
{ tags: { subway: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||
|
||||
// Accommodation
|
||||
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
|
||||
@@ -190,6 +199,7 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
|
||||
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
|
||||
{ tags: { landuse: 'recreation_ground' }, icon: 'person-running' },
|
||||
{ tags: { sport: true }, icon: 'person-running' },
|
||||
|
||||
// Healthcare
|
||||
@@ -204,6 +214,10 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
||||
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
||||
{ tags: { building: 'office' }, icon: 'lowrise-building' },
|
||||
|
||||
// Fallback
|
||||
{ tags: { historic: true }, icon: 'camera' },
|
||||
{ tags: { building: true }, icon: 'gable-roofed-building' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,18 @@ export const POI_CATEGORIES = [
|
||||
},
|
||||
];
|
||||
|
||||
// Special-cased category for spatial "Nearby" searches (triggered by clicking
|
||||
// the map). It intentionally lives outside POI_CATEGORIES so it does not show
|
||||
// up as a quick-search chip or in the search autocomplete. Like other
|
||||
// categories it is represented in the URL as ?category=nearby, which lets the
|
||||
// UI display its label and offer a clear button.
|
||||
export const NEARBY_CATEGORY = {
|
||||
id: 'nearby',
|
||||
label: 'Nearby',
|
||||
icon: 'target',
|
||||
};
|
||||
|
||||
export function getCategoryById(id) {
|
||||
if (id === NEARBY_CATEGORY.id) return NEARBY_CATEGORY;
|
||||
return POI_CATEGORIES.find((c) => c.id === id);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
# Place Icons
|
||||
|
||||
How place/POI icons on the map are imported, selected from OSM tags, and rendered.
|
||||
|
||||
> Scope: this covers the **POI icon system** used for map markers and UI.
|
||||
> It does **not** cover the PWA/launcher icons in `public/icons/` and `release/icons/`
|
||||
> (those are app icons referenced from `index.html` / `web-app-manifest.json`).
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `app/utils/icons.js` | Registry. Imports every SVG as a raw string (`?raw`), exposes `getIcon(name)` and `isIconFilled(name)`. |
|
||||
| `app/utils/osm-icons.js` | OSM tag → icon mapping. `POI_ICON_RULES`, `getIconNameForTags(tags)`, `getIconSvgForTags(tags)`. |
|
||||
| `app/components/icon.gjs` | Generic `<Icon @name="..." />` renderer used across the UI. |
|
||||
| `app/components/map.gjs` | Map marker call site; embeds the icon SVG into an OpenLayers `Icon` data-URI. |
|
||||
| `app/icons/` | Custom/local SVG files. |
|
||||
| `node_modules/@waysidemapping/pinhead/dist/icons/*.svg` | Pinhead source icons (2,320 files). |
|
||||
| `node_modules/feather-icons/dist/icons/*.svg` | Feather source icons. |
|
||||
| `tests/unit/utils/osm-icons-test.js` | Tests for tag matching + integrity check that every rule icon exists. |
|
||||
| `tests/integration/components/icon-test.gjs` | Tests for the `<Icon>` component. |
|
||||
|
||||
## How icons are imported (`app/utils/icons.js`)
|
||||
|
||||
Every icon is a **static named import** with Vite's `?raw` suffix, so the file contents
|
||||
arrive as a string. There is **no glob/context import**; each icon is listed explicitly.
|
||||
|
||||
Imports are grouped and sorted alphabetically (per the header comment at
|
||||
`app/utils/icons.js:1`):
|
||||
|
||||
1. **Feather icons** — `feather-icons/dist/icons/*.svg`
|
||||
2. **Pinhead icons** — `@waysidemapping/pinhead/dist/icons/*.svg`
|
||||
3. **Custom/local icons** — `../icons/*.svg`
|
||||
|
||||
```js
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
|
||||
import bitcoin from '../icons/bitcoin.svg?raw';
|
||||
```
|
||||
|
||||
The imported strings are collected in the `ICONS` object, keyed by the kebab-case name
|
||||
used everywhere else in the app. Quoted keys are needed when the name contains dashes:
|
||||
|
||||
```js
|
||||
const ICONS = {
|
||||
activity, // shorthand works for single-word names
|
||||
'map-pin': mapPin, // quote dashed keys
|
||||
donut,
|
||||
'loading-ring': loadingRing,
|
||||
};
|
||||
```
|
||||
|
||||
Some registry keys are intentionally **not** normalized to kebab-case
|
||||
(e.g. `climbing_wall`, `parking_p`, `winding_way_wide`) — match whatever string the
|
||||
rule uses.
|
||||
|
||||
`FILLED_ICONS` lists names that render with `fill` instead of `stroke`
|
||||
(`app/utils/icons.js:309`). `getIcon(name)` returns the SVG string (or `undefined`);
|
||||
`isIconFilled(name)` drives the `.icon-filled` CSS class.
|
||||
|
||||
## How icons are selected from OSM tags (`app/utils/osm-icons.js`)
|
||||
|
||||
`POI_ICON_RULES` is an **ordered** array of `{ tags, icon }`. Each rule requires **all**
|
||||
of its listed tags to match. The **first matching rule wins**, so more specific rules
|
||||
must come before catch-alls.
|
||||
|
||||
```js
|
||||
export const POI_ICON_RULES = [
|
||||
{ tags: { cuisine: 'donut' }, icon: 'donut' },
|
||||
...{ tags: { shop: true }, icon: 'shopping-bag' }, // catch-all, must be late
|
||||
];
|
||||
```
|
||||
|
||||
`getIconNameForTags(tags)` (`app/utils/osm-icons.js:214`) matching semantics:
|
||||
|
||||
- Returns `null` if `tags` is falsy or nothing matches.
|
||||
- A rule tag only matches if its value is truthy.
|
||||
- A tag value is split on `;` and trimmed, so `cuisine=donut;coffee_shop` matches both
|
||||
`donut` and `coffee_shop` rules.
|
||||
- `expectedValue: true` matches any non-empty value.
|
||||
|
||||
`getIconSvgForTags(tags)` wraps the name lookup and returns the raw SVG. It is exported
|
||||
but currently unused.
|
||||
|
||||
## Rendering
|
||||
|
||||
- **UI:** `<Icon @name="donut" @size={{16}} />` — `app/components/icon.gjs`. Renders
|
||||
only if `getIcon(@name)` returns an SVG. `@filled` overrides `isIconFilled`.
|
||||
- **Map markers:** `searchResultStyle` in `app/components/map.gjs:123` calls
|
||||
`getIconNameForTags(tags)` (line 151), strips the `<svg>` wrapper, and embeds the
|
||||
paths in white inside the red pin at a `0.8` scale (lines 194-199). Results are cached
|
||||
in `cachedIconUrls` keyed by icon name / `'default'`.
|
||||
|
||||
## Adding a new icon
|
||||
|
||||
1. **Find an SVG.**
|
||||
- Pinhead: browse `node_modules/@waysidemapping/pinhead/dist/icons/` or search the
|
||||
gallery at https://pinhead.ink. Filenames use snake_case (`coffee_bean.svg`).
|
||||
- Feather: `node_modules/feather-icons/dist/icons/`.
|
||||
- Otherwise drop a custom file in `app/icons/`.
|
||||
2. **Import it** in `app/utils/icons.js` in the correct group, keeping the group
|
||||
alphabetical, using `?raw`.
|
||||
3. **Register it** in the `ICONS` map with a kebab-case key (quote it if it has dashes).
|
||||
4. **Mark it filled** in `FILLED_ICONS` if it is a fill-style (not stroke-style) icon.
|
||||
5. **Map OSM tags** in `POI_ICON_RULES` (`app/utils/osm-icons.js`), inserting the rule
|
||||
before any catch-all that would otherwise shadow it.
|
||||
|
||||
No test is needed just to register a new icon or tag rule. The generic integrity test
|
||||
(`all icons used in POI_ICON_RULES exist in the icons utility`) already checks every
|
||||
rule's icon. Only add a focused test in `tests/unit/utils/osm-icons-test.js` for
|
||||
non-trivial matching behavior (e.g. ordering/shadowing, semicolon-separated values, or
|
||||
a new catch-all).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Order matters.** First match wins; put specific rules before catch-alls
|
||||
(`shop: true`, `office: true`, `craft: true`, `sport: true`, `historic: yes`).
|
||||
- **The integrity test is your safety net.** `osm-icons-test.js` asserts every icon named
|
||||
in `POI_ICON_RULES` exists in `getIcon`. Forgetting the import/registry entry fails CI.
|
||||
This covers _all_ icons automatically, so you normally don't need to add a test when
|
||||
adding one.
|
||||
- **Naming mismatch.** Pinhead filenames are snake_case; registry keys are usually
|
||||
kebab-case, with a few unnormalized exceptions. A typo yields `null` (no marker icon)
|
||||
rather than an error.
|
||||
- **Two icon systems.** Don't touch `public/icons/` (PWA icons) when working on POI icons;
|
||||
`pnpm build:icons` regenerates those PNGs and is release-only.
|
||||
- **Don't confuse with categories.** Tag→category matching for the sidebar/chips lives in
|
||||
`app/utils/poi-category-matcher.js` and `app/utils/poi-categories.js`, separate from
|
||||
tag→icon.
|
||||
|
||||
## Running the tests
|
||||
|
||||
```sh
|
||||
pnpm test # builds and runs the full QUnit suite via Testem
|
||||
pnpm lint # ESLint + Stylelint + Prettier + ember-template-lint
|
||||
pnpm lint:fix # auto-fix
|
||||
```
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.34.0",
|
||||
"version": "1.34.6",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||l===o||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t},c=(n,r,o)=>(o=null!=n?t(i(n)):{},l(!r&&n&&n.__esModule&&s.call(n,"default")?o:e(o,"default",{value:n,enumerable:!0}),n)),u=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var h,d=Object.prototype,p=Symbol("undefined"),f=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(f||{}),g=1,m=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=y(this.source);this._parent=t=null===e||e===d?null:_(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===p?void 0:e}removeDescriptors(t){this.writeDescriptors(t,p)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==p&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?f.ONCE:f.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,f.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=x(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===f.REMOVE&&t.kind!==f.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==g||this.source!==this.proto&&-1!==this._inheritedEnd||g++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<g){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);for(Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||l===o||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t},c=(n,r,o)=>(o=null!=n?t(i(n)):{},l(!r&&n&&n.__esModule&&s.call(n,"default")?o:e(o,"default",{value:n,enumerable:!0}),n)),u=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var h,d=Object.prototype,p=Symbol("undefined"),f=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(f||{}),g=1,m=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=y(this.source);this._parent=t=null===e||e===d?null:_(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===p?void 0:e}removeDescriptors(t){this.writeDescriptors(t,p)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==p&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?f.ONCE:f.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,f.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=x(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===f.REMOVE&&t.kind!==f.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==g||this.source!==this.proto&&-1!==this._inheritedEnd||g++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<g){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);for(Line truncated
|
||||
@@ -1 +1 @@
|
||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-B66TikLz.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-Djj89Vv-.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
||||
@@ -1 +1 @@
|
||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-B66TikLz.js";function l(){for(var t=[],n=0;n<arguments.length;n++)t[n]=arguments[n];return 1===(t=o(t)).length?e(t[0]):new r(function(t){return function(r){for(var n=[],s=function(s){n.push(e(t[s]).subscribe(i(r,function(t){if(n){for(var e=0;e<n.length;e++)e!==s&&n[e].unsubscribe();n=null}r.next(t)})))},a=0;n&&!r.closed&&a<t.length;a++)s(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-Djj89Vv-.js";function l(){for(var t=[],n=0;n<arguments.length;n++)t[n]=arguments[n];return 1===(t=o(t)).length?e(t[0]):new r(function(t){return function(r){for(var n=[],s=function(s){n.push(e(t[s]).subscribe(i(r,function(t){if(n){for(var e=0;e<n.length;e++)e!==s&&n[e].unsubscribe();n=null}r.next(t)})))},a=0;n&&!r.closed&&a<t.length;a++)s(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-B66TikLz.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-Djj89Vv-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -31,6 +31,8 @@ class MockActivityService extends Service {
|
||||
|
||||
async setSourceMode() {}
|
||||
|
||||
async retryUnresolvedProfiles() {}
|
||||
|
||||
loadMore() {}
|
||||
|
||||
stop() {
|
||||
|
||||
@@ -271,4 +271,85 @@ module('Acceptance | collections navigation', function (hooks) {
|
||||
'Saved Places are ordered by createdAt in descending order'
|
||||
);
|
||||
});
|
||||
|
||||
test('returning from place details preserves scroll position without a loading flash', async function (assert) {
|
||||
const places = Array.from({ length: 40 }, (_, i) => ({
|
||||
id: `place-${i}`,
|
||||
title: `Place ${i}`,
|
||||
geohash: 'u33dc0',
|
||||
lat: 48.1,
|
||||
lon: 11.5,
|
||||
createdAt: new Date(2023, 0, 1, 12, 0, i).toISOString(),
|
||||
osmTags: { name: `Place ${i}` },
|
||||
}));
|
||||
|
||||
class ManyPlacesStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = places;
|
||||
lists = [
|
||||
{
|
||||
id: 'to-go',
|
||||
title: 'Want to go',
|
||||
color: '#2e9e4f',
|
||||
placeRefs: places.map((p) => ({ id: p.id, geohash: p.geohash })),
|
||||
},
|
||||
];
|
||||
|
||||
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', ManyPlacesStorageService);
|
||||
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
sidebarContent.scrollTop = 300;
|
||||
assert.strictEqual(sidebarContent.scrollTop, 300, 'List is scrolled');
|
||||
|
||||
// Open place details from an item that is currently visible so the browser
|
||||
// does not scroll it back into view (which would reset our saved position),
|
||||
// then return via the back button.
|
||||
const visibleItem = document.querySelectorAll('.place-item')[5];
|
||||
await click(visibleItem);
|
||||
await waitFor('.sidebar-content');
|
||||
await click('.back-btn');
|
||||
await waitFor('.places-list');
|
||||
|
||||
// Allow the restore-scroll requestAnimationFrame to run
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
|
||||
const restored = document.querySelector('.sidebar-content');
|
||||
assert.strictEqual(
|
||||
restored.scrollTop,
|
||||
300,
|
||||
'Scroll position is restored on return'
|
||||
);
|
||||
assert
|
||||
.dom('.sidebar-loading')
|
||||
.doesNotExist('No loading spinner flashes when returning');
|
||||
});
|
||||
});
|
||||
@@ -129,4 +129,47 @@ module('Acceptance | navigation', function (hooks) {
|
||||
backStub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty search is not restored when navigating back from a place', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
// Nearby search that yields no results, but with a fetchable place
|
||||
this.owner.register(
|
||||
'service:osm',
|
||||
class extends Service {
|
||||
async getNearbyPois() {
|
||||
return [];
|
||||
}
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||
title: 'Test Place',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await visit('/search?lat=1&lon=1');
|
||||
assert.strictEqual(
|
||||
mapUi.currentSearch,
|
||||
null,
|
||||
'Empty search is not restorable'
|
||||
);
|
||||
|
||||
// Simulate opening a place while the (empty) search route is active
|
||||
mapUi.returnToSearch = true;
|
||||
|
||||
await visit('/place/osm:node:123');
|
||||
await click('.back-btn');
|
||||
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/',
|
||||
'Back navigation does not reopen the empty search'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,19 @@ class MockOsmService extends Service {
|
||||
return [];
|
||||
}
|
||||
async getNearbyPois() {
|
||||
return [];
|
||||
return new Promise((resolve) => {
|
||||
osmResolve = () => {
|
||||
resolve([
|
||||
{
|
||||
title: 'Nearby Place',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmId: '999',
|
||||
osmType: 'node',
|
||||
},
|
||||
]);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +80,7 @@ module('Acceptance | search loading', function (hooks) {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
});
|
||||
|
||||
test('search shows loading indicator but nearby search does not', async function (assert) {
|
||||
test('search shows loading indicator for text, category and nearby searches', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
// 1. Text Search
|
||||
@@ -121,11 +133,25 @@ module('Acceptance | search loading', function (hooks) {
|
||||
);
|
||||
|
||||
// 3. Nearby Search
|
||||
await visit('/search?lat=1&lon=1');
|
||||
const nearbyPromise = visit('/search?category=nearby&lat=1&lon=1');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
assert.deepEqual(
|
||||
mapUi.loadingState,
|
||||
{ type: 'nearby', value: 'nearby' },
|
||||
'Loading state is set for nearby search'
|
||||
);
|
||||
|
||||
// Resolve the manual promise
|
||||
osmResolve();
|
||||
|
||||
await nearbyPromise;
|
||||
await settled();
|
||||
|
||||
assert.strictEqual(
|
||||
mapUi.loadingState,
|
||||
null,
|
||||
'Loading state is NOT set for nearby search'
|
||||
'Loading state is cleared after nearby search'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL } from '@ember/test-helpers';
|
||||
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
@@ -277,6 +277,9 @@ module('Acceptance | search', function (hooks) {
|
||||
async getCategoryPois() {
|
||||
return [];
|
||||
}
|
||||
async getNearbyPois() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
@@ -336,10 +339,117 @@ module('Acceptance | search', function (hooks) {
|
||||
'Search input is populated with mapped category label'
|
||||
);
|
||||
|
||||
// 3. Go back to index
|
||||
// 3. Visit a nearby search URL
|
||||
await visit('/search?category=nearby&lat=52.52&lon=13.405');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue('Nearby', 'Search input is populated with the Nearby label');
|
||||
|
||||
// 4. Go back to index
|
||||
await visit('/');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue('', 'Search input is cleared on transitioning to index');
|
||||
});
|
||||
|
||||
test('closing the drawer clears the search route when there are no results', async function (assert) {
|
||||
class MockPhotonService extends Service {
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
rs = { on: () => {} };
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/search?q=nowhere');
|
||||
|
||||
assert.dom('.empty-state').hasText('No results found.');
|
||||
assert.strictEqual(
|
||||
mapUi.currentSearch,
|
||||
null,
|
||||
'An empty search is not kept as a restorable search context'
|
||||
);
|
||||
|
||||
await click('.close-btn');
|
||||
|
||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||
assert.strictEqual(currentURL(), '/', 'Search route is cleared');
|
||||
assert.strictEqual(mapUi.searchResults.length, 0, 'Results are cleared');
|
||||
});
|
||||
|
||||
test('nearby search state is shown in the search box and can be dismissed', async function (assert) {
|
||||
class MockOsmService extends Service {
|
||||
cancelAll() {}
|
||||
async getNearbyPois() {
|
||||
return [
|
||||
{
|
||||
title: 'Nearby Cafe',
|
||||
lat: 52.521,
|
||||
lon: 13.406,
|
||||
osmId: '789',
|
||||
osmType: 'N',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
rs = { on: () => {} };
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/search?category=nearby&lat=52.52&lon=13.405');
|
||||
|
||||
assert.dom('.sidebar-header h2').includesText('Nearby');
|
||||
assert.dom('.search-input').hasValue('Nearby', 'Input indicates Nearby');
|
||||
|
||||
// Closing the sidebar keeps the search (and its markers) active, so the
|
||||
// search box must still indicate the Nearby search and offer dismissal.
|
||||
await click('.close-btn');
|
||||
|
||||
assert.ok(
|
||||
currentURL().includes('category=nearby'),
|
||||
'Still on the nearby search route with markers visible'
|
||||
);
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue('Nearby', 'Input still indicates Nearby after closing sidebar');
|
||||
assert.dom('.search-clear-btn').exists('Clear button remains available');
|
||||
|
||||
await click('.search-clear-btn');
|
||||
|
||||
assert.strictEqual(currentURL(), '/', 'Dismissing nearby clears the route');
|
||||
assert.strictEqual(mapUi.searchResults.length, 0, 'Results are cleared');
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,16 @@ export class MockNostrDataService extends Service {
|
||||
return this.profiles[pubkey];
|
||||
}
|
||||
|
||||
getEventRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
recordPublishResult() {}
|
||||
|
||||
refreshProfiles() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
return [];
|
||||
}
|
||||
@@ -105,14 +115,14 @@ export class MockNostrDataService extends Service {
|
||||
|
||||
export class MockNostrRelayService extends Service {
|
||||
pool = {
|
||||
publish: () => Promise.resolve([{ ok: true }]),
|
||||
publish: () => Promise.resolve([{ ok: true, from: 'wss://relay.test' }]),
|
||||
subscribe: () => {},
|
||||
unsubscribe: () => {},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
async publish() {
|
||||
return [{ ok: true }];
|
||||
return [{ ok: true, from: 'wss://relay.test' }];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { click, fillIn, render, settled } from '@ember/test-helpers';
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
@@ -67,6 +68,10 @@ class MockNostrDataService extends Service {
|
||||
async clearCache() {}
|
||||
}
|
||||
|
||||
class MockNostrAuthService extends Service {
|
||||
@tracked isConnected = true;
|
||||
}
|
||||
|
||||
function readRows(element) {
|
||||
const list = element.querySelectorAll('.relay-list')[0];
|
||||
return [...list.querySelectorAll('li')];
|
||||
@@ -88,6 +93,7 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
localStorage.removeItem('marco:settings');
|
||||
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||
this.settings = this.owner.lookup('service:settings');
|
||||
this.onChange = () => {};
|
||||
});
|
||||
@@ -283,6 +289,37 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('publish-only settings are hidden when no nostr key is connected', async function (assert) {
|
||||
this.owner.lookup('service:nostrAuth').isConnected = false;
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
|
||||
assert.dom('#new-write-relay').doesNotExist('write relay input hidden');
|
||||
assert
|
||||
.dom('#nostr-media-server')
|
||||
.doesNotExist('media server select hidden');
|
||||
assert
|
||||
.dom('#nostr-photo-fallback-uploads')
|
||||
.doesNotExist('fallback uploads select hidden');
|
||||
assert.strictEqual(
|
||||
element.querySelectorAll('.relay-list').length,
|
||||
1,
|
||||
'only the read relay list is rendered'
|
||||
);
|
||||
});
|
||||
|
||||
test('read relays and cached data stay visible when no nostr key is connected', async function (assert) {
|
||||
this.owner.lookup('service:nostrAuth').isConnected = false;
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
|
||||
assert.dom('#new-read-relay').exists('read relay input visible');
|
||||
assert.dom(readRows(element)[0]).exists('read relay list visible');
|
||||
assert
|
||||
.dom(element.querySelector('.btn-outline'))
|
||||
.includesText('Clear profiles, photos, and reviews');
|
||||
});
|
||||
|
||||
test('tooltip appears on hover and disappears on mouseleave', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
|
||||
|
||||
@@ -22,6 +22,9 @@ module('Integration | Component | category-chips', function (hooks) {
|
||||
// Check for some expected labels
|
||||
assert.dom(this.element).includesText('Restaurants');
|
||||
assert.dom(this.element).includesText('Coffee');
|
||||
|
||||
// Nearby is an internal-only category and must not appear as a chip
|
||||
assert.dom(this.element).doesNotIncludeText('Nearby');
|
||||
});
|
||||
|
||||
test('clicking a chip triggers the @onSelect action', async function (assert) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
|
||||
import Service from '@ember/service';
|
||||
import { decodePointer } from 'applesauce-core/helpers/pointers';
|
||||
import PhotoGallery from 'marco/components/photo-gallery';
|
||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||
import sinon from 'sinon';
|
||||
@@ -163,8 +164,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
const confirmStub = sinon.stub(window, 'confirm').returns(true);
|
||||
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
|
||||
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||
const publishStub = sinon
|
||||
.stub(this.nostrRelay, 'publish')
|
||||
.resolves([{ ok: true, from: 'wss://relay.test' }]);
|
||||
const storeStub = sinon.stub(this.nostrData.store, 'add');
|
||||
const recordPublishSpy = sinon.spy(this.nostrData, 'recordPublishResult');
|
||||
const toastSpy = sinon.spy(this.toast, 'show');
|
||||
|
||||
await render(
|
||||
@@ -224,6 +228,17 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
'added kind 5 event to local store'
|
||||
);
|
||||
|
||||
// Check publish result recording
|
||||
assert.ok(
|
||||
recordPublishSpy.calledOnce,
|
||||
'nostrData.recordPublishResult was called'
|
||||
);
|
||||
assert.strictEqual(
|
||||
recordPublishSpy.firstCall.args[0],
|
||||
publishedEvent.id,
|
||||
'publish result recorded for the deletion event'
|
||||
);
|
||||
|
||||
// Check UX
|
||||
assert.ok(
|
||||
toastSpy.calledWith('Photo deleted successfully'),
|
||||
@@ -261,7 +276,9 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
sinon.stub(window, 'confirm').returns(true);
|
||||
sinon.stub(this.blossom, 'delete').resolves();
|
||||
sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||
sinon
|
||||
.stub(this.nostrRelay, 'publish')
|
||||
.resolves([{ ok: true, from: 'wss://relay.test' }]);
|
||||
sinon.stub(this.nostrData.store, 'add');
|
||||
|
||||
await render(
|
||||
@@ -284,9 +301,27 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it copies event id to clipboard', async function (assert) {
|
||||
const eventId = '1'.repeat(64);
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.photos = [
|
||||
{
|
||||
eventId,
|
||||
pubkey: USER_A,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'photo.jpg',
|
||||
},
|
||||
];
|
||||
this.selectedPhoto = this.photos[0];
|
||||
|
||||
sinon
|
||||
.stub(this.nostrData, 'getEventRelays')
|
||||
.returns([
|
||||
'wss://a.test',
|
||||
'wss://b.test',
|
||||
'wss://c.test',
|
||||
'wss://d.test',
|
||||
'wss://e.test',
|
||||
]);
|
||||
const clipboardStub = sinon
|
||||
.stub(navigator.clipboard, 'writeText')
|
||||
.resolves();
|
||||
@@ -317,7 +352,23 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
await click(copyBtn);
|
||||
|
||||
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
|
||||
const copied = clipboardStub.firstCall.args[0];
|
||||
assert.ok(
|
||||
copied.startsWith('nevent1'),
|
||||
'copied value is an nevent identifier'
|
||||
);
|
||||
const decoded = decodePointer(copied);
|
||||
assert.strictEqual(decoded.type, 'nevent', 'decoded value is an nevent');
|
||||
assert.strictEqual(
|
||||
decoded.data.id,
|
||||
eventId,
|
||||
'decoded nevent references the correct event id'
|
||||
);
|
||||
assert.deepEqual(
|
||||
decoded.data.relays,
|
||||
['wss://a.test', 'wss://b.test', 'wss://c.test'],
|
||||
'decoded nevent includes at most 3 relay hints'
|
||||
);
|
||||
assert.ok(
|
||||
toastSpy.calledWith('Event ID copied to clipboard'),
|
||||
'success toast was shown'
|
||||
|
||||
@@ -457,4 +457,112 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
|
||||
assert.dom('.place-payment-methods').doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders the operator name with a briefcase icon', async function (assert) {
|
||||
const place = {
|
||||
title: 'Operated Cafe',
|
||||
osmTags: {
|
||||
operator: 'Le Méridien',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const operatorBlock = this.element.querySelector(
|
||||
'.meta-info .content-with-icon span.icon[title="Operator"]'
|
||||
);
|
||||
assert.ok(operatorBlock, 'Operator block is rendered');
|
||||
|
||||
const row = operatorBlock.closest('.content-with-icon');
|
||||
assert.dom(row).includesText('Le Méridien');
|
||||
});
|
||||
|
||||
test('it splits semicolon-separated operators onto separate lines', async function (assert) {
|
||||
const place = {
|
||||
title: 'Multi Operator Stop',
|
||||
osmTags: {
|
||||
operator: 'De Lijn;TEC',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const row = this.element
|
||||
.querySelector('span.icon[title="Operator"]')
|
||||
.closest('.content-with-icon');
|
||||
|
||||
assert.dom(row).includesText('De Lijn');
|
||||
assert.dom(row).includesText('TEC');
|
||||
assert.true(
|
||||
row.querySelector('span:not(.icon)').innerHTML.includes('<br>'),
|
||||
'Operators are separated by a line break'
|
||||
);
|
||||
});
|
||||
|
||||
test('it suppresses invalid operator values of yes and no', async function (assert) {
|
||||
const place = {
|
||||
title: 'Invalid Operator',
|
||||
osmTags: {
|
||||
operator: 'yes',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
assert
|
||||
.dom('span.icon[title="Operator"]')
|
||||
.doesNotExist('Operator block is hidden for operator=yes');
|
||||
});
|
||||
|
||||
test('it renders operator wikidata and website links', async function (assert) {
|
||||
const place = {
|
||||
title: 'Linked Operator',
|
||||
osmTags: {
|
||||
operator: 'Flixbus',
|
||||
'operator:wikidata': 'Q15712278',
|
||||
'operator:website': 'https://www.flixbus.com/',
|
||||
'operator:wikipedia': 'en:FlixBus',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
const row = this.element
|
||||
.querySelector('span.icon[title="Operator"]')
|
||||
.closest('.content-with-icon');
|
||||
|
||||
const wikidataLink = row.querySelector(
|
||||
'a[href="https://www.wikidata.org/wiki/Q15712278"]'
|
||||
);
|
||||
assert.ok(wikidataLink, 'Wikidata link is rendered');
|
||||
assert.dom(wikidataLink).hasText('Wikidata');
|
||||
|
||||
const websiteLink = row.querySelector('a[href="https://www.flixbus.com/"]');
|
||||
assert.ok(websiteLink, 'Operator website link is rendered');
|
||||
assert.dom(websiteLink).hasText('www.flixbus.com');
|
||||
|
||||
const wikipediaLink = row.querySelector(
|
||||
'a[href="https://en.wikipedia.org/wiki/FlixBus"]'
|
||||
);
|
||||
assert.ok(wikipediaLink, 'Operator Wikipedia link is rendered');
|
||||
assert.dom(wikipediaLink).hasText('Wikipedia');
|
||||
});
|
||||
|
||||
test('it renders the operator block when only operator subkeys are present', async function (assert) {
|
||||
const place = {
|
||||
title: 'Subkey Only',
|
||||
osmTags: {
|
||||
'operator:wikidata': 'Q42',
|
||||
},
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
assert
|
||||
.dom('span.icon[title="Operator"]')
|
||||
.exists('Operator block is rendered from subkeys alone');
|
||||
assert
|
||||
.dom('a[href="https://www.wikidata.org/wiki/Q42"]')
|
||||
.exists('Wikidata link is rendered');
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,12 @@ function makePhotoEvent(opts = {}) {
|
||||
class MockNostrDataService extends Service {
|
||||
@tracked profiles = {};
|
||||
_contactPubkeys = new Set();
|
||||
refreshProfilesCalls = [];
|
||||
|
||||
refreshProfiles(pubkeys) {
|
||||
this.refreshProfilesCalls.push(pubkeys);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
store = {
|
||||
events: new Map(),
|
||||
@@ -301,6 +307,115 @@ module('Unit | Service | activity', function (hooks) {
|
||||
assert.false(entry.senderProfileLoading);
|
||||
});
|
||||
|
||||
test('retryUnresolvedProfiles refreshes only unresolved sender pubkeys', async function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service._userPubkey = USER_PUBKEY;
|
||||
service._sourceMode = 'explore';
|
||||
service.nostrData._contactPubkeys = new Set();
|
||||
service.nostrData.isTrustedEvent = () => true;
|
||||
|
||||
const OTHER_PUBKEY = 'c'.repeat(64);
|
||||
service.nostrData.profiles[SENDER_PUBKEY] = { name: 'Alice' };
|
||||
|
||||
const resolvedPhoto = makePhotoEvent({
|
||||
id: 'rp'.padEnd(64, '0'),
|
||||
author: SENDER_PUBKEY,
|
||||
placeIdentifier: 'osm:node:100',
|
||||
created_at: 1000,
|
||||
});
|
||||
const unresolvedPhoto = makePhotoEvent({
|
||||
id: 'up'.padEnd(64, '0'),
|
||||
author: OTHER_PUBKEY,
|
||||
placeIdentifier: 'osm:node:200',
|
||||
created_at: 2000,
|
||||
});
|
||||
service._updateSocialItems([resolvedPhoto, unresolvedPhoto]);
|
||||
|
||||
assert.strictEqual(service.items.length, 2, 'both entries present');
|
||||
assert.false(
|
||||
service.items.find((i) => i.senderPubkey === SENDER_PUBKEY)
|
||||
.senderProfileLoading,
|
||||
'resolved sender not loading'
|
||||
);
|
||||
assert.true(
|
||||
service.items.find((i) => i.senderPubkey === OTHER_PUBKEY)
|
||||
.senderProfileLoading,
|
||||
'unresolved sender still loading'
|
||||
);
|
||||
|
||||
await service.retryUnresolvedProfiles();
|
||||
|
||||
assert.strictEqual(
|
||||
service.nostrData.refreshProfilesCalls.length,
|
||||
1,
|
||||
'refresh called once'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.nostrData.refreshProfilesCalls[0],
|
||||
[OTHER_PUBKEY],
|
||||
'only the unresolved sender is refreshed'
|
||||
);
|
||||
});
|
||||
|
||||
test('retryUnresolvedProfiles does nothing when all senders are resolved', async function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service._userPubkey = USER_PUBKEY;
|
||||
service._sourceMode = 'explore';
|
||||
service.nostrData._contactPubkeys = new Set();
|
||||
service.nostrData.isTrustedEvent = () => true;
|
||||
service.nostrData.profiles[SENDER_PUBKEY] = { name: 'Alice' };
|
||||
|
||||
service._updateSocialItems([
|
||||
makePhotoEvent({
|
||||
id: 'rp2'.padEnd(64, '0'),
|
||||
author: SENDER_PUBKEY,
|
||||
placeIdentifier: 'osm:node:100',
|
||||
created_at: 1000,
|
||||
}),
|
||||
]);
|
||||
|
||||
await service.retryUnresolvedProfiles();
|
||||
|
||||
assert.strictEqual(
|
||||
service.nostrData.refreshProfilesCalls.length,
|
||||
0,
|
||||
'no refresh when every sender is resolved'
|
||||
);
|
||||
});
|
||||
|
||||
test('setSourceMode retries unresolved profiles after fetching', async function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service._userPubkey = USER_PUBKEY;
|
||||
service.nostrData._contactPubkeys = new Set();
|
||||
service.nostrData.isTrustedEvent = () => true;
|
||||
|
||||
const photo = makePhotoEvent({
|
||||
id: 'sm1'.padEnd(64, '0'),
|
||||
author: SENDER_PUBKEY,
|
||||
placeIdentifier: 'osm:node:100',
|
||||
created_at: 1000,
|
||||
});
|
||||
|
||||
service.nostrData.fetchActivityPhotos = async (_since, _until, mode) => {
|
||||
if (mode === 'explore')
|
||||
return { cacheEvents: [photo], networkEvents: Promise.resolve([]) };
|
||||
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||
};
|
||||
|
||||
await service.setSourceMode('explore');
|
||||
|
||||
assert.strictEqual(
|
||||
service.nostrData.refreshProfilesCalls.length,
|
||||
1,
|
||||
'refresh triggered by mode switch'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.nostrData.refreshProfilesCalls[0],
|
||||
[SENDER_PUBKEY],
|
||||
'refreshes the unresolved sender'
|
||||
);
|
||||
});
|
||||
|
||||
test('_updateSocialItems groups photos by author + place', function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service._userPubkey = USER_PUBKEY;
|
||||
|
||||
@@ -498,6 +498,56 @@ module('Unit | Service | nostr-data | provenance', function (hooks) {
|
||||
);
|
||||
assert.true(service.store.hasEvent(eventId), 'event added to store');
|
||||
});
|
||||
|
||||
test('getEventRelays returns normalized relays for an event', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(904);
|
||||
|
||||
service._recordProvenance(eventId, 'wss://one.example/');
|
||||
service._recordProvenance(eventId, 'WSS://Two.Example');
|
||||
|
||||
assert.deepEqual(
|
||||
service.getEventRelays(eventId).sort(),
|
||||
['wss://one.example', 'wss://two.example'],
|
||||
'returns normalized, unique relays'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.getEventRelays('unknown-event'),
|
||||
[],
|
||||
'returns an empty array for unknown events'
|
||||
);
|
||||
});
|
||||
|
||||
test('recordPublishResult records only successful relays into provenance', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(905);
|
||||
|
||||
service.recordPublishResult(eventId, [
|
||||
{ ok: true, from: 'wss://accepted.example' },
|
||||
{ ok: false, from: 'wss://rejected.example', message: 'blocked' },
|
||||
]);
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance recorded');
|
||||
assert.true(
|
||||
relays.has('wss://accepted.example'),
|
||||
'accepted relay is recorded'
|
||||
);
|
||||
assert.false(
|
||||
relays.has('wss://rejected.example'),
|
||||
'rejected relay is not recorded'
|
||||
);
|
||||
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
eventId
|
||||
);
|
||||
assert.deepEqual(
|
||||
persisted,
|
||||
['wss://accepted.example'],
|
||||
'accepted relay is persisted'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | trust evaluation', function (hooks) {
|
||||
@@ -1287,3 +1337,87 @@ module('Unit | Service | nostr-data | zap receipts', function (hooks) {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | profiles', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
function makeProfileEvent(pubkey, content, opts = {}) {
|
||||
return {
|
||||
id: opts.id || makeEventId(3000),
|
||||
pubkey,
|
||||
kind: 0,
|
||||
created_at: opts.created_at || 1000,
|
||||
tags: [],
|
||||
content: JSON.stringify(content),
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
test('refreshProfiles requests kind 0 for the given pubkeys', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pubkey = makePubkey(80);
|
||||
|
||||
await service.refreshProfiles([pubkey]);
|
||||
|
||||
const filter = this.requestedFilters.find((f) => f.kinds?.includes(0));
|
||||
assert.ok(filter, 'requested kind 0');
|
||||
assert.deepEqual(filter.authors, [pubkey], 'requested the pubkey');
|
||||
});
|
||||
|
||||
test('refreshProfiles adds returned profiles to the store', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pubkey = makePubkey(81);
|
||||
const event = makeProfileEvent(pubkey, { name: 'Alice' });
|
||||
|
||||
service.nostrRelay.pool.request = (_relays, filters) => {
|
||||
this.requestedFilters.push(...filters);
|
||||
return of(event);
|
||||
};
|
||||
|
||||
await service.refreshProfiles([pubkey]);
|
||||
|
||||
assert.true(service.store.hasReplaceable(0, pubkey), 'profile stored');
|
||||
});
|
||||
|
||||
test('refreshProfiles deduplicates input and skips empty input', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pubkey = makePubkey(82);
|
||||
|
||||
await service.refreshProfiles([pubkey, pubkey, null]);
|
||||
await service.refreshProfiles([]);
|
||||
|
||||
const filters = this.requestedFilters.filter((f) => f.kinds?.includes(0));
|
||||
assert.strictEqual(filters.length, 1, 'one request for deduped input');
|
||||
assert.deepEqual(filters[0].authors, [pubkey], 'deduped authors');
|
||||
});
|
||||
|
||||
test('loadProfiles re-requests an unresolved profile on a second call', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pubkey = makePubkey(83);
|
||||
|
||||
// First call: the ProfileModel loader handles it, no explicit refresh.
|
||||
service.loadProfiles([pubkey]);
|
||||
assert.strictEqual(
|
||||
this.requestedFilters.filter((f) => f.kinds?.includes(0)).length,
|
||||
0,
|
||||
'first lookup is not refreshed explicitly'
|
||||
);
|
||||
|
||||
// Re-entering the place clears the model subscriptions, but the ProfileModel
|
||||
// stays cached and its loader won't run again. The second call should retry.
|
||||
service._clearProfileSubs();
|
||||
service.loadProfiles([pubkey]);
|
||||
|
||||
const filters = this.requestedFilters.filter((f) => f.kinds?.includes(0));
|
||||
assert.strictEqual(
|
||||
filters.length,
|
||||
1,
|
||||
'second lookup refreshes the profile'
|
||||
);
|
||||
assert.deepEqual(
|
||||
filters[0].authors,
|
||||
[pubkey],
|
||||
'refreshes the missing pubkey'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user