Add and use relay list settings
This commit is contained in:
@@ -1,11 +1,94 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
|
import { normalizeRelayUrl } from '../../../utils/nostr';
|
||||||
|
|
||||||
|
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||||
|
|
||||||
export default class AppMenuSettingsNostr extends Component {
|
export default class AppMenuSettingsNostr extends Component {
|
||||||
@service settings;
|
@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>
|
||||||
{{! template-lint-disable no-nested-interactive }}
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
@@ -40,6 +123,96 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { or, not } from 'ember-truth-helpers';
|
|||||||
export default class PlacePhotoUpload extends Component {
|
export default class PlacePhotoUpload extends Component {
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
@service nostrRelay;
|
@service nostrRelay;
|
||||||
|
@service nostrData;
|
||||||
@service blossom;
|
@service blossom;
|
||||||
@service toast;
|
@service toast;
|
||||||
|
|
||||||
@@ -181,7 +182,7 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const event = await factory.sign(template);
|
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.toast.show('Photos published successfully');
|
||||||
this.status = '';
|
this.status = '';
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
|||||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||||
import { NostrIDB, openDB } from 'nostr-idb';
|
import { NostrIDB, openDB } from 'nostr-idb';
|
||||||
|
import { normalizeRelayUrl } from '../utils/nostr';
|
||||||
|
|
||||||
const BOOTSTRAP_RELAYS = [
|
const DIRECTORY_RELAYS = [
|
||||||
'wss://purplepag.es',
|
'wss://purplepag.es',
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
||||||
|
const DEFAULT_WRITE_RELAYS = [];
|
||||||
|
|
||||||
export default class NostrDataService extends Service {
|
export default class NostrDataService extends Service {
|
||||||
@service nostrRelay;
|
@service nostrRelay;
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
|
@service settings;
|
||||||
|
|
||||||
store = new EventStore();
|
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) {
|
async loadProfile(pubkey) {
|
||||||
if (!pubkey) return;
|
if (!pubkey) return;
|
||||||
|
|
||||||
@@ -79,22 +123,6 @@ export default class NostrDataService extends Service {
|
|||||||
|
|
||||||
this._cleanupSubscriptions();
|
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
|
// Setup models to track state reactively FIRST
|
||||||
// This way, if cached events populate the store, the UI updates instantly.
|
// This way, if cached events populate the store, the UI updates instantly.
|
||||||
this._profileSub = this.store
|
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
|
// 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
|
this._requestSub = this.nostrRelay.pool
|
||||||
.request(relayList, [
|
.request(profileRelays, [
|
||||||
{
|
{
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
kinds: [0, 10002, 10063],
|
kinds: [0, 10002, 10063],
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { RelayPool } from 'applesauce-relay';
|
|||||||
export default class NostrRelayService extends Service {
|
export default class NostrRelayService extends Service {
|
||||||
pool = new RelayPool();
|
pool = new RelayPool();
|
||||||
|
|
||||||
// For Phase 1, we hardcode the local relay
|
async publish(relays, event) {
|
||||||
relays = ['ws://127.0.0.1:7777'];
|
if (!relays || relays.length === 0) {
|
||||||
|
throw new Error('No relays provided to publish the event.');
|
||||||
async publish(event) {
|
}
|
||||||
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
||||||
// and automatically handles reconnecting and retrying.
|
// 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
|
// Check if at least one relay accepted the event
|
||||||
const success = responses.some((res) => res.ok);
|
const success = responses.some((res) => res.ok);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const DEFAULT_SETTINGS = {
|
|||||||
photonApi: 'https://photon.komoot.io/api/',
|
photonApi: 'https://photon.komoot.io/api/',
|
||||||
showQuickSearchButtons: true,
|
showQuickSearchButtons: true,
|
||||||
nostrPhotoFallbackUploads: false,
|
nostrPhotoFallbackUploads: false,
|
||||||
|
nostrReadRelays: null,
|
||||||
|
nostrWriteRelays: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class SettingsService extends Service {
|
export default class SettingsService extends Service {
|
||||||
@@ -16,6 +18,8 @@ export default class SettingsService extends Service {
|
|||||||
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
|
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
|
||||||
@tracked nostrPhotoFallbackUploads =
|
@tracked nostrPhotoFallbackUploads =
|
||||||
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
||||||
|
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
||||||
|
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{
|
{
|
||||||
@@ -102,6 +106,8 @@ export default class SettingsService extends Service {
|
|||||||
this.photonApi = finalSettings.photonApi;
|
this.photonApi = finalSettings.photonApi;
|
||||||
this.showQuickSearchButtons = finalSettings.showQuickSearchButtons;
|
this.showQuickSearchButtons = finalSettings.showQuickSearchButtons;
|
||||||
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
||||||
|
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
||||||
|
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||||
|
|
||||||
// Save to ensure migrated settings are stored in the new format
|
// Save to ensure migrated settings are stored in the new format
|
||||||
this.saveSettings();
|
this.saveSettings();
|
||||||
@@ -114,6 +120,8 @@ export default class SettingsService extends Service {
|
|||||||
photonApi: this.photonApi,
|
photonApi: this.photonApi,
|
||||||
showQuickSearchButtons: this.showQuickSearchButtons,
|
showQuickSearchButtons: this.showQuickSearchButtons,
|
||||||
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
||||||
|
nostrReadRelays: this.nostrReadRelays,
|
||||||
|
nostrWriteRelays: this.nostrWriteRelays,
|
||||||
};
|
};
|
||||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
--link-color-visited: #6a4fbf;
|
--link-color-visited: #6a4fbf;
|
||||||
--marker-color-primary: #ea4335;
|
--marker-color-primary: #ea4335;
|
||||||
--marker-color-dark: #b31412;
|
--marker-color-dark: #b31412;
|
||||||
|
--danger-color: var(--marker-color-primary);
|
||||||
|
--danger-color-dark: var(--marker-color-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -301,7 +303,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.photo-upload-item .btn-remove-photo:hover {
|
.photo-upload-item .btn-remove-photo:hover {
|
||||||
background: var(--marker-color-primary);
|
background: var(--danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin-animation {
|
.spin-animation {
|
||||||
@@ -565,6 +567,64 @@ body {
|
|||||||
padding: 0 1.4rem 1rem;
|
padding: 0 1.4rem 1rem;
|
||||||
animation: details-slide-down 0.2s ease-out;
|
animation: details-slide-down 0.2s ease-out;
|
||||||
font-size: 0.9rem;
|
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 {
|
@keyframes details-slide-down {
|
||||||
@@ -639,6 +699,11 @@ select.form-control {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-section .form-group {
|
.settings-section .form-group {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section .form-group:first-of-type {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1598,3 +1663,17 @@ button.create-place {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: 0.25rem;
|
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