diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
index 28c50dcb..8abc027f 100644
--- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
+++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
@@ -11,7 +11,8 @@ import {
isMedia,
isVideo,
isAudio,
- isWebsocketUrl
+ isWebsocketUrl,
+ isBlossomBudBlobUrl
} from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools'
@@ -441,27 +442,28 @@ export default function AsciidocArticle({
imetaInfos.forEach((info) => {
const cleaned = cleanUrl(info.url)
if (!cleaned || seenUrls.has(cleaned)) return
- if (!isImage(cleaned) && !isMedia(cleaned)) return
-
+ const byMime = !!(info.m && /^(image|video|audio)\//i.test(info.m))
+ if (!isImage(cleaned) && !isMedia(cleaned) && !isBlossomBudBlobUrl(cleaned) && !byMime) return
+
seenUrls.add(cleaned)
- if (info.m?.startsWith('image/') || isImage(cleaned)) {
- media.push({ url: info.url, type: 'image' })
- } else if (info.m?.startsWith('video/') || isVideo(cleaned)) {
+ if (info.m?.startsWith('video/') || isVideo(cleaned)) {
media.push({ url: info.url, type: 'video', poster: info.image })
} else if (info.m?.startsWith('audio/') || isAudio(cleaned)) {
media.push({ url: info.url, type: 'audio' })
+ } else if (info.m?.startsWith('image/') || isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
+ media.push({ url: info.url, type: 'image' })
}
})
-
+
// Extract from r tags
event.tags.filter(tag => tag[0] === 'r' && tag[1]).forEach(tag => {
const url = tag[1]
const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return
- if (!isImage(cleaned) && !isMedia(cleaned)) return
-
+ if (!isImage(cleaned) && !isMedia(cleaned) && !isBlossomBudBlobUrl(cleaned)) return
+
seenUrls.add(cleaned)
- if (isImage(cleaned)) {
+ if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
media.push({ url, type: 'image' })
} else if (isVideo(cleaned)) {
media.push({ url, type: 'video' })
@@ -474,7 +476,7 @@ export default function AsciidocArticle({
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
const cleaned = cleanUrl(imageTag[1])
- if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) {
+ if (cleaned && !seenUrls.has(cleaned) && (isImage(cleaned) || isBlossomBudBlobUrl(cleaned))) {
seenUrls.add(cleaned)
media.push({ url: imageTag[1], type: 'image' })
}
diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
index 060f27a1..5f2468bf 100644
--- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
+++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
@@ -20,7 +20,8 @@ import {
isWebsocketUrl,
isPseudoNostrHttpsUrl,
isSafeMediaUrl,
- isHlsPlaylistUrl
+ isHlsPlaylistUrl,
+ isBlossomBudBlobUrl
} from '@/lib/url'
import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
@@ -1946,7 +1947,7 @@ function parseMarkdownContentLegacy(
}
// Render the image
- if (isImage(cleaned)) {
+ if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
let imageIndex = imageIndexMap.get(cleaned)
if (imageIndex === undefined && getImageIdentifier) {
const identifier = getImageIdentifier(cleaned)
@@ -2092,7 +2093,7 @@ function parseMarkdownContentLegacy(
}
}
- if (isImage(cleaned)) {
+ if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
parts.push(
{label || src}
@@ -3513,7 +3631,7 @@ function parseMarkdownContentMarked(
const recoveredMdImage = tryRecoverMalformedMarkdownImageParagraph(paragraphText)
if (recoveredMdImage) {
const cleaned = cleanUrl(recoveredMdImage.href)
- if (cleaned && isImage(cleaned) && isSafeMediaUrl(cleaned)) {
+ if (cleaned && (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) && isSafeMediaUrl(cleaned)) {
const baseImeta = imetaInfoForStandaloneImageUrl(cleaned)
let imageIdx = imageIndexMap.get(cleaned)
if (imageIdx === undefined && getImageIdentifier) {
@@ -3688,8 +3806,8 @@ function parseMarkdownContentMarked(
)
}
- if (isImage(cleaned) && isSafeMediaUrl(cleaned)) {
- return renderStandaloneHttpsImageBlock(cleaned, `${key}-line-img-${lineIdx}`)
+ if ((isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) && isSafeMediaUrl(cleaned)) {
+ return renderStandaloneHttpsImageOrBlossomBlob(cleaned, `${key}-line-img-${lineIdx}`)
}
if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) {
return (
@@ -3861,8 +3979,8 @@ function parseMarkdownContentMarked(
)
}
- if (isImage(cleaned) && isSafeMediaUrl(cleaned)) {
- return renderStandaloneHttpsImageBlock(cleaned, `${key}-para-img`)
+ if ((isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) && isSafeMediaUrl(cleaned)) {
+ return renderStandaloneHttpsImageOrBlossomBlob(cleaned, `${key}-para-img`)
}
if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) {
return (
@@ -4241,7 +4359,24 @@ function parseMarkdownContentMarked(
)
}
- if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) {
+ if (isBlossomBudBlobUrl(cleaned) && isSafeMediaUrl(cleaned)) {
+ const im = imetaInfoForStandaloneImageUrl(cleaned)
+ if (im.m?.startsWith('video/') || im.m?.startsWith('audio/')) {
+ const poster = videoPosterMap?.get(cleaned) ?? im.image
+ return (
+
+
+
+ )
+ }
+ }
+ if ((!isImage(cleaned) && !isBlossomBudBlobUrl(cleaned)) || !isSafeMediaUrl(cleaned)) {
return (
{renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)}
@@ -5217,12 +5352,18 @@ export default function MarkdownArticle({
imetaInfos.forEach((info) => {
const cleaned = cleanUrl(info.url)
if (!cleaned || seenUrls.has(cleaned)) return
- if (!isImage(cleaned) && !isMedia(cleaned) && !isHlsPlaylistUrl(cleaned)) return
-
+ const byMime = !!(info.m && /^(image|video|audio)\//i.test(info.m))
+ if (
+ !isImage(cleaned) &&
+ !isMedia(cleaned) &&
+ !isHlsPlaylistUrl(cleaned) &&
+ !isBlossomBudBlobUrl(cleaned) &&
+ !byMime
+ )
+ return
+
seenUrls.add(cleaned)
- if (info.m?.startsWith('image/') || isImage(cleaned)) {
- media.push({ url: info.url, type: 'image' })
- } else if (
+ if (
info.m?.startsWith('video/') ||
isVideo(cleaned) ||
isHlsPlaylistUrl(cleaned) ||
@@ -5241,6 +5382,8 @@ export default function MarkdownArticle({
poster: info.thumb,
blurHash: info.blurHash
})
+ } else if (info.m?.startsWith('image/') || isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
+ media.push({ url: info.url, type: 'image' })
}
})
@@ -5249,10 +5392,10 @@ export default function MarkdownArticle({
const url = tag[1]
const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return
- if (!isImage(cleaned) && !isMedia(cleaned) && !isHlsPlaylistUrl(cleaned)) return
-
+ if (!isImage(cleaned) && !isMedia(cleaned) && !isHlsPlaylistUrl(cleaned) && !isBlossomBudBlobUrl(cleaned)) return
+
seenUrls.add(cleaned)
- if (isImage(cleaned)) {
+ if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
media.push({ url, type: 'image' })
} else if (isVideo(cleaned) || isHlsPlaylistUrl(cleaned)) {
media.push({ url, type: 'video' })
@@ -5265,7 +5408,7 @@ export default function MarkdownArticle({
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
const cleaned = cleanUrl(imageTag[1])
- if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) {
+ if (cleaned && !seenUrls.has(cleaned) && (isImage(cleaned) || isBlossomBudBlobUrl(cleaned))) {
seenUrls.add(cleaned)
media.push({ url: imageTag[1], type: 'image' })
}
@@ -5348,7 +5491,7 @@ export default function MarkdownArticle({
const url = tag[1]
if (!url.startsWith('http://') && !url.startsWith('https://')) return
if (isPseudoNostrHttpsUrl(url)) return
- if (isImage(url) || isMedia(url) || isHlsPlaylistUrl(url)) return
+ if (isImage(url) || isMedia(url) || isHlsPlaylistUrl(url) || isBlossomBudBlobUrl(url)) return
if (isYouTubeUrl(url)) return // Exclude YouTube URLs
if (isSpotifyUrl(url)) return
if (isZapStreamWatchUrl(url)) return
@@ -5380,7 +5523,7 @@ export default function MarkdownArticle({
// Add metadata image if it exists
if (metadata.image) {
const cleaned = cleanUrl(metadata.image)
- if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) {
+ if (cleaned && !seenUrls.has(cleaned) && (isImage(cleaned) || isBlossomBudBlobUrl(cleaned))) {
seenUrls.add(cleaned)
images.push({ url: metadata.image })
}
@@ -5422,6 +5565,9 @@ export default function MarkdownArticle({
const pathname = parsed.pathname
// Extract the filename (last segment of the path)
const filename = pathname.split('/').pop() || ''
+ if (filename && /^[a-f0-9]{64}$/i.test(filename)) {
+ return `blossom-sha256:${filename.toLowerCase()}`
+ }
// If the filename looks like a hash (hex string), use it for comparison
// Also use the full pathname as a fallback
if (filename && /^[a-f0-9]{32,}\.(png|jpg|jpeg|gif|webp|svg)$/i.test(filename)) {
@@ -5467,7 +5613,7 @@ export default function MarkdownArticle({
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
const cleaned = cleanUrl(url)
- if (cleaned && (isImage(cleaned) || isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned))) {
+ if (cleaned && (isImage(cleaned) || isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned) || isBlossomBudBlobUrl(cleaned))) {
urls.add(cleaned)
// Also add image identifier for filename-based matching
const identifier = getImageIdentifier(cleaned)
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx
index 439d639a..9fd39e93 100644
--- a/src/components/PostEditor/PostContent.tsx
+++ b/src/components/PostEditor/PostContent.tsx
@@ -48,7 +48,7 @@ import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
-import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url'
+import { cleanUrl, isBlossomBudBlobUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import logger from '@/lib/logger'
import { LoginRequiredError } from '@/lib/nostr-errors'
import postEditorCache from '@/services/post-editor-cache.service'
@@ -1531,6 +1531,7 @@ export default function PostContent({
}
const inferKindFromEditorMediaUrl = (url: string): number | null => {
+ if (isBlossomBudBlobUrl(url)) return ExtendedKind.PICTURE
const path = url.split(/[?#]/)[0].toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|heic|avif|apng)$/i.test(path)) return ExtendedKind.PICTURE
if (/\.(mp3|m4a|mka|ogg|opus|wav|aac|flac)$/i.test(path)) return ExtendedKind.VOICE
diff --git a/src/constants.ts b/src/constants.ts
index 8a01a071..559bec11 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -246,11 +246,25 @@ export const METADATA_BATCH_AUTHORS_CHUNK = 22
*/
export const PROFILE_FETCH_PROMISE_TIMEOUT_MS = 20000
-export const RECOMMENDED_BLOSSOM_SERVERS = [
- 'https://blossom.band',
- 'https://blossom.primal.net',
- 'https://nostr.media'
-]
+/**
+ * Public Blossom (BUD) upload bases: presets in post settings and merged after the user’s
+ * kind-10063 URLs when resolving the default Blossom server list.
+ * @see https://0x0.happytavern.co/ — Lotus-style ephemeral Blossom (0x0 backend).
+ */
+export const STANDARD_BLOSSOM_UPLOAD_HOSTS = [
+ { url: 'https://0x0.happytavern.co', labelKey: 'BlossomUploadOptionHappyTavern' },
+ { url: 'https://blossom.band', labelKey: 'BlossomUploadOptionBand' },
+ { url: 'https://blossom.primal.net', labelKey: 'BlossomUploadOptionPrimal' },
+ { url: 'https://nostr.media', labelKey: 'BlossomUploadOptionNostrMedia' },
+ { url: 'https://blossom.nostr.build', labelKey: 'BlossomUploadOptionNostrBuild' }
+] as const
+
+export const RECOMMENDED_BLOSSOM_SERVERS = STANDARD_BLOSSOM_UPLOAD_HOSTS.map((h) => h.url)
+
+/** Prefix for media-upload Select values that pin a Blossom host (`URL` is URI-encoded after this). */
+export const BLOSSOM_PRESET_SELECT_PREFIX = 'blossom-preset:'
+/** [Lotus](https://github.com/0ceanSlim/lotus) — self-hosted Blossom (BUD) server (see GitHub for cdn_url / api_addr). */
+export const LOTUS_BLOSSOM_REPO_URL = 'https://github.com/0ceanSlim/lotus'
export const StorageKey = {
VERSION: 'version',
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 789c507d..8a14e567 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -545,6 +545,20 @@ export default {
"Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.": "Grüne Hinweise anzeigen, wenn Beiträge, Antworten, Reaktionen und andere Veröffentlichungen gelingen. Wenn aus, erscheint kurz ein kleines Häkchen unten rechts. Fehler weiterhin als Hinweis.",
"Publish successful": "Veröffentlichung erfolgreich",
"Media upload service": "Medien-Upload-Service",
+ BlossomUploadYourListOption: "Blossom (eigene Liste)",
+ BlossomUploadOptionHappyTavern: "Happy Tavern 0x0 (Blossom)",
+ BlossomUploadOptionBand: "blossom.band (Blossom)",
+ BlossomUploadOptionPrimal: "Primal (Blossom)",
+ BlossomUploadOptionNostrMedia: "nostr.media (Blossom)",
+ BlossomUploadOptionNostrBuild: "Nostr.build (Blossom)",
+ BlossomUploadServiceBlurb:
+ "Nutzt das Blossom-(BUD-)Protokoll mit deiner sortierten Serverliste unten (öffentliche Anbieter wie Primal oder eigene).",
+ BlossomPresetUploadServiceBlurb:
+ "Fester Blossom-(BUD-)Host: Uploads laufen nur über diesen Server, nicht über deine veröffentlichte Kind-10063-Liste.",
+ BlossomPresetSelectedHostLabel: "Blossom-Host (dieses Preset)",
+ "Lotus on GitHub": "Lotus auf GitHub",
+ BlossomSelfHostLotusHint:
+ "Eigene Blossom-(BUD-)Server: öffentliche Basis-URL einfügen (siehe README des Projekts zu cdn_url / api_addr).",
"Choose a relay": "Wähle ein Relay",
"no relays found": "Keine Relays gefunden",
video: "Video",
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 4f579268..fb6ea2fd 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -552,6 +552,20 @@ export default {
"Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.": "Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.",
"Publish successful": "Publish successful",
"Media upload service": "Media upload service",
+ BlossomUploadYourListOption: "Blossom (your list)",
+ BlossomUploadOptionHappyTavern: "Happy Tavern 0x0 (Blossom)",
+ BlossomUploadOptionBand: "blossom.band (Blossom)",
+ BlossomUploadOptionPrimal: "Primal (Blossom)",
+ BlossomUploadOptionNostrMedia: "nostr.media (Blossom)",
+ BlossomUploadOptionNostrBuild: "Nostr.build (Blossom)",
+ BlossomUploadServiceBlurb:
+ "Uses the Blossom (BUD) protocol with your ordered server list below (public hosts like Primal or your own).",
+ BlossomPresetUploadServiceBlurb:
+ "Fixed Blossom (BUD) host: uploads use this server only, not your published kind-10063 list.",
+ BlossomPresetSelectedHostLabel: "Blossom host (this preset)",
+ "Lotus on GitHub": "Lotus on GitHub",
+ BlossomSelfHostLotusHint:
+ "Self-hosted Blossom (BUD) servers: paste your public base URL (see the project README for cdn_url / api_addr).",
"Choose a relay": "Choose a relay",
"no relays found": "no relays found",
video: "video",
diff --git a/src/lib/content-parser.ts b/src/lib/content-parser.ts
index d63d138c..b0cc4e6f 100644
--- a/src/lib/content-parser.ts
+++ b/src/lib/content-parser.ts
@@ -12,7 +12,7 @@ import {
} from '@/lib/content-patterns'
import { PAYTO_URI_REGEX } from '@/lib/payto'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
-import { isImage, isMedia, isHlsPlaylistUrl } from './url'
+import { isImage, isMedia, isHlsPlaylistUrl, isBlossomBudBlobUrl } from './url'
import { isSpotifyOpenUrl } from './spotify-url'
import { isZapStreamWatchUrl } from './zap-stream-url'
@@ -111,6 +111,8 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
let type: TEmbeddedNodeType = 'url'
if (isImage(url)) {
type = 'image'
+ } else if (isBlossomBudBlobUrl(url)) {
+ type = 'image'
} else if (isHlsPlaylistUrl(url)) {
type = 'media'
} else if (isMedia(url)) {
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index 9a3ecf06..959d1342 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -32,7 +32,7 @@ import {
NIP22_URL_SCOPE_KIND
} from '@/lib/rss-article'
import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns'
-import { cleanUrl } from '@/lib/url'
+import { blossomSha256FromBlobUrl, cleanUrl, isBlossomBudBlobUrl } from '@/lib/url'
import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip'
import { randomString } from './random'
import { generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag'
@@ -195,6 +195,7 @@ export function collectUploadImetaTagsForContentUrls(content: string): string[][
const keys = [raw]
const c = cleanUrl(raw)
if (c && c !== raw) keys.push(c)
+ let fromUpload = false
for (const key of keys) {
const tag = mediaUpload.getImetaTagByUrl(key)
if (tag) {
@@ -203,9 +204,17 @@ export function collectUploadImetaTagsForContentUrls(content: string): string[][
seen.add(u)
out.push(tag)
}
+ fromUpload = true
break
}
}
+ if (!fromUpload && c && isBlossomBudBlobUrl(c)) {
+ const x = blossomSha256FromBlobUrl(c)
+ if (x && !seen.has(c)) {
+ seen.add(c)
+ out.push(['imeta', `url ${c}`, 'm image/jpeg', `x ${x}`])
+ }
+ }
}
return out
}
diff --git a/src/lib/tag.ts b/src/lib/tag.ts
index 233be5ce..754b6b24 100644
--- a/src/lib/tag.ts
+++ b/src/lib/tag.ts
@@ -1,5 +1,5 @@
import { TEmoji, TImetaInfo } from '@/types'
-import { cleanUrl, isImage, isMedia } from './url'
+import { cleanUrl, isImage, isMedia, isBlossomBudBlobUrl } from './url'
import { isBlurhashValid } from 'blurhash'
import { nip19 } from 'nostr-tools'
import { isValidPubkey } from './pubkey'
@@ -137,6 +137,7 @@ export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImeta
if (
isImage(t) ||
isMedia(t) ||
+ isBlossomBudBlobUrl(t) ||
(mimeHint &&
(mimeHint.startsWith('image/') ||
mimeHint.startsWith('video/') ||
diff --git a/src/lib/url.ts b/src/lib/url.ts
index a38e00f5..985a1e72 100644
--- a/src/lib/url.ts
+++ b/src/lib/url.ts
@@ -354,6 +354,30 @@ export function isMedia(url: string) {
}
}
+/**
+ * SHA-256 hex from a Blossom (BUD) blob URL path (`https://host/<64-hex>`), or null.
+ */
+export function blossomSha256FromBlobUrl(url: string): string | null {
+ try {
+ const u = new URL(url.trim())
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
+ const segs = u.pathname.split('/').filter(Boolean)
+ if (segs.length !== 1) return null
+ const h = segs[0]!
+ return /^[a-f0-9]{64}$/i.test(h) ? h.toLowerCase() : null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Blossom (BUD) blob URLs: `https://host/<64-hex-sha256>` with no file extension.
+ * MIME comes from the server or from NIP-94 `imeta` (`m`, `x`, etc.).
+ */
+export function isBlossomBudBlobUrl(url: string): boolean {
+ return blossomSha256FromBlobUrl(url) !== null
+}
+
export function isAudio(url: string) {
try {
const path = new URL(url).pathname.toLowerCase()
diff --git a/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx b/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx
index 1ea81723..30287606 100644
--- a/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx
+++ b/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
-import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants'
+import { LOTUS_BLOSSOM_REPO_URL, RECOMMENDED_BLOSSOM_SERVERS } from '@/constants'
import { createBlossomServerListDraftEvent } from '@/lib/draft-event'
import { getServersFromServerTags } from '@/lib/tag'
import { normalizeHttpUrl } from '@/lib/url'
@@ -185,6 +185,18 @@ export default function BlossomServerListSetting() {
{t('Add')}
+
+
+ {t('Lotus on GitHub')}
+
+ {' — '}
+ {t('BlossomSelfHostLotusHint')}
+
)
}
diff --git a/src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx b/src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx
index 676422bd..4dd6397d 100644
--- a/src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx
+++ b/src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx
@@ -6,8 +6,12 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
-import { DEFAULT_NIP_96_SERVICE, NIP_96_SERVICE } from '@/constants'
-import { simplifyUrl } from '@/lib/url'
+import {
+ BLOSSOM_PRESET_SELECT_PREFIX,
+ NIP_96_SERVICE,
+ STANDARD_BLOSSOM_UPLOAD_HOSTS
+} from '@/constants'
+import { simplifyUrl, normalizeHttpUrl } from '@/lib/url'
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -15,6 +19,19 @@ import BlossomServerListSetting from './BlossomServerListSetting'
const BLOSSOM = 'blossom'
+function blossomPresetSelectValue(url: string): string {
+ return `${BLOSSOM_PRESET_SELECT_PREFIX}${encodeURIComponent(url)}`
+}
+
+function tryParseBlossomPresetSelectValue(value: string): string | undefined {
+ if (!value.startsWith(BLOSSOM_PRESET_SELECT_PREFIX)) return undefined
+ try {
+ return decodeURIComponent(value.slice(BLOSSOM_PRESET_SELECT_PREFIX.length))
+ } catch {
+ return undefined
+ }
+}
+
export default function MediaUploadServiceSetting() {
const { t } = useTranslation()
const { serviceConfig, updateServiceConfig } = useMediaUploadService()
@@ -22,6 +39,9 @@ export default function MediaUploadServiceSetting() {
if (serviceConfig.type === 'blossom') {
return BLOSSOM
}
+ if (serviceConfig.type === 'blossom-preset') {
+ return blossomPresetSelectValue(serviceConfig.url)
+ }
return serviceConfig.service
}, [serviceConfig])
@@ -29,22 +49,48 @@ export default function MediaUploadServiceSetting() {
if (value === BLOSSOM) {
return updateServiceConfig({ type: 'blossom' })
}
+ if (value.startsWith(BLOSSOM_PRESET_SELECT_PREFIX)) {
+ const presetUrl = tryParseBlossomPresetSelectValue(value)
+ if (presetUrl !== undefined) {
+ const normalized = normalizeHttpUrl(presetUrl)
+ if (normalized) {
+ return updateServiceConfig({ type: 'blossom-preset', url: normalized })
+ }
+ }
+ return
+ }
return updateServiceConfig({ type: 'nip96', service: value })
}
+ const showBlossomServerList = selectedValue === BLOSSOM
+ const isBlossomPreset = serviceConfig.type === 'blossom-preset'
+
+ /** Radix Select reads item text from the closed portal; items are often unmounted, so the trigger stays blank unless we set this explicitly. */
+ const selectTriggerLabel = useMemo(() => {
+ if (serviceConfig.type === 'blossom') {
+ return t('BlossomUploadYourListOption')
+ }
+ if (serviceConfig.type === 'blossom-preset') {
+ const preset = STANDARD_BLOSSOM_UPLOAD_HOSTS.find((h) => h.url === serviceConfig.url)
+ return preset ? t(preset.labelKey) : `${simplifyUrl(serviceConfig.url)} (${t('Blossom')})`
+ }
+ return simplifyUrl(serviceConfig.service)
+ }, [serviceConfig, t])
+
return (
-
)
}
diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts
index c1818674..10c11892 100644
--- a/src/services/content-parser.service.ts
+++ b/src/services/content-parser.service.ts
@@ -9,7 +9,7 @@ import { getImetaInfosFromEvent } from '@/lib/event'
import { URL_REGEX, ExtendedKind } from '@/constants'
import { TImetaInfo } from '@/types'
import logger from '@/lib/logger'
-import { isPseudoNostrHttpsUrl } from '@/lib/url'
+import { isPseudoNostrHttpsUrl, isBlossomBudBlobUrl } from '@/lib/url'
export interface ParsedContent {
html: string
@@ -803,13 +803,13 @@ class ContentParserService {
const rawUrls = content.match(URL_REGEX) || []
rawUrls.forEach(url => {
if (!seenUrls.has(url)) {
- const isImage = /\.(jpeg|jpg|png|gif|webp|svg)$/i.test(url)
- const isVideo = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url)
- if (isImage || isVideo) {
- media.push({
- url,
- pubkey: event?.pubkey || '',
- m: isVideo ? 'video/*' : 'image/*'
+ const isImageExt = /\.(jpeg|jpg|png|gif|webp|svg)$/i.test(url)
+ const isVideoExt = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url)
+ if (isImageExt || isVideoExt || isBlossomBudBlobUrl(url)) {
+ media.push({
+ url,
+ pubkey: event?.pubkey || '',
+ m: isVideoExt ? 'video/*' : 'image/*'
})
seenUrls.add(url)
}
diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts
index 109d4706..55911bcf 100644
--- a/src/services/local-storage.service.ts
+++ b/src/services/local-storage.service.ts
@@ -851,7 +851,18 @@ class LocalStorageService {
if (!pubkey) {
return defaultConfig
}
- return this.mediaUploadServiceConfigMap[pubkey] ?? defaultConfig
+ const cfg = this.mediaUploadServiceConfigMap[pubkey] ?? defaultConfig
+ // Legacy `{ type: 'lotus' }` matched `blossom` uploads; migrate to `blossom`.
+ if ((cfg as { type?: string }).type === 'lotus') {
+ const migrated: TMediaUploadServiceConfig = { type: 'blossom' }
+ this.mediaUploadServiceConfigMap[pubkey] = migrated
+ this.persistSetting(
+ StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP,
+ JSON.stringify(this.mediaUploadServiceConfigMap)
+ )
+ return migrated
+ }
+ return cfg
}
setMediaUploadServiceConfig(
diff --git a/src/services/media-extraction.service.ts b/src/services/media-extraction.service.ts
index a77bdc5c..456736c8 100644
--- a/src/services/media-extraction.service.ts
+++ b/src/services/media-extraction.service.ts
@@ -1,14 +1,23 @@
import { Event } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event'
-import { cleanUrl, isImage, isMedia, isAudio, isVideo, isHlsPlaylistUrl } from '@/lib/url'
+import {
+ blossomSha256FromBlobUrl,
+ cleanUrl,
+ isImage,
+ isMedia,
+ isAudio,
+ isVideo,
+ isHlsPlaylistUrl,
+ isBlossomBudBlobUrl
+} from '@/lib/url'
+import { TImetaInfo } from '@/types'
+import mediaUpload from './media-upload.service'
+import { getImetaInfoFromImetaTag } from '@/lib/tag'
/** Any URL we may embed or extract from note bodies (incl. video-only extensions like .3gp, HLS manifests). */
function isEmbeddableMediaUrl(cleaned: string): boolean {
- return isImage(cleaned) || isMedia(cleaned) || isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)
+ return isImage(cleaned) || isMedia(cleaned) || isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned) || isBlossomBudBlobUrl(cleaned)
}
-import { TImetaInfo } from '@/types'
-import mediaUpload from './media-upload.service'
-import { getImetaInfoFromImetaTag } from '@/lib/tag'
export interface ExtractedMedia {
images: TImetaInfo[]
@@ -44,6 +53,8 @@ export function extractAllMediaFromEvent(
if (!mime) {
if (isImage(cleaned)) {
mime = 'image/*'
+ } else if (isBlossomBudBlobUrl(cleaned)) {
+ mime = 'image/*'
} else if (isHlsPlaylistUrl(cleaned)) {
mime = 'video/*'
} else if (isAudio(cleaned)) {
@@ -157,6 +168,10 @@ export function extractAllMediaFromEvent(
try {
const u = cleanUrl(url)
if (!u) return null
+ const blossom = blossomSha256FromBlobUrl(u)
+ if (blossom) {
+ return `blossom-sha256:${blossom}`
+ }
const pathname = new URL(u).pathname
const filename = pathname.split('/').pop() || ''
if (filename && /^[a-f0-9]{32,}\.(png|jpg|jpeg|gif|webp|svg|avif|apng)$/i.test(filename)) {
@@ -171,11 +186,15 @@ export function extractAllMediaFromEvent(
imetaInfos.forEach((imeta) => {
const imetaUrl = cleanUrl(imeta.url)
const imetaKey = imetaUrl ? imageIdentityKey(imetaUrl) : null
+ const x = imeta.x?.trim()
+ const imetaKeyFromX = x && /^[a-f0-9]{64}$/i.test(x) ? `blossom-sha256:${x.toLowerCase()}` : null
allMedia.forEach((media, index) => {
if (imetaUrl && imetaUrl === media.url) {
allMedia[index] = { ...media, ...imeta, url: media.url }
} else if (imetaKey && imetaKey === imageIdentityKey(media.url)) {
allMedia[index] = { ...media, ...imeta, url: media.url }
+ } else if (imetaKeyFromX && imetaKeyFromX === imageIdentityKey(media.url)) {
+ allMedia[index] = { ...media, ...imeta, url: media.url }
} else {
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(media.url)
@@ -201,6 +220,14 @@ export function extractAllMediaFromEvent(
videos.push(media)
} else if (media.m?.startsWith('audio/') || isAudio(media.url)) {
audio.push(media)
+ } else if (isBlossomBudBlobUrl(media.url)) {
+ if (media.m?.startsWith('video/')) {
+ videos.push(media)
+ } else if (media.m?.startsWith('audio/')) {
+ audio.push(media)
+ } else {
+ images.push(media)
+ }
} else {
// Fallback: try to determine by URL extension
if (isImage(media.url)) {
diff --git a/src/services/media-upload.service.ts b/src/services/media-upload.service.ts
index 990c325f..5e4ee062 100644
--- a/src/services/media-upload.service.ts
+++ b/src/services/media-upload.service.ts
@@ -163,7 +163,10 @@ class MediaUploadService {
}
startPseudoProgress()
- const servers = await client.fetchBlossomServerList(pubkey)
+ const servers =
+ this.serviceConfig.type === 'blossom-preset'
+ ? [this.serviceConfig.url]
+ : await client.fetchBlossomServerList(pubkey)
if (servers.length === 0) {
throw new Error('No Blossom services available')
}
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 2bb10224..a907bd41 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -230,6 +230,11 @@ export type TMediaUploadServiceConfig =
| {
type: 'blossom'
}
+ /** Blossom (BUD) upload pinned to one public host (ignores the kind-10063 list for the primary upload). */
+ | {
+ type: 'blossom-preset'
+ url: string
+ }
export type TPollType = (typeof POLL_TYPE)[keyof typeof POLL_TYPE]