aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-29 08:33:28 -0700
committerAdam Soltys <[email protected]>2026-09-29 08:33:28 -0700
commitbdb8c3878df0439e3ebbcede5e2555f949a0cd7a (patch)
treef3f4632643dfcbc5cd6d4cf4906b1df565449e9b /src
parentb9c04f573567d69d94fb120aa8feed89cf75585c (diff)
downloadcoinosv3-bdb8c3878df0439e3ebbcede5e2555f949a0cd7a.tar.gz
coinosv3-bdb8c3878df0439e3ebbcede5e2555f949a0cd7a.zip
Fix public media uploads and feed tab navigation
Diffstat (limited to 'src')
-rw-r--r--src/app.js34
-rw-r--r--src/features/messages.js14
-rw-r--r--src/i18n.js8
-rw-r--r--src/locales/ar.json4
-rw-r--r--src/locales/bn.json4
-rw-r--r--src/locales/de.json4
-rw-r--r--src/locales/es.json4
-rw-r--r--src/locales/fr.json4
-rw-r--r--src/locales/hi.json4
-rw-r--r--src/locales/ja.json4
-rw-r--r--src/locales/ko.json4
-rw-r--r--src/locales/pt.json4
-rw-r--r--src/locales/ru.json4
-rw-r--r--src/locales/tr.json4
-rw-r--r--src/locales/zh.json4
-rw-r--r--src/media-upload.js86
16 files changed, 145 insertions, 45 deletions
diff --git a/src/app.js b/src/app.js
index 4003e1b..203b50a 100644
--- a/src/app.js
+++ b/src/app.js
@@ -9,6 +9,7 @@ import { qrSvg } from './qr.js';
import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
import { npubOf, seedPubkey, neventOf } from './nostr.js';
import { nip98Header } from './nip98.js';
+import { uploadPublicMedia, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
@@ -3452,21 +3453,27 @@ async function onbUpload(file) {
const fd = new FormData();
fd.append('file', file);
const endpoint = 'https://nostr.build/api/v2/upload/files';
- // nostr.build no longer takes anonymous uploads — sign with the wallet's
- // nostr key, the one key that's always available without a signer prompt.
- const headers = {};
- try {
- if (wallet.nostrPubkey && wallet.nostrPubkey()) {
- headers.authorization = await nip98Header({ signEvent: (e) => wallet.nostrSign(e) }, endpoint, 'POST');
- }
- } catch {}
- const r = await fetch(endpoint, { method: 'POST', body: fd, headers });
- const j = await r.json();
- const url = j?.data?.[0]?.url;
+ const authorization = await nip98Header({ signEvent: (event) => wallet.nostrSign(event) }, endpoint, 'POST');
+ const response = await fetch(endpoint, {
+ method: 'POST', body: fd, headers: { authorization }, signal: AbortSignal.timeout(60_000),
+ });
+ let body = null;
+ try { body = await response.json(); } catch {}
+ const url = response.ok && body?.data?.[0]?.url;
if (!url) throw new Error(t('onbUploadFailed'));
return url;
}
+async function publicMediaUpload(file) {
+ try {
+ return await uploadPublicMedia(file, (event) => wallet.nostrSign(event));
+ } catch (error) {
+ if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig'));
+ if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported'));
+ throw new Error(t('msgUploadFailed'));
+ }
+}
+
// Whether the wizard can offer Spending at all: an Ark-capable build on a
// network with the names registrar, for a full (signing) account. Elsewhere
// the offer would be a spinner that never resolves.
@@ -5720,9 +5727,10 @@ const ctx = {
setAccount: (a, dir) => setAccountSel(a, dir),
// Open (or create) a wallet from a mnemonic — used by nostr login.
openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts),
- // Upload an image (avatar) and get back its URL — the same signed
- // nostr.build path the onboarding picker uses.
+ // Profile pictures retain the metadata-scrubbing image uploader. Public
+ // post media uses resilient Blossom storage and media-specific failures.
uploadImage: (file) => onbUpload(file),
+ uploadMedia: (file) => publicMediaUpload(file),
// A nostr login that lands mid-wizard: a restored wallet has been through
// onboarding elsewhere, so the wizard ends without tour or prompts; a fresh
// one falls through to the wallet + tour on the next render.
diff --git a/src/features/messages.js b/src/features/messages.js
index 7505dbf..fb88a56 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -6995,7 +6995,7 @@ export function messagesFeature(ctx) {
// A reply can carry a picture too — same upload, same imeta tag, same
// paperclip. The URL lands in the draft, which is what every client
// reads as the media.
- ctx.uploadImage ? h('button', {
+ (ctx.uploadMedia || ctx.uploadImage) ? h('button', {
class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('reply-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
@@ -7588,10 +7588,11 @@ export function messagesFeature(ctx) {
const composeText = () => (ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose || '');
// Upload, then hand the URL to whichever draft asked for it.
async function attachTo(file, place) {
- if (!file || !ctx.uploadImage) return;
+ const upload = ctx.uploadMedia || ctx.uploadImage;
+ if (!file || !upload) return;
ui.postUploading = true; render();
try {
- const url = await ctx.uploadImage(file);
+ const url = await upload(file);
(ui.postMedia ||= []).push({ url, m: file.type || '' });
place(url);
} catch (e) { toast(e.message || String(e)); }
@@ -7667,7 +7668,7 @@ export function messagesFeature(ctx) {
render();
}
} }, t('profPostBtn')),
- ctx.uploadImage ? h('button', {
+ (ctx.uploadMedia || ctx.uploadImage) ? h('button', {
class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('post-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
@@ -7804,7 +7805,10 @@ export function messagesFeature(ctx) {
class: 'app-bottom-nav', 'data-key': 'app-bottom-nav',
},
button('feed', t('feedTitle'), '<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2m0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8M15 18h-5M10 6h8v4h-8z"/>', () => {
- if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit) { jumpToNew(); return; }
+ // A destination tab is a destination: tapping Feed while already
+ // there always returns to its top. Only the explicit "x new posts"
+ // pill jumps to the first unread arrival.
+ if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit) { glideToTop(); return; }
leavePage();
if (visitor) { ui.pubProf = true; openFirehose(); } else showFeed(curFeedId);
window.scrollTo({ top: 0 });
diff --git a/src/i18n.js b/src/i18n.js
index afc76ef..475f919 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -198,7 +198,9 @@ const STR = {
msgAttachFailed: 'Attachment unavailable',
msgAttach: 'Attach a picture',
msgAttachTooBig: 'That file is too big (20 MB max)',
- msgUploadFailed: 'Upload failed. Try again',
+ msgUploadFailed: 'Upload failed. Check your connection and try again',
+ mediaUploadTooBig: 'That file is too big for the available media hosts',
+ mediaUploadUnsupported: 'That file format is not supported',
msgPhoto: 'Photo',
searchPeopleFor: 'Search people for {q}',
msgEmojiSearch: 'Search emoji…',
@@ -1006,9 +1008,9 @@ const STR = {
onbNameWait: 'Getting things ready…',
onbNotNow: 'Not now',
onbAvatarTitle: 'Choose your look',
- onbAvatarBody: 'Pick a punk or upload your own picture. You can change it anytime.',
+ onbAvatarBody: 'Choose an avatar or upload your own picture. You can change it anytime.',
onbUpload: 'Upload a photo',
- onbUploadFailed: 'Upload failed. Try a punk for now',
+ onbUploadFailed: 'Upload failed. Try another picture',
onbProfileSkipped: 'Couldn’t publish your profile. Set it later from Edit profile',
onbContinue: 'Continue',
onbSkipAvatar: 'Continue with my default',
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 6763c56..2e914c3 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -616,9 +616,9 @@
"onbNameWait": "جهز الأشياء",
"onbNotNow": "ليس الآن",
"onbAvatarTitle": "اختر مظهرك",
- "onbAvatarBody": "إلتقط قطعة أو ارفع صورتك يمكنك تغييره في أي وقت",
+ "onbAvatarBody": "اختر صورة رمزية أو ارفع صورتك. يمكنك تغييرها في أي وقت",
"onbUpload": "تحميل الصورة",
- "onbUploadFailed": "لقد فشل التحميل - حاول أن تفشل الآن",
+ "onbUploadFailed": "فشل الرفع. جرّب صورة أخرى",
"onbProfileSkipped": "لم يتمكن من نشر ملفك - وضعه في وقت لاحق من ملف إديت",
"onbContinue": "استمر",
"onbSkipAvatar": "استمر في إخفاقي",
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 8c52809..44b2700 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -616,9 +616,9 @@
"onbNameWait": "জিনিস প্রস্তুত করা হচ্ছে...",
"onbNotNow": "এই মুহূর্তে নয়",
"onbAvatarTitle": "আপনার চেহারা পছন্দ করুন",
- "onbAvatarBody": "তোমার নিজের ছবি আপলোড করে নাও। তুমি যেকোন সময় এটাকে পরিবর্তন করতে পারো।",
+ "onbAvatarBody": "একটি অবতার বেছে নাও অথবা নিজের ছবি আপলোড করো। যেকোনো সময় এটি বদলাতে পারো।",
"onbUpload": "একটি ছবি আপলোড করুন",
- "onbUploadFailed": "আপলোড করতে ব্যর্থ - এখন একটা বাজে প্রচেষ্টা করুন",
+ "onbUploadFailed": "আপলোড ব্যর্থ হয়েছে। অন্য একটি ছবি চেষ্টা করো",
"onbProfileSkipped": "প্রোফাইল সম্পাদনা করতে ব্যর্থ - পরে নির্ধারণ করা সম্ভব নয়",
"onbContinue": "এগিয়ে চলুন",
"onbSkipAvatar": "ডিফল্ট হিসেবে নির্দিষ্ট করো",
diff --git a/src/locales/de.json b/src/locales/de.json
index 6409d81..bf827b4 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -616,9 +616,9 @@
"onbNameWait": "Bereitmachen...",
"onbNotNow": "Nicht jetzt",
"onbAvatarTitle": "Wählen Sie Ihren Look",
- "onbAvatarBody": "Wählen Sie einen Punk oder laden Sie Ihr eigenes Bild hoch. Sie können es jederzeit ändern.",
+ "onbAvatarBody": "Wählen Sie einen Avatar oder laden Sie Ihr eigenes Bild hoch. Sie können ihn jederzeit ändern.",
"onbUpload": "Foto hochladen",
- "onbUploadFailed": "Upload fehlgeschlagen Versuchen Sie einen Punk für jetzt",
+ "onbUploadFailed": "Upload fehlgeschlagen. Versuchen Sie ein anderes Bild",
"onbProfileSkipped": "Konnte dein Profil nicht publizieren",
"onbContinue": "Weiter",
"onbSkipAvatar": "Weiter mit meinem Standard",
diff --git a/src/locales/es.json b/src/locales/es.json
index 6cf00b6..52e417b 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -616,9 +616,9 @@
"onbNameWait": "Preparando las cosas...",
"onbNotNow": "Ahora no.",
"onbAvatarTitle": "Elige tu look",
- "onbAvatarBody": "Escoge un punk o sube tu propia imagen. Puedes cambiarlo en cualquier momento.",
+ "onbAvatarBody": "Escoge un avatar o sube tu propia imagen. Puedes cambiarlo en cualquier momento.",
"onbUpload": "Sube una foto",
- "onbUploadFailed": "Subir falló, probar un punk por ahora",
+ "onbUploadFailed": "La subida falló. Prueba con otra imagen",
"onbProfileSkipped": "No se pudo publicar su perfil - establecerlo más tarde del perfil Editar",
"onbContinue": "Continuar",
"onbSkipAvatar": "Continuar con mi defecto",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 9dd56c0..471eb5b 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -616,9 +616,9 @@
"onbNameWait": "Préparer les choses...",
"onbNotNow": "Pas maintenant.",
"onbAvatarTitle": "Choisissez votre look",
- "onbAvatarBody": "Choisissez un punk ou téléchargez votre propre photo. Tu peux le changer à tout moment.",
+ "onbAvatarBody": "Choisissez un avatar ou téléchargez votre propre photo. Vous pouvez le changer à tout moment.",
"onbUpload": "Télécharger une photo",
- "onbUploadFailed": "Télécharger échoué, essayez un punk pour l'instant",
+ "onbUploadFailed": "Le téléversement a échoué. Essayez une autre image",
"onbProfileSkipped": "Impossible de publier votre profil, définissez-le plus tard à partir du profil Édition",
"onbContinue": "Continuer",
"onbSkipAvatar": "Continuer avec mon défaut",
diff --git a/src/locales/hi.json b/src/locales/hi.json
index be3e6a3..4fcd47d 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -616,9 +616,9 @@
"onbNameWait": "चीजों को तैयार करना",
"onbNotNow": "अब नहीं",
"onbAvatarTitle": "अपना लुक चुनें",
- "onbAvatarBody": "एक पंक चुनें या अपनी खुद की तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।",
+ "onbAvatarBody": "एक अवतार चुनें या अपनी तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।",
"onbUpload": "फ़ोटो अपलोड करें",
- "onbUploadFailed": "असफल रहा - अब के लिए एक पंक का प्रयास करें",
+ "onbUploadFailed": "अपलोड विफल रहा। कोई दूसरी तस्वीर आज़माएँ",
"onbProfileSkipped": "अपने प्रोफ़ाइल को प्रकाशित नहीं कर सका - इसे बाद में संपादित प्रोफ़ाइल से सेट करें",
"onbContinue": "जारी",
"onbSkipAvatar": "जारी रखें मेरी डिफ़ॉल्ट के साथ",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index d9cd967..2ab8916 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -616,9 +616,9 @@
"onbNameWait": "物事を準備する...",
"onbNotNow": "今は今",
"onbAvatarTitle": "お問い合わせ",
- "onbAvatarBody": "パンクを選ぶか、自分の写真をアップロードしてください。 いつでも変更できます。",
+ "onbAvatarBody": "アバターを選ぶか、自分の写真をアップロードしてください。いつでも変更できます。",
"onbUpload": "写真をアップロードする",
- "onbUploadFailed": "アップロード失敗, 今すぐパンクを試してみる",
+ "onbUploadFailed": "アップロードに失敗しました。別の画像をお試しください",
"onbProfileSkipped": "プロフィールを公開できません。編集プロファイルから後で設定できます。",
"onbContinue": "お問い合わせ",
"onbSkipAvatar": "デフォルトで続ける",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 6413666..e125415 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -615,9 +615,9 @@
"onbNameWait": "준비하는 것들 ...",
"onbNotNow": "현재 위치",
"onbAvatarTitle": "자주 묻는 질문",
- "onbAvatarBody": "펑크를 선택하거나 자신의 사진을 업로드하십시오. 언제든지 변경할 수 있습니다.",
+ "onbAvatarBody": "아바타를 선택하거나 사진을 업로드하세요. 언제든지 변경할 수 있습니다.",
"onbUpload": "사진 업로드",
- "onbUploadFailed": "업로드 실패, 지금 punk 시도",
+ "onbUploadFailed": "업로드하지 못했습니다. 다른 이미지를 사용해 보세요",
"onbProfileSkipped": "프로필을 게시 할 수 없습니다 - 나중에 편집 프로필에서 설정",
"onbContinue": "계속하기",
"onbSkipAvatar": "기본 설정",
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 8c36922..9f5b2ad 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -616,9 +616,9 @@
"onbNameWait": "Preparando as coisas...",
"onbNotNow": "Agora não.",
"onbAvatarTitle": "Escolha o seu look",
- "onbAvatarBody": "Escolha um punk ou carregue a sua própria foto. Podes mudá-lo quando quiseres.",
+ "onbAvatarBody": "Escolha um avatar ou carregue a sua própria foto. Podes mudá-lo quando quiseres.",
"onbUpload": "Enviar uma foto",
- "onbUploadFailed": "O envio falhou, tente um punk por enquanto",
+ "onbUploadFailed": "O envio falhou. Tente outra imagem",
"onbProfileSkipped": "Não foi possível publicar o seu perfil, defini-lo mais tarde a partir de Editar perfil",
"onbContinue": "Continuar",
"onbSkipAvatar": "Continuar com o meu padrão",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 7ea2c0f..4e472fc 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -616,9 +616,9 @@
"onbNameWait": "Готовить вещи...",
"onbNotNow": "Не сейчас",
"onbAvatarTitle": "Выберите свой взгляд",
- "onbAvatarBody": "Выберите панк или загрузите свою фотографию. Вы можете изменить его в любое время.",
+ "onbAvatarBody": "Выберите аватар или загрузите свою фотографию. Его можно изменить в любое время.",
"onbUpload": "Загрузить фото",
- "onbUploadFailed": "Загрузить не удалось, попробуйте панк на данный момент",
+ "onbUploadFailed": "Не удалось загрузить. Попробуйте другое изображение",
"onbProfileSkipped": "Не смог опубликовать свой профиль, установите его позже из профиля Edit",
"onbContinue": "Продолжать",
"onbSkipAvatar": "Продолжить мой дефолт",
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 1f261b7..e1e9928 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -615,9 +615,9 @@
"onbNameWait": "İşler hazır olsun...",
"onbNotNow": "Şimdi Değil",
"onbAvatarTitle": "görünümünü seçin",
- "onbAvatarBody": "Bir punk seçin veya kendi resmini yükleyin. Her zaman değiştirebilirsiniz.",
+ "onbAvatarBody": "Bir avatar seçin veya kendi resminizi yükleyin. İstediğiniz zaman değiştirebilirsiniz.",
"onbUpload": "Bir fotoğraf",
- "onbUploadFailed": "Yükleme başarısız oldu - şimdilik bir punk deneyin",
+ "onbUploadFailed": "Yükleme başarısız oldu. Başka bir resim deneyin",
"onbProfileSkipped": "Profilinizi yayınlayamaz - daha sonra Edit profilinden ayarlayın",
"onbContinue": "Devam etmeye devam et",
"onbSkipAvatar": "Benim varsayılanimle devam et",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 04748fc..6bb4c50 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -616,9 +616,9 @@
"onbNameWait": "准备...",
"onbNotNow": "现在不行",
"onbAvatarTitle": "选择你的外观",
- "onbAvatarBody": "选择一个朋克或上传自己的相片. 你可以随时改变",
+ "onbAvatarBody": "选择一个头像或上传自己的照片。你可以随时更改",
"onbUpload": "上传相片",
- "onbUploadFailed": "上传失败,, 暂时尝试一个朋克",
+ "onbUploadFailed": "上传失败。请尝试其他图片",
"onbProfileSkipped": "无法发布您的配置文件, 稍后从编辑配置文件中设置 。",
"onbContinue": "继续",
"onbSkipAvatar": "继续我的默认",
diff --git a/src/media-upload.js b/src/media-upload.js
new file mode 100644
index 0000000..686fec5
--- /dev/null
+++ b/src/media-upload.js
@@ -0,0 +1,86 @@
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex } from '@noble/hashes/utils';
+
+// Independent public hosts: a phone upload should not fail just because one
+// provider is down or rejects a particular container. nostr.download's NIP-96
+// document currently advertises large image/video/audio uploads; nostr.build
+// is the second, widely-used Blossom home.
+export const PUBLIC_MEDIA_SERVERS = ['https://nostr.download', 'https://blossom.nostr.build'];
+
+export class MediaUploadError extends Error {
+ constructor(code, details = '') {
+ super(code);
+ this.name = 'MediaUploadError';
+ this.code = code;
+ this.details = details;
+ }
+}
+
+async function fileHash(file) {
+ const hash = sha256.create();
+ const chunk = 4 * 1024 * 1024;
+ for (let at = 0; at < file.size; at += chunk)
+ hash.update(new Uint8Array(await file.slice(at, at + chunk).arrayBuffer()));
+ return bytesToHex(hash.digest());
+}
+
+const responseUrl = (body) => body?.url
+ || body?.data?.[0]?.url
+ || (body?.tags || []).find((x) => x?.[0] === 'url')?.[1]
+ || null;
+
+function mediaUrl(url, server, file) {
+ const out = new URL(url, server);
+ const tail = out.pathname.split('/').at(-1) || '';
+ if (/^[0-9a-f]{64}$/i.test(tail)) {
+ const named = String(file.name || '').match(/\.([a-z0-9]{1,8})$/i)?.[1];
+ const byType = { 'video/quicktime': 'mov', 'video/mp4': 'mp4', 'video/webm': 'webm',
+ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' }[file.type];
+ const ext = (named || byType || '').toLowerCase();
+ if (ext) out.pathname += '.' + ext;
+ }
+ return out.href;
+}
+
+// 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,
+ fetcher = globalThis.fetch,
+ timeoutMs = 5 * 60_000,
+} = {}) {
+ if (!file || !file.size || typeof signEvent !== 'function') throw new MediaUploadError('failed');
+ const x = await fileHash(file);
+ const now = Math.floor(Date.now() / 1000);
+ const auth = await signEvent({
+ kind: 24242, created_at: now - 5, content: 'upload',
+ tags: [['t', 'upload'], ['x', x], ['expiration', String(now + Math.ceil(timeoutMs / 1000) + 120)]],
+ });
+ if (!auth) throw new MediaUploadError('failed');
+ const authorization = 'Nostr ' + btoa(JSON.stringify(auth));
+ const failures = [];
+ for (const server of servers) {
+ try {
+ const response = await fetcher(server.replace(/\/$/, '') + '/upload', {
+ method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs),
+ headers: {
+ authorization,
+ 'content-type': file.type || 'application/octet-stream',
+ 'x-sha-256': x,
+ 'x-content-length': String(file.size),
+ },
+ });
+ let body = null;
+ try { body = await response.json(); } catch {}
+ const url = response.ok && responseUrl(body);
+ if (url) return mediaUrl(url, server, file);
+ failures.push({ status: response.status, reason: response.headers.get('x-reason') || body?.message || '' });
+ } catch (error) {
+ failures.push({ status: 0, reason: error?.message || '' });
+ }
+ }
+ const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; ');
+ if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details);
+ if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details);
+ throw new MediaUploadError('failed', details);
+}