Compare commits

..
9 Commits
Author SHA1 Message Date
raucao a0eea6024a 1.34.1
CI / Lint (push) Successful in 1m16s
CI / Test (push) Successful in 1m21s
2026-09-17 23:51:16 +02:00
raucao ec0751de14 Merge pull request 'Show Nearby search in search input field' (#107) from feature/nearby_search_text into master
CI / Lint (push) Successful in 1m6s
CI / Test (push) Successful in 1m24s
Reviewed-on: #107
2026-09-17 21:48:47 +00:00
raucao b295636799 Show Nearby search in search input field
CI / Lint (pull_request) Successful in 1m8s
CI / Test (pull_request) Successful in 1m28s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
Add a special Nearby category, show it in the input field, and make it
dismissable via X button (same as other searches)
2026-09-17 23:38:50 +02:00
raucao 7a8d4531f7 Merge pull request 'Clear search with no results when closing drawer' (#106) from chore/clear_empty_search into master
CI / Lint (push) Successful in 1m9s
CI / Test (push) Successful in 1m27s
Reviewed-on: #106
2026-09-17 21:05:06 +00:00
raucao 12e746c2a3 Clear search with no results when closing drawer
CI / Lint (pull_request) Successful in 1m12s
CI / Test (pull_request) Successful in 1m29s
Release Drafter / Update release notes draft (pull_request) Successful in 6s
2026-09-17 23:01:07 +02:00
raucao cb60e1d9b5 Merge pull request 'Hide Nostr publishing settings when no account connected' (#105) from feature/nostr_settings into master
CI / Lint (push) Successful in 1m12s
CI / Test (push) Successful in 1m30s
Reviewed-on: #105
2026-09-17 20:30:55 +00:00
raucao 75c0862134 Hide Nostr publishing settings when no account connected
CI / Lint (pull_request) Successful in 1m11s
CI / Test (pull_request) Successful in 1m30s
Release Drafter / Update release notes draft (pull_request) Successful in 15s
2026-09-17 22:24:51 +02:00
raucao 6ba2f1d132 Merge pull request 'Retry unresolved profiles' (#104) from feature/retry_unresolved_profiles into master
CI / Lint (push) Successful in 1m14s
CI / Test (push) Successful in 1m33s
Reviewed-on: #104
2026-09-17 19:59:36 +00:00
raucao a41ad8c99c Retry unresolved profiles
CI / Lint (pull_request) Successful in 1m14s
CI / Test (pull_request) Successful in 1m33s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
For now just with the same relays, in the future maybe with an extended
list
2026-09-17 20:20:23 +02:00
29 changed files with 719 additions and 159 deletions
+2 -2
View File
@@ -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 = '';
+100 -97
View File
@@ -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">
+2 -1
View File
@@ -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,
};
+6 -1
View File
@@ -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() {
+1
View File
@@ -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}} />
+64 -40
View File
@@ -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) {
+4
View File
@@ -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) {
-4
View File
@@ -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) {
+21
View File
@@ -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]);
}
/**
+60
View File
@@ -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;
@@ -922,6 +926,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 +935,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 +1316,7 @@ export default class NostrDataService extends Service {
super.willDestroy(...arguments);
this._cleanupSubscriptions();
this._clearProfileSubs();
this._attemptedProfilePubkeys.clear();
if (this._deletionsSub) {
this._deletionsSub.unsubscribe();
+1 -1
View File
@@ -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;
+3
View File
@@ -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');
}
}
}
+8 -2
View File
@@ -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>
+12
View File
@@ -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);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.34.0",
"version": "1.34.1",
"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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-B0dlspz_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
</head>
<body>
+2
View File
@@ -31,6 +31,8 @@ class MockActivityService extends Service {
async setSourceMode() {}
async retryUnresolvedProfiles() {}
loadMore() {}
stop() {
+43
View File
@@ -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'
);
});
});
+30 -4
View File
@@ -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'
);
});
+112 -2
View File
@@ -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');
});
});
+4
View File
@@ -62,6 +62,10 @@ export class MockNostrDataService extends Service {
return this.profiles[pubkey];
}
refreshProfiles() {
return Promise.resolve();
}
get activeReadRelays() {
return [];
}
@@ -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) {
+115
View File
@@ -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;
+84
View File
@@ -1287,3 +1287,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'
);
});
});