Compare commits

...

5 Commits

Author SHA1 Message Date
b23d54d74f Cache user profile/settings events in IndexedDB 2026-04-21 14:58:05 +04:00
5bd4dba907 Refactor settings menu, add Nostr settings
Adds a setting to control if photos should be uploaded only to the
main/default server, or all known servers of a user.

Only upload to the main server by default, to speed up adding photos.
2026-04-21 14:39:38 +04:00
54ba99673f Break up settings into sub sections 2026-04-21 14:17:14 +04:00
54445f249b Fix app menu header height
Wasn't the same between main menu and sub menus
2026-04-21 14:16:42 +04:00
9828ad2714 Close photo upload window, show toast when published 2026-04-21 14:16:05 +04:00
12 changed files with 351 additions and 147 deletions

View File

@@ -1,129 +1,25 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { action } from '@ember/object';
import Icon from '#components/icon';
import eq from 'ember-truth-helpers/helpers/eq';
import AppMenuSettingsMapUi from './settings/map-ui';
import AppMenuSettingsApis from './settings/apis';
import AppMenuSettingsNostr from './settings/nostr';
export default class AppMenuSettings extends Component {
@service settings;
<template>
<div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2>Settings</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
@action
updateApi(event) {
this.settings.updateOverpassApi(event.target.value);
}
@action
toggleKinetic(event) {
this.settings.updateMapKinetic(event.target.value === 'true');
}
@action
toggleQuickSearchButtons(event) {
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
}
@action
updatePhotonApi(event) {
this.settings.updatePhotonApi(event.target.value);
}
<template>
<div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2>Settings</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content">
<section class="settings-section">
<div class="form-group">
<label for="show-quick-search">Quick search buttons visible</label>
<select
id="show-quick-search"
class="form-control"
{{on "change" this.toggleQuickSearchButtons}}
>
<option
value="true"
selected={{if this.settings.showQuickSearchButtons "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.showQuickSearchButtons
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
<select
id="map-kinetic"
class="form-control"
{{on "change" this.toggleKinetic}}
>
<option
value="true"
selected={{if this.settings.mapKinetic "selected"}}
>
On
</option>
<option
value="false"
selected={{unless this.settings.mapKinetic "selected"}}
>
Off
</option>
</select>
</div>
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select
id="overpass-api"
class="form-control"
{{on "change" this.updateApi}}
>
{{#each this.settings.overpassApis as |api|}}
<option
value={{api.url}}
selected={{if
(eq api.url this.settings.overpassApi)
"selected"
}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
<div class="form-group">
<label for="photon-api">Photon API Provider</label>
<select
id="photon-api"
class="form-control"
{{on "change" this.updatePhotonApi}}
>
{{#each this.settings.photonApis as |api|}}
<option
value={{api.url}}
selected={{if (eq api.url this.settings.photonApi) "selected"}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
</section>
</div>
</template>
}
<div class="sidebar-content">
<section class="settings-section">
<AppMenuSettingsMapUi />
<AppMenuSettingsApis />
<AppMenuSettingsNostr />
</section>
</div>
</template>

View File

@@ -0,0 +1,69 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { action } from '@ember/object';
import Icon from '#components/icon';
import eq from 'ember-truth-helpers/helpers/eq';
export default class AppMenuSettingsApis extends Component {
@service settings;
@action
updateApi(event) {
this.settings.updateOverpassApi(event.target.value);
}
@action
updatePhotonApi(event) {
this.settings.updatePhotonApi(event.target.value);
}
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
<summary>
<Icon @name="server" @size={{20}} />
<span>API Providers</span>
</summary>
<div class="details-content">
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select
id="overpass-api"
class="form-control"
{{on "change" this.updateApi}}
>
{{#each this.settings.overpassApis as |api|}}
<option
value={{api.url}}
selected={{if
(eq api.url this.settings.overpassApi)
"selected"
}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
<div class="form-group">
<label for="photon-api">Photon API Provider</label>
<select
id="photon-api"
class="form-control"
{{on "change" this.updatePhotonApi}}
>
{{#each this.settings.photonApis as |api|}}
<option
value={{api.url}}
selected={{if (eq api.url this.settings.photonApi) "selected"}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
</div>
</details>
</template>
}

View File

@@ -0,0 +1,76 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { action } from '@ember/object';
import Icon from '#components/icon';
export default class AppMenuSettingsMapUi extends Component {
@service settings;
@action
toggleKinetic(event) {
this.settings.updateMapKinetic(event.target.value === 'true');
}
@action
toggleQuickSearchButtons(event) {
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
}
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
<summary>
<Icon @name="map" @size={{20}} />
<span>Map & UI</span>
</summary>
<div class="details-content">
<div class="form-group">
<label for="show-quick-search">Quick search buttons visible</label>
<select
id="show-quick-search"
class="form-control"
{{on "change" this.toggleQuickSearchButtons}}
>
<option
value="true"
selected={{if this.settings.showQuickSearchButtons "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.showQuickSearchButtons
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
<select
id="map-kinetic"
class="form-control"
{{on "change" this.toggleKinetic}}
>
<option
value="true"
selected={{if this.settings.mapKinetic "selected"}}
>
On
</option>
<option
value="false"
selected={{unless this.settings.mapKinetic "selected"}}
>
Off
</option>
</select>
</div>
</div>
</details>
</template>
}

View File

@@ -0,0 +1,53 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { action } from '@ember/object';
import Icon from '#components/icon';
export default class AppMenuSettingsNostr extends Component {
@service settings;
@action
togglePhotoFallbackUploads(event) {
this.settings.updateNostrPhotoFallbackUploads(
event.target.value === 'true'
);
}
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
<summary>
<Icon @name="zap" @size={{20}} />
<span>Nostr</span>
</summary>
<div class="details-content">
<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" this.togglePhotoFallbackUploads}}
>
<option
value="true"
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.nostrPhotoFallbackUploads
"selected"
}}
>
No
</option>
</select>
</div>
</div>
</details>
</template>
}

View File

@@ -551,7 +551,10 @@ export default class PlaceDetails extends Component {
{{#if this.isPhotoUploadModalOpen}}
<Modal @onClose={{this.closePhotoUploadModal}}>
<PlacePhotoUpload @place={{this.saveablePlace}} />
<PlacePhotoUpload
@place={{this.saveablePlace}}
@onClose={{this.closePhotoUploadModal}}
/>
</Modal>
{{/if}}

View File

@@ -183,11 +183,16 @@ export default class PlacePhotoUpload extends Component {
const event = await factory.sign(template);
await this.nostrRelay.publish(event);
this.status = 'Published successfully!';
this.toast.show('Photos published successfully');
this.status = '';
// Clear out the files so user can upload more or be done
this.files = [];
this.uploadedPhotos = [];
if (this.args.onClose) {
this.args.onClose();
}
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';

View File

@@ -21,10 +21,17 @@ function getBlossomUrl(serverUrl, path) {
export default class BlossomService extends Service {
@service nostrAuth;
@service nostrData;
@service settings;
get servers() {
const servers = this.nostrData.blossomServers;
return servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
const allServers = servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
if (!this.settings.nostrPhotoFallbackUploads) {
return [allServers[0]];
}
return allServers;
}
async _getAuthHeader(action, hash, serverUrl) {

View File

@@ -4,6 +4,8 @@ import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile';
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';
const BOOTSTRAP_RELAYS = [
'wss://purplepag.es',
@@ -26,10 +28,40 @@ export default class NostrDataService extends Service {
_blossomSub = null;
_requestSub = null;
_cachePromise = null;
constructor() {
super(...arguments);
// Initialize the IndexedDB cache
this._cachePromise = openDB('applesauce-events').then(async (db) => {
this.cache = new NostrIDB(db, {
cacheIndexes: 1000,
maxEvents: 10000,
});
await this.cache.start();
// Automatically persist new events to the cache
this._stopPersisting = persistEventsToCache(
this.store,
async (events) => {
// Only cache profiles, mailboxes, and blossom servers
const toCache = events.filter(
(e) => e.kind === 0 || e.kind === 10002 || e.kind === 10063
);
if (toCache.length > 0) {
await Promise.all(toCache.map((event) => this.cache.add(event)));
}
},
{
batchTime: 1000, // Batch writes every 1 second
maxBatchSize: 100,
}
);
});
// Feed events from the relay pool into the event store
this.nostrRelay.pool.relays$.subscribe(() => {
// Setup relay subscription tracking if needed, or we just rely on request()
@@ -63,24 +95,8 @@ export default class NostrDataService extends Service {
const relayList = Array.from(relays);
// Request events and dump them into the store
this._requestSub = this.nostrRelay.pool
.request(relayList, [
{
authors: [pubkey],
kinds: [0, 10002, 10063],
},
])
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error('Error fetching profile events:', err);
},
});
// Setup models to track state reactively
// Setup models to track state reactively FIRST
// This way, if cached events populate the store, the UI updates instantly.
this._profileSub = this.store
.model(ProfileModel, pubkey)
.subscribe((profileContent) => {
@@ -104,6 +120,43 @@ export default class NostrDataService extends Service {
this.blossomServers = [];
}
});
// 1. Await cache initialization and populate the EventStore with local data
try {
await this._cachePromise;
const cachedEvents = await this.cache.query([
{
authors: [pubkey],
kinds: [0, 10002, 10063],
},
]);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn('Failed to read from local Nostr IDB cache', e);
}
// 2. Request new events from the network in the background and dump them into the store
this._requestSub = this.nostrRelay.pool
.request(relayList, [
{
authors: [pubkey],
kinds: [0, 10002, 10063],
},
])
.subscribe({
next: (event) => {
this.store.add(event);
},
error: (err) => {
console.error('Error fetching profile events:', err);
},
});
}
get userDisplayName() {
@@ -154,5 +207,13 @@ export default class NostrDataService extends Service {
willDestroy() {
super.willDestroy(...arguments);
this._cleanupSubscriptions();
if (this._stopPersisting) {
this._stopPersisting();
}
if (this.cache) {
this.cache.stop();
}
}
}

View File

@@ -6,6 +6,7 @@ export default class SettingsService extends Service {
@tracked mapKinetic = true;
@tracked photonApi = 'https://photon.komoot.io/api/';
@tracked showQuickSearchButtons = true;
@tracked nostrPhotoFallbackUploads = false;
overpassApis = [
{
@@ -64,6 +65,14 @@ export default class SettingsService extends Service {
if (savedShowQuickSearch !== null) {
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
}
const savedNostrPhotoFallbackUploads = localStorage.getItem(
'marco:nostr-photo-fallback-uploads'
);
if (savedNostrPhotoFallbackUploads !== null) {
this.nostrPhotoFallbackUploads =
savedNostrPhotoFallbackUploads === 'true';
}
}
updateOverpassApi(url) {
@@ -84,4 +93,9 @@ export default class SettingsService extends Service {
updatePhotonApi(url) {
this.photonApi = url;
}
updateNostrPhotoFallbackUploads(enabled) {
this.nostrPhotoFallbackUploads = enabled;
localStorage.setItem('marco:nostr-photo-fallback-uploads', String(enabled));
}
}

View File

@@ -595,7 +595,7 @@ body {
display: block;
font-size: 0.85rem;
color: #666;
margin-bottom: 0.25rem;
margin-bottom: 0.5rem;
}
.form-control {
@@ -967,6 +967,7 @@ abbr[title] {
display: inline-flex;
width: 32px;
height: 32px;
margin: -6px 0;
}
.app-logo-icon svg {

View File

@@ -111,6 +111,7 @@
"blurhash": "^2.0.5",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
"nostr-idb": "^5.0.0",
"oauth2-pkce": "^2.1.3",
"qrcode": "^1.5.4",
"rxjs": "^7.8.2"

18
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
ember-lifeline:
specifier: ^7.0.0
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
nostr-idb:
specifier: ^5.0.0
version: 5.0.0
oauth2-pkce:
specifier: ^2.1.3
version: 2.1.3
@@ -3709,6 +3712,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
idb@8.0.3:
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -4405,6 +4411,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
nostr-idb@5.0.0:
resolution: {integrity: sha512-w5y4AnHefZIwCCL11NryfM2xp3U0Ka4qVNQEYAjnQbPwyoV+bZTdwuPXHCdRDWvhOFP2bZr1WBegcsAmkBjrxQ==}
nostr-signer-capacitor-plugin@0.0.5:
resolution: {integrity: sha512-/EvqWz71HZ5cWmzvfXWTm48AWZtbeZDbOg3vLwXyXPjnIp1DR7Wurww/Mo41ORNu1DNPlqH20l7kIXKO6vR5og==}
peerDependencies:
@@ -10286,6 +10295,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
idb@8.0.3: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -11021,6 +11032,13 @@ snapshots:
normalize-path@3.0.0: {}
nostr-idb@5.0.0:
dependencies:
debug: 4.4.3
idb: 8.0.3
transitivePeerDependencies:
- supports-color
nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.2):
dependencies:
'@capacitor/core': 7.6.2