Add and use relay list settings
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
@@ -40,6 +123,96 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new-read-relay">Read Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.nostrData.activeReadRelays 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>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="add-relay-input">
|
||||
<input
|
||||
id="new-read-relay"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="relay.example.com"
|
||||
value={{this.newReadRelay}}
|
||||
{{on "input" this.updateNewReadRelay}}
|
||||
{{on "keydown" this.handleReadRelayKeydown}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
{{on "click" this.addReadRelay}}
|
||||
>Add</button>
|
||||
</div>
|
||||
{{#if this.settings.nostrReadRelays}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetReadRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</label>
|
||||
<ul class="relay-list">
|
||||
{{#each this.nostrData.activeWriteRelays 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>
|
||||
</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.settings.nostrWriteRelays}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link reset-relays"
|
||||
{{on "click" this.resetWriteRelays}}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<PublishResponse[]>
|
||||
// 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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
15
app/utils/nostr.js
Normal file
15
app/utils/nostr.js
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user