diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 08:59:03 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 08:59:03 -0700 |
| commit | 39083f3ca2f80c7c51fefffb1e473f3d0a7d4ed6 (patch) | |
| tree | 712f0dd93ef0f10df1439b1ac43dd865802e17e8 /src | |
| parent | 98d90827cbf581c4322232063b4f785f97bcb45b (diff) | |
| download | coinosv3-39083f3ca2f80c7c51fefffb1e473f3d0a7d4ed6.tar.gz coinosv3-39083f3ca2f80c7c51fefffb1e473f3d0a7d4ed6.zip | |
Allow custom Blossom servers for attachments and post media
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 23 | ||||
| -rw-r--r-- | src/features/messages.js | 5 | ||||
| -rw-r--r-- | src/i18n.js | 6 | ||||
| -rw-r--r-- | src/media-servers.js | 27 | ||||
| -rw-r--r-- | src/media-upload.js | 5 |
5 files changed, 62 insertions, 4 deletions
@@ -1,3 +1,4 @@ +import { customMediaServers, saveMediaServers, resetMediaServers } from './media-servers.js'; // Bitcoin Wallet — UI controller (vanilla DOM, no framework). // // State lives in `ui` + the singleton `wallet`. Mutating handlers call render(), @@ -3030,11 +3031,33 @@ function registerLinkHandler(scheme) { toast(t('linkHandlerAsked')); } catch (e) { toast(t('linkHandlerRefused')); } } +function mediaServersCard() { + if (ui.mediaServersDraft == null) ui.mediaServersDraft = (customMediaServers() || []).join('\n'); + const save = (reset = false) => { + try { + if (reset) { resetMediaServers(); ui.mediaServersDraft = ''; } + else ui.mediaServersDraft = saveMediaServers(ui.mediaServersDraft).join('\n'); + toast(t('mediaServersSaved')); + render(); + } catch (e) { toast(t(e.message === 'invalid' ? 'mediaServersInvalid' : 'mediaServersSaveFailed')); } + }; + return h('div', { class: 'card col', 'data-key': 'media-servers' }, + h('h3', {}, t('mediaServersTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('mediaServersHow')), + h('textarea', { rows: 3, style: 'font-size:12px', 'aria-label': t('mediaServersTitle'), + placeholder: t('mediaServersDefault'), value: ui.mediaServersDraft, spellcheck: 'false', + onInput: (e) => { ui.mediaServersDraft = e.target.value; } }), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost small', onClick: () => save() }, t('syncStorageSave')), + h('button', { class: 'btn-ghost small', onClick: () => save(true) }, t('syncStorageReset')))); +} + function nostrSettingsView() { return h( 'div', { class: 'col', style: 'gap:16px' }, ...featureAll('nostrSettingsCards'), + mediaServersCard(), // The browser's own "open these links here" registry: njump's "Your // default web app" is a web+nostr: link, and it opens whichever site // asked for that scheme last. Needs a tap (the browser asks). diff --git a/src/features/messages.js b/src/features/messages.js index 57acc3b..ec564e8 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1,3 +1,4 @@ +import { mediaServers } from '../media-servers.js'; // Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs. // // Communities are end-to-end private: there is no global discovery in @@ -9446,7 +9447,7 @@ export function messagesFeature(ctx) { // key and 16-byte nonce per file, the ciphertext uploaded as an opaque // blob, the key riding inside the (already encrypted) message. The media // host never sees a picture. coinos's own Blossom server stores only sync - // envelopes, so these go to public hosts that take anonymous blobs. + // envelopes, so these go to the configured media hosts or public defaults. const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com']; const MEDIA_MAX = 20 * 1024 * 1024; async function encryptFile(file) { @@ -9473,7 +9474,7 @@ export function messagesFeature(ctx) { const auth = id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : await id.signer.signEvent(evt); const header = 'Nostr ' + btoa(JSON.stringify(auth)); const urls = []; - for (const server of MEDIA_SERVERS) { + for (const server of mediaServers(MEDIA_SERVERS)) { try { const r = await fetch(server + '/upload', { method: 'PUT', body: enc.cipher, signal: AbortSignal.timeout(60_000), diff --git a/src/i18n.js b/src/i18n.js index f8b0cca..586a7e6 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -857,6 +857,12 @@ const STR = { msgBadInvite: 'Not a valid invite link', msgInviteLoading: 'Fetching invite…', msgInviteNotFound: 'Invite not found on its relays', + mediaServersTitle: 'Media servers', + mediaServersHow: 'Blossom servers for chat attachments and post media, including videos. Enter up to 6 HTTPS server addresses, one per line. Custom servers replace the public upload defaults. Saved on this device; existing uploads stay where they are.', + mediaServersDefault: 'Using public media servers', + mediaServersSaved: 'Media servers saved', + mediaServersInvalid: 'Enter 1–6 HTTPS server addresses without paths, credentials, queries or fragments.', + mediaServersSaveFailed: 'Could not save media servers on this device.', syncStorageTitle: 'Sync storage', syncStorageHow: 'Wallet state too large for a relay event is encrypted and stored as a blob on these Blossom servers (one per line). Any Blossom server works. Run your own or use another provider.', syncStorageSave: 'Save servers', diff --git a/src/media-servers.js b/src/media-servers.js new file mode 100644 index 0000000..e2220d7 --- /dev/null +++ b/src/media-servers.js @@ -0,0 +1,27 @@ +// Upload destinations are a device preference, separate from wallet sync storage. +const KEY = 'btc-wallet-media-servers'; +export function parseMediaServers(text) { + const entries = String(text).trim().split(/\s+/).filter(Boolean); + if (!entries.length) throw new Error('invalid'); + const servers = [...new Set(entries.map((entry) => { + let url; + try { url = new URL(entry); } catch { throw new Error('invalid'); } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !/^\/*$/.test(url.pathname)) throw new Error('invalid'); + return url.origin; + }))]; + if (servers.length > 6) throw new Error('invalid'); + return servers; +} +export function customMediaServers() { + try { + const saved = JSON.parse(localStorage.getItem(KEY)); + return Array.isArray(saved) ? parseMediaServers(saved.join('\n')) : null; + } catch { return null; } +} +export const mediaServers = (defaults) => customMediaServers() || defaults; +export function saveMediaServers(text) { + const servers = parseMediaServers(text); + localStorage.setItem(KEY, JSON.stringify(servers)); + return servers; +} +export function resetMediaServers() { localStorage.removeItem(KEY); } diff --git a/src/media-upload.js b/src/media-upload.js index e45d2fe..d7a99e1 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -1,3 +1,4 @@ +import { mediaServers } from './media-servers.js'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; @@ -65,7 +66,7 @@ function putWithProgress(url, { body, headers, signal }, onProgress) { // BUD-02 upload. `signEvent` is deliberately injected: the app can use its // always-available wallet identity, while tests need no secret key. export async function uploadPublicMedia(file, signEvent, { - servers = PUBLIC_MEDIA_SERVERS, + servers = mediaServers(PUBLIC_MEDIA_SERVERS), fetcher = globalThis.fetch, timeoutMs = 5 * 60_000, onProgress = null, // (fraction 0..1) as the bytes go out @@ -132,7 +133,7 @@ export function rewritePlaylist(text, urls) { return line.replace(/URI="([^"]+)"/g, (m, p) => `URI="${to(p)}"`); }).join('\n'); } -export async function uploadStream({ files, master }, signEvent, { servers = STREAM_SERVERS, fetcher, onProgress = null } = {}) { +export async function uploadStream({ files, master }, signEvent, { servers = mediaServers(STREAM_SERVERS), fetcher, onProgress = null } = {}) { const media = [...files.keys()].filter((p) => !/\.m3u8$/.test(p)); const lists = [...files.keys()].filter((p) => /\.m3u8$/.test(p) && p !== master); const total = [...files.values()].reduce((n, f) => n + f.size, 0) || 1; |
