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(
{ + const im = imetaInfoForStandaloneImageUrl(cleaned) + if (im.m?.startsWith('video/')) { + const poster = videoPosterMap?.get(cleaned) ?? im.image + return ( +
+ +
+ ) + } + if (im.m?.startsWith('audio/')) { + return ( +
+ +
+ ) + } + return renderStandaloneHttpsImageBlock(cleaned, reactKey) + } + const hashtagsInContent = new Set() const footnotes = new Map() const citations: Array<{ id: string; type: string; citationId: string }> = [] @@ -3305,6 +3338,22 @@ function parseMarkdownContentMarked( return s.length > 0 ? s : undefined } + const isBareMarkdownLinkToUrl = ( + token: { text?: string; tokens?: unknown[] }, + href: string, + cleaned: string | null + ): boolean => { + if (!cleaned) return false + const inner = String(token.text ?? '').trim() + if (inner === href || inner === cleaned) return true + const tok = token.tokens + if (Array.isArray(tok) && tok.length === 1 && tok[0] && typeof tok[0] === 'object' && 'text' in (tok[0] as object)) { + const tx = String((tok[0] as { text?: string }).text ?? '').trim() + return tx === href || tx === cleaned + } + return false + } + const renderInlineTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => { const out: React.ReactNode[] = [] for (let i = 0; i < tokens.length; i++) { @@ -3362,16 +3411,17 @@ function parseMarkdownContentMarked( } case 'link': { const href = String(token.href ?? '') + const cleaned = cleanUrl(href) const linkTip = markdownTokenTitle(token) const linkVisual = cn( 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words', linkTip && 'cursor-help underline decoration-dotted decoration-current/70 underline-offset-2' ) - const children = stripNestedAnchorsFromNodes( - renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${key}-link`), - `${key}-link-sanitized` - ) if (href.startsWith('payto://')) { + const children = stripNestedAnchorsFromNodes( + renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${key}-link`), + `${key}-link-sanitized` + ) out.push( ) + } else if (cleaned && isSafeMediaUrl(cleaned)) { + const bare = isBareMarkdownLinkToUrl(token, href, cleaned) + const embedMedia = + isBlossomBudBlobUrl(cleaned) || + (bare && + (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned) || isImage(cleaned))) + if (embedMedia) { + if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) { + const poster = videoPosterMap?.get(cleaned) + out.push( +
+ +
+ ) + } else { + out.push( + renderStandaloneHttpsImageOrBlossomBlob( + cleaned, + `${key}-link-as-img` + ) as React.ReactNode + ) + } + } else { + const children = stripNestedAnchorsFromNodes( + renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${key}-link`), + `${key}-link-sanitized` + ) + out.push( + + {children} + + ) + } } else { + const children = stripNestedAnchorsFromNodes( + renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${key}-link`), + `${key}-link-sanitized` + ) out.push( + +
+ ) + break + } + } + if ((!isImage(cleaned) && !isBlossomBudBlobUrl(cleaned)) || !isSafeMediaUrl(cleaned)) { out.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 (
- + + {selectTriggerLabel} - {t('Blossom')} + {t('BlossomUploadYourListOption')} + {STANDARD_BLOSSOM_UPLOAD_HOSTS.map(({ url, labelKey }) => ( + + {t(labelKey)} + + ))} {NIP_96_SERVICE.map((url) => ( {simplifyUrl(url)} @@ -52,8 +98,22 @@ export default function MediaUploadServiceSetting() { ))} + {selectedValue === BLOSSOM ? ( +

{t('BlossomUploadServiceBlurb')}

+ ) : null} + {isBlossomPreset && serviceConfig.type === 'blossom-preset' ? ( + <> +

{t('BlossomPresetUploadServiceBlurb')}

+
+
{t('BlossomPresetSelectedHostLabel')}
+
+ {serviceConfig.url} +
+
+ + ) : null} - {selectedValue === BLOSSOM && } + {showBlossomServerList ? : null}
) } 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]