Compare commits

...

18 Commits

Author SHA1 Message Date
raucao 3bada05b63 Merge pull request 'Various UI improvements' (#63) from ui/various into master
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 55s
Reviewed-on: #63
2026-06-29 15:37:25 +00:00
raucao f01730fef5 Add icon and quick search results for tourist information
CI / Lint (pull_request) Successful in 33s
CI / Test (pull_request) Successful in 58s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-06-29 17:32:26 +02:00
raucao 448c51bab6 Add icons for city gates and historic towers 2026-06-29 17:28:57 +02:00
raucao 0bcbae374b Use "yes" tag values only as fallbacks if there isn't a more specific
OSM key
2026-06-29 17:28:00 +02:00
raucao c33fe3b268 Add icon for car repair shops 2026-06-29 17:08:55 +02:00
raucao 18bda60310 Add guest houses to hotel quick search 2026-06-29 17:02:43 +02:00
raucao 86d25dc6ba Add OSM links for custom saved places
Link to the OSM search route, so we get a pin when opening OSM from
Marco
2026-06-29 16:45:49 +02:00
raucao 5b8bec6a00 Cut off overlong sidebar link texts with ellipses 2026-06-29 16:06:55 +02:00
raucao f2c2eb1fdc Add icon for amenity=townhall 2026-06-29 15:55:28 +02:00
raucao b42c4881f6 Merge pull request 'Add missing icons, test for missing icons in rules' (#62) from bugfix/icons into master
CI / Lint (push) Successful in 34s
CI / Test (push) Successful in 58s
Reviewed-on: #62
2026-06-29 13:38:34 +00:00
raucao b18e299eca Add missing icons, test for missing icons in rules
CI / Lint (pull_request) Successful in 34s
CI / Test (pull_request) Successful in 59s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-06-29 15:32:48 +02:00
raucao 401ed41fcd Merge pull request 'Turn default relays into required relays' (#61) from nostr/required_relays into master
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
Reviewed-on: #61
2026-06-07 12:30:38 +00:00
raucao 504e8fab94 Fix lint errors
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-06-07 16:28:09 +04:00
raucao 76897c9e69 Turn default relays into required relays
CI / Lint (pull_request) Failing after 31s
CI / Test (pull_request) Successful in 55s
2026-06-07 16:21:26 +04:00
raucao 59bc5ca046 1.24.0
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 55s
2026-06-06 12:12:06 +04:00
raucao ef4bb8f51a Merge pull request 'Include saved places in search results' (#59) from feature/search_saved_places into master
CI / Lint (push) Successful in 33s
CI / Test (push) Successful in 56s
Reviewed-on: #59
2026-06-06 08:03:32 +00:00
raucao f82a797720 Include list names in search results for saved places
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 55s
Release Drafter / Update release notes draft (pull_request) Successful in 16s
2026-06-06 12:00:48 +04:00
raucao f9cb22ee0e Include saved places in search results 2026-06-06 11:47:27 +04:00
24 changed files with 1015 additions and 96 deletions
+183 -41
View File
@@ -5,7 +5,11 @@ import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import { normalizeRelayUrl } from '../../../utils/nostr';
import {
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
} from '../../../utils/nostr';
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
@@ -17,6 +21,74 @@ export default class AppMenuSettingsNostr extends Component {
@tracked newReadRelay = '';
@tracked newWriteRelay = '';
get customReadRelays() {
return excludeRequiredRelays(
this.settings.nostrReadRelays || [],
this.nostrData.requiredReadRelays
);
}
get customWriteRelays() {
return excludeRequiredRelays(
this.settings.nostrWriteRelays || [],
this.nostrData.requiredWriteRelays
);
}
get readRelayExclusions() {
return this.settings.nostrReadRelayExclusions || [];
}
get writeRelayExclusions() {
return this.settings.nostrWriteRelayExclusions || [];
}
get requiredReadRelaySet() {
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
}
get requiredWriteRelaySet() {
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
}
get mailboxReadRelaySet() {
return new Set(this.nostrData.mailboxReadRelays);
}
get mailboxWriteRelaySet() {
return new Set(this.nostrData.mailboxWriteRelays);
}
get hasReadOverrides() {
return (
this.customReadRelays.length > 0 || this.readRelayExclusions.length > 0
);
}
get hasWriteOverrides() {
return (
this.customWriteRelays.length > 0 || this.writeRelayExclusions.length > 0
);
}
get readRelaysForDisplay() {
return this.nostrData.activeReadRelays.map((url) => {
return {
url,
isRequired: this.requiredReadRelaySet.has(url),
};
});
}
get writeRelaysForDisplay() {
return this.nostrData.activeWriteRelays.map((url) => {
return {
url,
isRequired: this.requiredWriteRelaySet.has(url),
};
});
}
@action
updateNewReadRelay(event) {
this.newReadRelay = event.target.value;
@@ -32,19 +104,51 @@ export default class AppMenuSettingsNostr extends Component {
const url = normalizeRelayUrl(this.newReadRelay);
if (!url) return;
const current =
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
const set = new Set([...current, url]);
this.settings.update('nostrReadRelays', Array.from(set));
const merged = mergeRequiredRelays(this.nostrData.requiredReadRelays, [
...this.customReadRelays,
url,
]);
const custom = excludeRequiredRelays(
merged,
this.nostrData.requiredReadRelays
);
const readExclusions = this.readRelayExclusions.filter((relay) => {
return normalizeRelayUrl(relay) !== url;
});
this.settings.update('nostrReadRelays', custom.length > 0 ? custom : null);
this.settings.update(
'nostrReadRelayExclusions',
readExclusions.length > 0 ? readExclusions : null
);
this.newReadRelay = '';
}
@action
removeReadRelay(url) {
const current =
this.settings.nostrReadRelays || this.nostrData.defaultReadRelays;
const filtered = current.filter((r) => r !== url);
this.settings.update('nostrReadRelays', filtered);
if (this.requiredReadRelaySet.has(url)) {
return;
}
const normalizedUrl = normalizeRelayUrl(url);
const remainingCustom = this.customReadRelays.filter((relay) => {
return normalizeRelayUrl(relay) !== normalizedUrl;
});
const nextExclusions = this.mailboxReadRelaySet.has(normalizedUrl)
? Array.from(new Set([...this.readRelayExclusions, normalizedUrl]))
: this.readRelayExclusions;
this.settings.update(
'nostrReadRelays',
remainingCustom.length > 0 ? remainingCustom : null
);
this.settings.update(
'nostrReadRelayExclusions',
nextExclusions.length > 0 ? nextExclusions : null
);
}
@action
@@ -64,6 +168,7 @@ export default class AppMenuSettingsNostr extends Component {
@action
resetReadRelays() {
this.settings.update('nostrReadRelays', null);
this.settings.update('nostrReadRelayExclusions', null);
}
@action
@@ -71,24 +176,57 @@ export default class AppMenuSettingsNostr extends Component {
const url = normalizeRelayUrl(this.newWriteRelay);
if (!url) return;
const current =
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
const set = new Set([...current, url]);
this.settings.update('nostrWriteRelays', Array.from(set));
const merged = mergeRequiredRelays(this.nostrData.requiredWriteRelays, [
...this.customWriteRelays,
url,
]);
const custom = excludeRequiredRelays(
merged,
this.nostrData.requiredWriteRelays
);
const writeExclusions = this.writeRelayExclusions.filter((relay) => {
return normalizeRelayUrl(relay) !== url;
});
this.settings.update('nostrWriteRelays', custom.length > 0 ? custom : null);
this.settings.update(
'nostrWriteRelayExclusions',
writeExclusions.length > 0 ? writeExclusions : null
);
this.newWriteRelay = '';
}
@action
removeWriteRelay(url) {
const current =
this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays;
const filtered = current.filter((r) => r !== url);
this.settings.update('nostrWriteRelays', filtered);
if (this.requiredWriteRelaySet.has(url)) {
return;
}
const normalizedUrl = normalizeRelayUrl(url);
const remainingCustom = this.customWriteRelays.filter((relay) => {
return normalizeRelayUrl(relay) !== normalizedUrl;
});
const nextExclusions = this.mailboxWriteRelaySet.has(normalizedUrl)
? Array.from(new Set([...this.writeRelayExclusions, normalizedUrl]))
: this.writeRelayExclusions;
this.settings.update(
'nostrWriteRelays',
remainingCustom.length > 0 ? remainingCustom : null
);
this.settings.update(
'nostrWriteRelayExclusions',
nextExclusions.length > 0 ? nextExclusions : null
);
}
@action
resetWriteRelays() {
this.settings.update('nostrWriteRelays', null);
this.settings.update('nostrWriteRelayExclusions', null);
}
@action
@@ -112,18 +250,20 @@ export default class AppMenuSettingsNostr extends Component {
<div class="form-group">
<label for="new-read-relay">Read Relays</label>
<ul class="relay-list">
{{#each this.nostrData.activeReadRelays as |relay|}}
{{#each this.readRelaysForDisplay as |relay|}}
<li>
<span>{{stripProtocol relay}}</span>
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeReadRelay relay)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
<span>{{stripProtocol relay.url}}</span>
{{#unless relay.isRequired}}
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeReadRelay relay.url)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
{{/unless}}
</li>
{{/each}}
</ul>
@@ -143,7 +283,7 @@ export default class AppMenuSettingsNostr extends Component {
{{on "click" this.addReadRelay}}
>Add</button>
</div>
{{#if this.settings.nostrReadRelays}}
{{#if this.hasReadOverrides}}
<button
type="button"
class="btn-link reset-relays"
@@ -157,18 +297,20 @@ export default class AppMenuSettingsNostr extends Component {
<div class="form-group">
<label for="new-write-relay">Write Relays</label>
<ul class="relay-list">
{{#each this.nostrData.activeWriteRelays as |relay|}}
{{#each this.writeRelaysForDisplay as |relay|}}
<li>
<span>{{stripProtocol relay}}</span>
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeWriteRelay relay)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
<span>{{stripProtocol relay.url}}</span>
{{#unless relay.isRequired}}
<button
type="button"
class="btn-remove-relay"
title="Remove relay"
aria-label="Remove"
{{on "click" (fn this.removeWriteRelay relay.url)}}
>
<Icon @name="x" @size={{14}} @color="currentColor" />
</button>
{{/unless}}
</li>
{{/each}}
</ul>
@@ -188,7 +330,7 @@ export default class AppMenuSettingsNostr extends Component {
{{on "click" this.addWriteRelay}}
>Add</button>
</div>
{{#if this.settings.nostrWriteRelays}}
{{#if this.hasWriteOverrides}}
<button
type="button"
class="btn-link reset-relays"
+17 -4
View File
@@ -23,6 +23,7 @@ export default class PlaceDetails extends Component {
@service storage;
@service nostrAuth;
@service nostrData;
@service mapUi;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadActive = false;
@@ -345,9 +346,21 @@ export default class PlaceDetails extends Component {
get osmUrl() {
const id = this.place.osmId;
if (!id) return null;
const type = this.place.osmType || 'node';
return `https://www.openstreetmap.org/${type}/${id}`;
if (id) {
const type = this.place.osmType || 'node';
return `https://www.openstreetmap.org/${type}/${id}`;
}
const lat = this.place.lat;
const lon = this.place.lon;
if (!lat || !lon) return null;
const viewLat = this.mapUi.currentCenter?.lat ?? lat;
const viewLon = this.mapUi.currentCenter?.lon ?? lon;
const zoom = this.mapUi.currentZoom ?? 17;
const roundedZoom = Math.round(zoom);
return `https://www.openstreetmap.org/search?lat=${lat}&lon=${lon}&zoom=${roundedZoom}#map=${roundedZoom}/${Number(viewLat).toFixed(5)}/${Number(viewLon).toFixed(5)}`;
}
get gmapsUrl() {
@@ -591,7 +604,7 @@ export default class PlaceDetails extends Component {
</div>
{{#if this.osmUrl}}
{{#if this.place.osmId}}
<div class="meta-info">
<p class="content-with-icon">
<Icon @name="feather-camera" />
+52 -3
View File
@@ -13,6 +13,7 @@ import { eq, or } from 'ember-truth-helpers';
export default class SearchBoxComponent extends Component {
@service photon;
@service osm;
@service storage;
@service router;
@service mapUi;
@service map; // Assuming we might need map context, but mostly we use router
@@ -50,6 +51,29 @@ export default class SearchBoxComponent extends Component {
this.searchTask.perform(value);
}
formatSavedPlace(place) {
const listNames = (place._listIds || [])
.map((id) => this.storage.lists?.find((l) => l.id === id)?.title)
.filter(Boolean)
.join(', ');
const description = listNames
? `Saved place (${listNames})`
: 'Saved place';
return {
source: 'saved',
id: place.id,
title: place.title,
icon: 'bookmark',
description,
osmId: place.osmId,
osmType: place.osmType,
lat: place.lat,
lon: place.lon,
};
}
searchTask = task({ restartable: true }, async (term) => {
await timeout(300);
@@ -76,8 +100,29 @@ export default class SearchBoxComponent extends Component {
icon: 'search',
}));
// Filter saved places (minimum 3 characters)
let savedMatches = [];
if (q.length >= 3) {
savedMatches = this.storage.savedPlaces
.filter((p) => p.title && p.title.toLowerCase().includes(q))
.map((p) => this.formatSavedPlace(p));
}
const results = await this.photon.search(query, lat, lon);
this.results = [...categoryMatches, ...results];
// Deduplicate Photon results that are already in saved matches
const savedOsmIds = new Set(
savedMatches.map((s) => s.osmId).filter(Boolean)
);
const filteredPhotonResults = results.filter(
(r) => !savedOsmIds.has(r.osmId)
);
this.results = [
...categoryMatches,
...savedMatches,
...filteredPhotonResults,
];
} catch (e) {
console.error('Search failed', e);
this.results = [];
@@ -156,8 +201,12 @@ export default class SearchBoxComponent extends Component {
}
this.results = []; // Hide popover
// If it has an OSM ID, go to place details
if (place.osmId) {
// If it's a custom saved place without an OSM ID, go to place details via internal ID
if (place.source === 'saved' && place.id && !place.osmId) {
this.router.transitionTo('place', place.id);
}
// If it has an OSM ID, go to place details via OSM ID
else if (place.osmId) {
// Format: osm:node:123
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
const id = `osm:${place.osmType}:${place.osmId}`;
+10 -7
View File
@@ -78,14 +78,17 @@ export default class SearchController extends Controller {
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);
// Search local bookmarks by name
// Search local bookmarks by name (minimum 3 characters)
const queryLower = params.q.toLowerCase();
const localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
let localMatches = [];
if (queryLower.length >= 3) {
localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
}
// Merge local matches
localMatches.forEach((local) => {
+52 -28
View File
@@ -6,7 +6,12 @@ import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { NostrIDB, openDB } from 'nostr-idb';
import { normalizeRelayUrl } from '../utils/nostr';
import {
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
uniqNormalizedRelays,
} from '../utils/nostr';
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
const DIRECTORY_RELAYS = [
@@ -83,43 +88,62 @@ export default class NostrDataService extends Service {
});
}
get defaultReadRelays() {
const mailboxes = (this.mailboxes?.inboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
get requiredReadRelays() {
return DEFAULT_READ_RELAYS;
}
get defaultWriteRelays() {
const mailboxes = (this.mailboxes?.outboxes || [])
get requiredWriteRelays() {
return DEFAULT_WRITE_RELAYS;
}
get mailboxReadRelays() {
return (this.mailboxes?.inboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
const defaults =
DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean);
return Array.from(new Set([...defaults, ...mailboxes]));
}
get mailboxWriteRelays() {
return (this.mailboxes?.outboxes || [])
.map(normalizeRelayUrl)
.filter(Boolean);
}
get configuredReadRelays() {
const configured = uniqNormalizedRelays([
...this.mailboxReadRelays,
...(this.settings.nostrReadRelays || []),
]);
return excludeRequiredRelays(
configured,
this.settings.nostrReadRelayExclusions || []
);
}
get configuredWriteRelays() {
const configured = uniqNormalizedRelays([
...this.mailboxWriteRelays,
...(this.settings.nostrWriteRelays || []),
]);
return excludeRequiredRelays(
configured,
this.settings.nostrWriteRelayExclusions || []
);
}
get activeReadRelays() {
if (this.settings.nostrReadRelays) {
return Array.from(
new Set(
this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultReadRelays;
return mergeRequiredRelays(
this.requiredReadRelays,
this.configuredReadRelays
);
}
get activeWriteRelays() {
if (this.settings.nostrWriteRelays) {
return Array.from(
new Set(
this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean)
)
);
}
return this.defaultWriteRelays;
return mergeRequiredRelays(
this.requiredWriteRelays,
this.configuredWriteRelays
);
}
async loadPlacesInBounds(bbox) {
+9
View File
@@ -9,6 +9,8 @@ const DEFAULT_SETTINGS = {
nostrPhotoFallbackUploads: false,
nostrReadRelays: null,
nostrWriteRelays: null,
nostrReadRelayExclusions: null,
nostrWriteRelayExclusions: null,
experimentalEnablePhotoDeletion: false,
};
@@ -21,6 +23,9 @@ export default class SettingsService extends Service {
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
@tracked nostrWriteRelayExclusions =
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
@tracked experimentalEnablePhotoDeletion =
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
@@ -111,6 +116,8 @@ export default class SettingsService extends Service {
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
this.nostrReadRelays = finalSettings.nostrReadRelays;
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
this.experimentalEnablePhotoDeletion =
finalSettings.experimentalEnablePhotoDeletion;
@@ -127,6 +134,8 @@ export default class SettingsService extends Service {
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
nostrReadRelays: this.nostrReadRelays,
nostrWriteRelays: this.nostrWriteRelays,
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
};
localStorage.setItem('marco:settings', JSON.stringify(settings));
+14
View File
@@ -1266,6 +1266,20 @@ span.icon {
gap: 0.5rem;
}
.content-with-icon > span:not(.icon) {
min-width: 0;
flex: 1;
}
.content-with-icon > span:not(.icon) a {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.content-with-icon .icon {
margin-top: 0.15rem;
}
+27
View File
@@ -1,4 +1,7 @@
// AGENT: Keep imports sorted alphabetically, grouped by feather-icons → pinhead → custom/local
/*
* Feather icons
*/
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';
@@ -40,6 +43,9 @@ import check from 'feather-icons/dist/icons/check.svg?raw';
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
/*
* Pinhead icons
*/
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
@@ -53,8 +59,12 @@ import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
import carAndWrench from '@waysidemapping/pinhead/dist/icons/car_and_wrench.svg?raw';
import castleKeep from '@waysidemapping/pinhead/dist/icons/castle_keep.svg?raw';
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
import cityGate from '@waysidemapping/pinhead/dist/icons/city_gate.svg?raw';
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
import classicalBuildingWithClock from '@waysidemapping/pinhead/dist/icons/classical_building_with_clock.svg?raw';
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
@@ -82,8 +92,13 @@ import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
import infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
@@ -118,6 +133,9 @@ import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/w
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
/*
* Custom/local icons
*/
import loadingRing from '../icons/270-ring.svg?raw';
import nostrich from '../icons/nostrich-2.svg?raw';
import remotestorage from '../icons/remotestorage.svg?raw';
@@ -144,11 +162,13 @@ const ICONS = {
'chevron-left': chevronLeft,
'chevron-right': chevronRight,
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
'city-gate': cityGate,
climbing_wall: climbingWall,
check,
'alert-circle': alertCircle,
'alert-triangle': alertTriangle,
'classical-building': classicalBuilding,
'classical-building-with-clock': classicalBuildingWithClock,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag,
'commercial-building': commercialBuilding,
@@ -185,6 +205,7 @@ const ICONS = {
'ice-cream-on-cone': iceCreamOnCone,
'industrial-building': industrialBuilding,
info,
'info-i': infoI,
instagram,
jewel,
'log-in': logIn,
@@ -193,7 +214,11 @@ const ICONS = {
mail,
map,
'map-pin': mapPin,
'market-stall': marketStall,
'memorial-stone-with-inscription': memorialStoneWithInscription,
menu,
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
'molar-tooth': molarTooth,
'more-horizontal': moreHorizontal,
'more-vertical': moreVertical,
navigation,
@@ -245,6 +270,8 @@ const ICONS = {
winding_way_wide: windingWayWide,
parking_p: parkingP,
car,
'car-and-wrench': carAndWrench,
'castle-keep': castleKeep,
x,
zap,
'loading-ring': loadingRing,
+24
View File
@@ -14,6 +14,30 @@ export function normalizeRelayUrl(url) {
return normalized;
}
export function uniqNormalizedRelays(relays = []) {
return Array.from(new Set(relays.map(normalizeRelayUrl).filter(Boolean)));
}
export function mergeRequiredRelays(requiredRelays = [], customRelays = []) {
const requiredSet = new Set(requiredRelays.filter(Boolean));
const merged = [...requiredRelays.filter(Boolean)];
for (const relay of uniqNormalizedRelays(customRelays)) {
if (!requiredSet.has(relay)) {
merged.push(relay);
}
}
return merged;
}
export function excludeRequiredRelays(customRelays = [], requiredRelays = []) {
const requiredSet = new Set(requiredRelays.filter(Boolean));
return uniqNormalizedRelays(customRelays).filter((relay) => {
return !requiredSet.has(relay);
});
}
/**
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
+6
View File
@@ -27,6 +27,8 @@ export const POI_ICON_RULES = [
{ tags: { amenity: 'bank' }, icon: 'banknote' },
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
{ tags: { amenity: 'townhall' }, icon: 'classical-building-with-clock' },
{ tags: { building: 'townhall' }, icon: 'classical-building-with-clock' },
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
@@ -72,6 +74,7 @@ export const POI_ICON_RULES = [
tags: { shop: 'beauty' },
icon: 'fancy-mirror-with-reflection-and-stars',
},
{ tags: { shop: 'car_repair' }, icon: 'car-and-wrench' },
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
{ tags: { office: true }, icon: 'commercial-building' },
@@ -103,6 +106,7 @@ export const POI_ICON_RULES = [
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
{ tags: { tourism: 'zoo' }, icon: 'camera' },
{ tags: { tourism: 'artwork' }, icon: 'camera' },
{ tags: { tourism: 'information' }, icon: 'info-i' },
{ tags: { amenity: 'cinema' }, icon: 'film' },
{ tags: { amenity: 'theatre' }, icon: 'camera' },
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
@@ -113,7 +117,9 @@ export const POI_ICON_RULES = [
{ tags: { historic: 'bridge' }, icon: 'bridge' },
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
{ tags: { historic: 'fort' }, icon: 'fort' },
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
{ tags: { historic: 'castle' }, icon: 'palace' },
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
+10 -1
View File
@@ -56,15 +56,24 @@ const PLACE_TYPE_KEYS = [
export function getPlaceType(tags) {
if (!tags) return null;
let fallbackKey = null;
for (const key of PLACE_TYPE_KEYS) {
const value = tags[key];
if (value) {
if (value === 'yes') {
return humanizeOsmTag(key);
if (!fallbackKey) {
fallbackKey = key;
}
continue;
}
return humanizeOsmTag(value);
}
}
if (fallbackKey) {
return humanizeOsmTag(fallbackKey);
}
return null;
}
+2 -2
View File
@@ -43,7 +43,7 @@ export const POI_CATEGORIES = [
label: 'Things to do',
icon: 'feather-camera',
filter: [
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
'["historic"]',
@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
id: 'accommodation',
label: 'Hotels',
icon: 'person-sleeping-in-bed',
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
types: ['node', 'way', 'relation'],
},
];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.23.0",
"version": "1.24.0",
"private": true,
"description": "Unhosted maps app",
"repository": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -39,7 +39,7 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-DSyq2vVy.js"></script>
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
</head>
<body>
+10 -2
View File
@@ -49,11 +49,19 @@ export class MockNostrDataService extends Service {
return [];
}
get defaultReadRelays() {
get requiredReadRelays() {
return [];
}
get defaultWriteRelays() {
get requiredWriteRelays() {
return [];
}
get mailboxReadRelays() {
return [];
}
get mailboxWriteRelays() {
return [];
}
@@ -0,0 +1,188 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { click, fillIn, render } from '@ember/test-helpers';
import Service, { service } from '@ember/service';
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
import {
excludeRequiredRelays,
mergeRequiredRelays,
uniqNormalizedRelays,
} from 'marco/utils/nostr';
class MockNostrDataService extends Service {
@service settings;
requiredReadRelays = ['wss://nostr.kosmos.org'];
requiredWriteRelays = [];
mailboxReadRelays = ['wss://mailbox.example.com'];
mailboxWriteRelays = ['wss://mailbox-write.example.com'];
get configuredReadRelays() {
const configured = uniqNormalizedRelays([
...this.mailboxReadRelays,
...(this.settings.nostrReadRelays || []),
]);
return excludeRequiredRelays(
configured,
this.settings.nostrReadRelayExclusions || []
);
}
get configuredWriteRelays() {
const configured = uniqNormalizedRelays([
...this.mailboxWriteRelays,
...(this.settings.nostrWriteRelays || []),
]);
return excludeRequiredRelays(
configured,
this.settings.nostrWriteRelayExclusions || []
);
}
get activeReadRelays() {
return mergeRequiredRelays(
this.requiredReadRelays,
this.configuredReadRelays
);
}
get activeWriteRelays() {
return mergeRequiredRelays(
this.requiredWriteRelays,
this.configuredWriteRelays
);
}
async clearCache() {}
}
function readRows(element) {
const list = element.querySelectorAll('.relay-list')[0];
return [...list.querySelectorAll('li')];
}
function writeRows(element) {
const list = element.querySelectorAll('.relay-list')[1];
return [...list.querySelectorAll('li')];
}
function rowByText(rows, text) {
return rows.find((row) => row.textContent.includes(text));
}
module('Integration | Component | app-menu/settings/nostr', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
localStorage.removeItem('marco:settings');
this.owner.register('service:nostrData', MockNostrDataService);
this.settings = this.owner.lookup('service:settings');
this.onChange = () => {};
});
hooks.afterEach(function () {
localStorage.removeItem('marco:settings');
});
async function renderAndOpenDetails(context) {
await render(
<template><AppMenuSettingsNostr @onChange={{this.onChange}} /></template>
);
await click('summary');
return context.element;
}
test('required read relay is first and non-removable', async function (assert) {
const element = await renderAndOpenDetails(this);
const rows = readRows(element);
assert.dom(rows[0]).includesText('nostr.kosmos.org');
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
const mailboxRow = rowByText(rows, 'mailbox.example.com');
assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist();
assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists();
});
test('removing mailbox read relay stores exclusion override', async function (assert) {
const element = await renderAndOpenDetails(this);
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
await click(mailboxRow.querySelector('.btn-remove-relay'));
assert.deepEqual(this.settings.nostrReadRelayExclusions, [
'wss://mailbox.example.com',
]);
assert.strictEqual(this.settings.nostrReadRelays, null);
});
test('removing custom read relay updates custom list without exclusions', async function (assert) {
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
const element = await renderAndOpenDetails(this);
const customRow = rowByText(readRows(element), 'custom.example.com');
await click(customRow.querySelector('.btn-remove-relay'));
assert.strictEqual(this.settings.nostrReadRelays, null);
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
});
test('adding read relay clears existing exclusion for same relay', async function (assert) {
this.settings.update('nostrReadRelayExclusions', [
'wss://mailbox.example.com',
]);
const element = await renderAndOpenDetails(this);
await fillIn('#new-read-relay', 'Mailbox.EXAMPLE.com/');
await click(element.querySelector('#new-read-relay').nextElementSibling);
assert.deepEqual(this.settings.nostrReadRelays, [
'wss://mailbox.example.com',
]);
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
});
test('reset read relays clears additions and exclusions', async function (assert) {
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
this.settings.update('nostrReadRelayExclusions', [
'wss://mailbox.example.com',
]);
const element = await renderAndOpenDetails(this);
await click(element.querySelectorAll('.reset-relays')[0]);
assert.strictEqual(this.settings.nostrReadRelays, null);
assert.strictEqual(this.settings.nostrReadRelayExclusions, null);
const requiredRow = rowByText(readRows(element), 'nostr.kosmos.org');
assert.dom(requiredRow).exists();
});
test('write relays are removable and mailbox delete stores exclusion', async function (assert) {
this.settings.update('nostrWriteRelays', [
'wss://custom-write.example.com',
]);
const element = await renderAndOpenDetails(this);
const rows = writeRows(element);
assert.true(
rows.every((row) => row.querySelector('.btn-remove-relay')),
'all write relays can be removed'
);
const mailboxRow = rowByText(rows, 'mailbox-write.example.com');
await click(mailboxRow.querySelector('.btn-remove-relay'));
assert.deepEqual(this.settings.nostrWriteRelayExclusions, [
'wss://mailbox-write.example.com',
]);
});
});
@@ -334,4 +334,54 @@ module('Integration | Component | place-details', function (hooks) {
assert.dom(links[0]).hasText('+44 987 654 321');
assert.dom(links[1]).hasText('+1 234-567 8900');
});
test('it renders correct OpenStreetMap link for an OSM place', async function (assert) {
const place = {
title: 'OSM Place',
osmId: '12345',
osmType: 'node',
lat: 52.520008,
lon: 13.404954,
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const osmLink = this.element.querySelector(
'.meta-info a[href^="https://www.openstreetmap.org/node/12345"]'
);
assert.ok(osmLink, 'OpenStreetMap link is rendered');
assert.strictEqual(
osmLink.getAttribute('href'),
'https://www.openstreetmap.org/node/12345'
);
assert.dom('button.btn-link').hasText('Add a photo');
});
test('it renders correct search-based OpenStreetMap link for a custom saved place', async function (assert) {
class MockMapUi extends Service {
currentCenter = { lat: 52.5, lon: 13.4 };
currentZoom = 15.6;
}
this.owner.register('service:map-ui', MockMapUi);
const place = {
title: 'Custom Place',
lat: 52.520008,
lon: 13.404954,
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const osmLink = this.element.querySelector(
'.meta-info a[href^="https://www.openstreetmap.org/search"]'
);
assert.ok(osmLink, 'OpenStreetMap search link is rendered');
assert.strictEqual(
osmLink.getAttribute('href'),
'https://www.openstreetmap.org/search?lat=52.520008&lon=13.404954&zoom=16#map=16/52.50000/13.40000'
);
assert.dom('button.btn-link').doesNotExist();
});
});
@@ -230,4 +230,279 @@ module('Integration | Component | search-box', function (hooks) {
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
]);
});
test('it includes, deduplicates, and prioritizes saved places in search results', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo(routeName, id) {
assert.step(`transitionTo: ${routeName} ["${id}"]`);
}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service
class MockStorageService extends Service {
lists = [{ id: 'favs', title: 'Favorites' }];
savedPlaces = [
{
title: 'Awesome Coffee',
lat: 52.5,
lon: 13.4,
osmId: '999',
osmType: 'node',
_listIds: ['favs'],
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search(query) {
if (query === 'coffee') {
return [
{
title: 'Awesome Coffee',
osmId: '999',
osmType: 'node',
description: 'Duplicate to be removed',
},
{
title: 'Other Coffee',
osmId: '888',
osmType: 'node',
description: 'A different coffee shop',
},
];
}
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type "coffee" to trigger matches in Category, Saved, and Photon
await fillIn('.search-input', 'coffee');
await waitFor('.search-results-popover', { timeout: 2000 });
const resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should be exactly 3 items:
// 1. Category (Coffee)
// 2. Saved (Awesome Coffee)
// 3. Photon (Other Coffee)
// (The Photon duplicate of "Awesome Coffee" is removed)
assert.strictEqual(resultItems.length, 3, 'Renders exactly 3 items');
// 1. Category
assert.ok(
resultItems[0].textContent.includes('Coffee'),
'First item is the category match'
);
assert
.dom(resultItems[0].querySelector('.result-icon svg'))
.hasClass('feather-search', 'Category uses search icon');
// 2. Saved Place
assert.ok(
resultItems[1].textContent.includes('Awesome Coffee'),
'Second item is the saved place match'
);
assert.ok(
resultItems[1].textContent.includes('Saved place'),
'Saved place has correct description text'
);
assert
.dom(resultItems[1].querySelector('.result-icon svg'))
.hasClass('feather-bookmark', 'Saved place uses bookmark icon');
// 3. Photon Match
assert.ok(
resultItems[2].textContent.includes('Other Coffee'),
'Third item is the unique photon result'
);
// Click the Saved Place
await click(resultItems[1]);
assert.verifySteps(['transitionTo: place ["osm:node:999"]']);
});
test('it requires 3 or more characters to match saved places', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo() {}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service
class MockStorageService extends Service {
lists = [{ id: 'favs', title: 'Favorites' }];
savedPlaces = [
{
title: 'Awesome Coffee',
lat: 52.5,
lon: 13.4,
osmId: '999',
osmType: 'node',
_listIds: ['favs'],
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search(query) {
if (query === 'aw' || query === 'awe') {
return [
{
title: 'Aww Some Place',
osmId: '111',
osmType: 'node',
},
];
}
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type "aw" (2 characters)
await fillIn('.search-input', 'aw');
await waitFor('.search-results-popover', { timeout: 2000 });
let resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should only show Photon match since 'aw' is < 3 characters
assert.strictEqual(
resultItems.length,
1,
'Renders exactly 1 item for 2 chars'
);
assert.ok(
resultItems[0].textContent.includes('Aww Some Place'),
'Shows photon match'
);
assert.notOk(
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
'Saved place is NOT shown for 2 char query'
);
// Type "awe" (3 characters)
await fillIn('.search-input', 'awe');
await waitFor('.search-results-popover', { timeout: 2000 });
resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should now show Saved Place and Photon match
assert.strictEqual(
resultItems.length,
2,
'Renders exactly 2 items for 3 chars'
);
assert.ok(
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
'Saved place is now shown'
);
assert.ok(
resultItems.some((item) =>
item.textContent.includes('Saved place (Favorites)')
),
'List names are appended to the description'
);
});
test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo(routeName, id) {
assert.step(`transitionTo: ${routeName} ["${id}"]`);
}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service (Custom Place)
class MockStorageService extends Service {
savedPlaces = [
{
id: 'custom-1234',
title: 'My Custom Home',
lat: 52.5,
lon: 13.4,
// Notice NO osmId or osmType
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search() {
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type 3 chars to trigger saved place match
await fillIn('.search-input', 'cus');
await waitFor('.search-results-popover', { timeout: 2000 });
const resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Ensure our custom place is rendered
const customResult = resultItems.find((item) =>
item.textContent.includes('My Custom Home')
);
assert.ok(customResult, 'Custom place is rendered');
// Click it
await click(customResult);
// Verify it navigated using the internal ID, NOT a search query
assert.verifySteps(['transitionTo: place ["custom-1234"]']);
});
});
+62 -1
View File
@@ -1,5 +1,11 @@
import { module, test } from 'qunit';
import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr';
import {
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
parsePlacePhotos,
uniqNormalizedRelays,
} from 'marco/utils/nostr';
module('Unit | Utility | nostr', function () {
test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) {
@@ -141,4 +147,59 @@ module('Unit | Utility | nostr', function () {
assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123');
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
});
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
const relays = uniqNormalizedRelays([
'Relay.example.com',
'wss://relay.example.com/',
'wss://other.example.com',
]);
assert.deepEqual(relays, [
'wss://relay.example.com',
'wss://other.example.com',
]);
});
test('mergeRequiredRelays keeps required relays as-is and merges normalized custom relays', function (assert) {
const relays = mergeRequiredRelays(
['wss://required.example.com', 'required-2.example.com'],
['required-2.example.com/', 'wss://custom.example.com']
);
assert.deepEqual(relays, [
'wss://required.example.com',
'required-2.example.com',
'wss://required-2.example.com',
'wss://custom.example.com',
]);
});
test('excludeRequiredRelays removes required relays from normalized custom list', function (assert) {
const relays = excludeRequiredRelays(
[
'wss://required.example.com',
'custom.example.com',
'ws://custom2.example.com',
],
['wss://required.example.com']
);
assert.deepEqual(relays, [
'wss://custom.example.com',
'ws://custom2.example.com',
]);
});
test('excludeRequiredRelays trusts required list without normalizing it', function (assert) {
const relays = excludeRequiredRelays(
['Required.example.com', 'custom.example.com'],
['required.example.com']
);
assert.deepEqual(relays, [
'wss://required.example.com',
'wss://custom.example.com',
]);
});
});
+12 -1
View File
@@ -1,4 +1,5 @@
import { getIconNameForTags } from 'marco/utils/osm-icons';
import { getIconNameForTags, POI_ICON_RULES } from 'marco/utils/osm-icons';
import { getIcon } from 'marco/utils/icons';
import { module, test } from 'qunit';
module('Unit | Utility | osm-icons', function () {
@@ -36,4 +37,14 @@ module('Unit | Utility | osm-icons', function () {
let result = getIconNameForTags({ foo: 'bar' });
assert.strictEqual(result, null);
});
test('all icons used in POI_ICON_RULES exist in the icons utility', function (assert) {
for (let rule of POI_ICON_RULES) {
let icon = getIcon(rule.icon);
assert.ok(
icon,
`Icon "${rule.icon}" specified in POI_ICON_RULES should be imported and available in getIcon`
);
}
});
});
+6
View File
@@ -89,6 +89,12 @@ module('Unit | Utility | osm', function (hooks) {
assert.strictEqual(result, 'Building');
});
test('getPlaceType ignores generic "yes" values if a more specific tag is present', function (assert) {
const tags = { historic: 'yes', building: 'tower' };
const result = getPlaceType(tags);
assert.strictEqual(result, 'Tower');
});
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
// If something is both a shop and a building, it should be a shop
const tags = { building: 'yes', shop: 'supermarket' };