diff --git a/app/components/app-menu/settings/nostr.gjs b/app/components/app-menu/settings/nostr.gjs index 970a29e..d332111 100644 --- a/app/components/app-menu/settings/nostr.gjs +++ b/app/components/app-menu/settings/nostr.gjs @@ -1,11 +1,94 @@ import Component from '@glimmer/component'; import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +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'; + +const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : ''); export default class AppMenuSettingsNostr extends Component { @service settings; + @service nostrData; + + @tracked newReadRelay = ''; + @tracked newWriteRelay = ''; + + @action + updateNewReadRelay(event) { + this.newReadRelay = event.target.value; + } + + @action + updateNewWriteRelay(event) { + this.newWriteRelay = event.target.value; + } + + @action + addReadRelay() { + 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)); + 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); + } + + @action + handleReadRelayKeydown(event) { + if (event.key === 'Enter') { + this.addReadRelay(); + } + } + + @action + handleWriteRelayKeydown(event) { + if (event.key === 'Enter') { + this.addWriteRelay(); + } + } + + @action + resetReadRelays() { + this.settings.update('nostrReadRelays', null); + } + + @action + addWriteRelay() { + 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)); + 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); + } + + @action + resetWriteRelays() { + this.settings.update('nostrWriteRelays', null); + } diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs index 46410cc..7d8b52b 100644 --- a/app/components/place-photo-upload.gjs +++ b/app/components/place-photo-upload.gjs @@ -13,6 +13,7 @@ import { or, not } from 'ember-truth-helpers'; export default class PlacePhotoUpload extends Component { @service nostrAuth; @service nostrRelay; + @service nostrData; @service blossom; @service toast; @@ -181,7 +182,7 @@ export default class PlacePhotoUpload extends Component { } const event = await factory.sign(template); - await this.nostrRelay.publish(event); + await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event); this.toast.show('Photos published successfully'); this.status = ''; diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index 455af6e..5a1d257 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -6,16 +6,21 @@ 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'; -const BOOTSTRAP_RELAYS = [ +const DIRECTORY_RELAYS = [ 'wss://purplepag.es', 'wss://relay.damus.io', 'wss://nos.lol', ]; +const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org']; +const DEFAULT_WRITE_RELAYS = []; + export default class NostrDataService extends Service { @service nostrRelay; @service nostrAuth; + @service settings; store = new EventStore(); @@ -69,6 +74,45 @@ 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 defaultWriteRelays() { + const mailboxes = (this.mailboxes?.outboxes || []) + .map(normalizeRelayUrl) + .filter(Boolean); + const defaults = + DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean); + return Array.from(new Set([...defaults, ...mailboxes])); + } + + get activeReadRelays() { + if (this.settings.nostrReadRelays) { + return Array.from( + new Set( + this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean) + ) + ); + } + return this.defaultReadRelays; + } + + get activeWriteRelays() { + if (this.settings.nostrWriteRelays) { + return Array.from( + new Set( + this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean) + ) + ); + } + return this.defaultWriteRelays; + } + async loadProfile(pubkey) { if (!pubkey) return; @@ -79,22 +123,6 @@ export default class NostrDataService extends Service { this._cleanupSubscriptions(); - const relays = new Set(BOOTSTRAP_RELAYS); - - // Try to get extension relays - if (typeof window.nostr !== 'undefined' && window.nostr.getRelays) { - try { - const extRelays = await window.nostr.getRelays(); - for (const url of Object.keys(extRelays)) { - relays.add(url); - } - } catch { - console.warn('Failed to get NIP-07 relays'); - } - } - - const relayList = Array.from(relays); - // Setup models to track state reactively FIRST // This way, if cached events populate the store, the UI updates instantly. this._profileSub = this.store @@ -142,8 +170,11 @@ export default class NostrDataService extends Service { } // 2. Request new events from the network in the background and dump them into the store + const profileRelays = Array.from( + new Set([...DIRECTORY_RELAYS, ...this.activeWriteRelays]) + ); this._requestSub = this.nostrRelay.pool - .request(relayList, [ + .request(profileRelays, [ { authors: [pubkey], kinds: [0, 10002, 10063], diff --git a/app/services/nostr-relay.js b/app/services/nostr-relay.js index 98e7e9f..320a38b 100644 --- a/app/services/nostr-relay.js +++ b/app/services/nostr-relay.js @@ -4,13 +4,13 @@ import { RelayPool } from 'applesauce-relay'; export default class NostrRelayService extends Service { pool = new RelayPool(); - // For Phase 1, we hardcode the local relay - relays = ['ws://127.0.0.1:7777']; - - async publish(event) { + async publish(relays, event) { + if (!relays || relays.length === 0) { + throw new Error('No relays provided to publish the event.'); + } // The publish method is a wrapper around the event method that returns a Promise // and automatically handles reconnecting and retrying. - const responses = await this.pool.publish(this.relays, event); + const responses = await this.pool.publish(relays, event); // Check if at least one relay accepted the event const success = responses.some((res) => res.ok); diff --git a/app/services/settings.js b/app/services/settings.js index 6d3efc8..669a4ed 100644 --- a/app/services/settings.js +++ b/app/services/settings.js @@ -7,6 +7,8 @@ const DEFAULT_SETTINGS = { photonApi: 'https://photon.komoot.io/api/', showQuickSearchButtons: true, nostrPhotoFallbackUploads: false, + nostrReadRelays: null, + nostrWriteRelays: null, }; export default class SettingsService extends Service { @@ -16,6 +18,8 @@ export default class SettingsService extends Service { @tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons; @tracked nostrPhotoFallbackUploads = DEFAULT_SETTINGS.nostrPhotoFallbackUploads; + @tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays; + @tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays; overpassApis = [ { @@ -102,6 +106,8 @@ export default class SettingsService extends Service { this.photonApi = finalSettings.photonApi; this.showQuickSearchButtons = finalSettings.showQuickSearchButtons; this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads; + this.nostrReadRelays = finalSettings.nostrReadRelays; + this.nostrWriteRelays = finalSettings.nostrWriteRelays; // Save to ensure migrated settings are stored in the new format this.saveSettings(); @@ -114,6 +120,8 @@ export default class SettingsService extends Service { photonApi: this.photonApi, showQuickSearchButtons: this.showQuickSearchButtons, nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads, + nostrReadRelays: this.nostrReadRelays, + nostrWriteRelays: this.nostrWriteRelays, }; localStorage.setItem('marco:settings', JSON.stringify(settings)); } diff --git a/app/styles/app.css b/app/styles/app.css index eb668b1..b39146f 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -8,6 +8,8 @@ --link-color-visited: #6a4fbf; --marker-color-primary: #ea4335; --marker-color-dark: #b31412; + --danger-color: var(--marker-color-primary); + --danger-color-dark: var(--marker-color-dark); } html, @@ -301,7 +303,7 @@ body { } .photo-upload-item .btn-remove-photo:hover { - background: var(--marker-color-primary); + background: var(--danger-color); } .spin-animation { @@ -565,6 +567,64 @@ body { padding: 0 1.4rem 1rem; animation: details-slide-down 0.2s ease-out; font-size: 0.9rem; + display: flex; + flex-direction: column; + gap: 16px; +} + +.relay-list { + list-style: none; + padding: 0; + margin: 0 0 0.75rem; + display: flex; + flex-direction: column; + gap: 4px; +} + +.relay-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.25rem 0; + border-radius: 4px; + font-size: 0.9rem; + word-break: break-all; +} + +.btn-remove-relay { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #fff; + border: 1px solid var(--danger-color); + color: var(--danger-color); + cursor: pointer; + padding: 0; + transition: all 0.1s ease; + flex-shrink: 0; +} + +.btn-remove-relay svg { + stroke: currentcolor; +} + +.btn-remove-relay:hover, +.btn-remove-relay:active { + background-color: var(--danger-color); + color: #fff; +} + +.add-relay-input { + display: flex; + gap: 0.5rem; +} + +.btn-link.reset-relays { + margin-top: 0.75rem; + font-size: 0.85rem; } @keyframes details-slide-down { @@ -639,6 +699,11 @@ select.form-control { } .settings-section .form-group { + margin-top: 0.5rem; + margin-bottom: 0; +} + +.settings-section .form-group:first-of-type { margin-top: 1rem; } @@ -1598,3 +1663,17 @@ button.create-place { max-width: 100%; border-radius: 0.25rem; } + +.btn-link { + background: none; + border: none; + padding: 0; + color: var(--link-color); + text-decoration: none; + cursor: pointer; + font: inherit; +} + +.btn-link:hover { + text-decoration: underline; +} diff --git a/app/utils/nostr.js b/app/utils/nostr.js new file mode 100644 index 0000000..d68c973 --- /dev/null +++ b/app/utils/nostr.js @@ -0,0 +1,15 @@ +export function normalizeRelayUrl(url) { + if (!url) return ''; + let normalized = url.trim().toLowerCase(); + if (!normalized) return ''; + + if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) { + normalized = 'wss://' + normalized; + } + + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + + return normalized; +}