diff --git a/app/components/app-menu/settings/nostr.gjs b/app/components/app-menu/settings/nostr.gjs
index 29c7dc0..7612f9e 100644
--- a/app/components/app-menu/settings/nostr.gjs
+++ b/app/components/app-menu/settings/nostr.gjs
@@ -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 {
- {{#if this.settings.nostrReadRelays}}
+ {{#if this.hasReadOverrides}}
- {{#if this.settings.nostrWriteRelays}}
+ {{#if this.hasWriteOverrides}}
{
+ 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.
diff --git a/tests/helpers/mock-nostr.js b/tests/helpers/mock-nostr.js
index 00d9a5a..40ed0f0 100644
--- a/tests/helpers/mock-nostr.js
+++ b/tests/helpers/mock-nostr.js
@@ -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 [];
}
diff --git a/tests/integration/components/app-menu/settings/nostr-test.gjs b/tests/integration/components/app-menu/settings/nostr-test.gjs
new file mode 100644
index 0000000..5fcac34
--- /dev/null
+++ b/tests/integration/components/app-menu/settings/nostr-test.gjs
@@ -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(
+
+ );
+ 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',
+ ]);
+ });
+});
diff --git a/tests/unit/utils/nostr-test.js b/tests/unit/utils/nostr-test.js
index 19e51b7..e14349a 100644
--- a/tests/unit/utils/nostr-test.js
+++ b/tests/unit/utils/nostr-test.js
@@ -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',
+ ]);
+ });
});