Browse Source

added happytavern to the blossom servers

imwald
Silberengel 1 month ago
parent
commit
2e4c9d8d7c
  1. 24
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 202
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 3
      src/components/PostEditor/PostContent.tsx
  4. 24
      src/constants.ts
  5. 14
      src/i18n/locales/de.ts
  6. 14
      src/i18n/locales/en.ts
  7. 4
      src/lib/content-parser.ts
  8. 11
      src/lib/draft-event.ts
  9. 3
      src/lib/tag.ts
  10. 24
      src/lib/url.ts
  11. 14
      src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx
  12. 82
      src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx
  13. 16
      src/services/content-parser.service.ts
  14. 13
      src/services/local-storage.service.ts
  15. 37
      src/services/media-extraction.service.ts
  16. 5
      src/services/media-upload.service.ts
  17. 5
      src/types/index.d.ts

24
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -11,7 +11,8 @@ import {
isMedia, isMedia,
isVideo, isVideo,
isAudio, isAudio,
isWebsocketUrl isWebsocketUrl,
isBlossomBudBlobUrl
} from '@/lib/url' } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@ -441,27 +442,28 @@ export default function AsciidocArticle({
imetaInfos.forEach((info) => { imetaInfos.forEach((info) => {
const cleaned = cleanUrl(info.url) const cleaned = cleanUrl(info.url)
if (!cleaned || seenUrls.has(cleaned)) return 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) seenUrls.add(cleaned)
if (info.m?.startsWith('image/') || isImage(cleaned)) { if (info.m?.startsWith('video/') || isVideo(cleaned)) {
media.push({ url: info.url, type: 'image' })
} else if (info.m?.startsWith('video/') || isVideo(cleaned)) {
media.push({ url: info.url, type: 'video', poster: info.image }) media.push({ url: info.url, type: 'video', poster: info.image })
} else if (info.m?.startsWith('audio/') || isAudio(cleaned)) { } else if (info.m?.startsWith('audio/') || isAudio(cleaned)) {
media.push({ url: info.url, type: 'audio' }) 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 // Extract from r tags
event.tags.filter(tag => tag[0] === 'r' && tag[1]).forEach(tag => { event.tags.filter(tag => tag[0] === 'r' && tag[1]).forEach(tag => {
const url = tag[1] const url = tag[1]
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return if (!cleaned || seenUrls.has(cleaned)) return
if (!isImage(cleaned) && !isMedia(cleaned)) return if (!isImage(cleaned) && !isMedia(cleaned) && !isBlossomBudBlobUrl(cleaned)) return
seenUrls.add(cleaned) seenUrls.add(cleaned)
if (isImage(cleaned)) { if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
media.push({ url, type: 'image' }) media.push({ url, type: 'image' })
} else if (isVideo(cleaned)) { } else if (isVideo(cleaned)) {
media.push({ url, type: 'video' }) media.push({ url, type: 'video' })
@ -474,7 +476,7 @@ export default function AsciidocArticle({
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) { if (imageTag?.[1]) {
const cleaned = cleanUrl(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) seenUrls.add(cleaned)
media.push({ url: imageTag[1], type: 'image' }) media.push({ url: imageTag[1], type: 'image' })
} }

202
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -20,7 +20,8 @@ import {
isWebsocketUrl, isWebsocketUrl,
isPseudoNostrHttpsUrl, isPseudoNostrHttpsUrl,
isSafeMediaUrl, isSafeMediaUrl,
isHlsPlaylistUrl isHlsPlaylistUrl,
isBlossomBudBlobUrl
} from '@/lib/url' } from '@/lib/url'
import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event' import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article' import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
@ -1946,7 +1947,7 @@ function parseMarkdownContentLegacy(
} }
// Render the image // Render the image
if (isImage(cleaned)) { if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
let imageIndex = imageIndexMap.get(cleaned) let imageIndex = imageIndexMap.get(cleaned)
if (imageIndex === undefined && getImageIdentifier) { if (imageIndex === undefined && getImageIdentifier) {
const identifier = getImageIdentifier(cleaned) const identifier = getImageIdentifier(cleaned)
@ -2092,7 +2093,7 @@ function parseMarkdownContentLegacy(
} }
} }
if (isImage(cleaned)) { if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
parts.push( parts.push(
<div key={`img-${patternIdx}`} className="my-2 block max-w-[400px]"> <div key={`img-${patternIdx}`} className="my-2 block max-w-[400px]">
<Image <Image
@ -2135,7 +2136,7 @@ function parseMarkdownContentLegacy(
const imageUrl = imageMatch[2] const imageUrl = imageMatch[2]
const cleaned = cleanUrl(imageUrl) const cleaned = cleanUrl(imageUrl)
if (isImage(cleaned)) { if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
// Check if there's a thumbnail available for this image // Check if there's a thumbnail available for this image
let thumbnailUrl: string | undefined let thumbnailUrl: string | undefined
if (imageThumbnailMap) { if (imageThumbnailMap) {
@ -2774,7 +2775,7 @@ function parseMarkdownContentLegacy(
} }
// Render the image // Render the image
if (isImage(cleaned)) { if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
let imageIndex = imageIndexMap.get(cleaned) let imageIndex = imageIndexMap.get(cleaned)
if (imageIndex === undefined && getImageIdentifier) { if (imageIndex === undefined && getImageIdentifier) {
const identifier = getImageIdentifier(cleaned) const identifier = getImageIdentifier(cleaned)
@ -3262,6 +3263,38 @@ function parseMarkdownContentMarked(
) )
} }
/** Blossom BUD URLs (single path segment: 64-hex SHA-256) and normal image URLs; uses `imeta` `m` for video/audio. */
const renderStandaloneHttpsImageOrBlossomBlob = (cleaned: string, reactKey: string) => {
const im = imetaInfoForStandaloneImageUrl(cleaned)
if (im.m?.startsWith('video/')) {
const poster = videoPosterMap?.get(cleaned) ?? im.image
return (
<div key={reactKey} className="my-2">
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned) ?? im.blurHash}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
}
if (im.m?.startsWith('audio/')) {
return (
<div key={reactKey} className="my-2">
<MediaPlayer
src={cleaned}
blurHash={mediaBlurHashMap?.get(cleaned) ?? im.blurHash}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
}
return renderStandaloneHttpsImageBlock(cleaned, reactKey)
}
const hashtagsInContent = new Set<string>() const hashtagsInContent = new Set<string>()
const footnotes = new Map<string, string>() const footnotes = new Map<string, string>()
const citations: Array<{ id: string; type: string; citationId: string }> = [] const citations: Array<{ id: string; type: string; citationId: string }> = []
@ -3305,6 +3338,22 @@ function parseMarkdownContentMarked(
return s.length > 0 ? s : undefined 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 renderInlineTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => {
const out: React.ReactNode[] = [] const out: React.ReactNode[] = []
for (let i = 0; i < tokens.length; i++) { for (let i = 0; i < tokens.length; i++) {
@ -3362,16 +3411,17 @@ function parseMarkdownContentMarked(
} }
case 'link': { case 'link': {
const href = String(token.href ?? '') const href = String(token.href ?? '')
const cleaned = cleanUrl(href)
const linkTip = markdownTokenTitle(token) const linkTip = markdownTokenTitle(token)
const linkVisual = cn( const linkVisual = cn(
'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words', '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' 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://')) { if (href.startsWith('payto://')) {
const children = stripNestedAnchorsFromNodes(
renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${key}-link`),
`${key}-link-sanitized`
)
out.push( out.push(
<PaytoLink <PaytoLink
key={`${key}-payto`} key={`${key}-payto`}
@ -3382,7 +3432,57 @@ function parseMarkdownContentMarked(
{children} {children}
</PaytoLink> </PaytoLink>
) )
} 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(
<div key={`${key}-link-inline-media`} className="my-2 not-prose">
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned)}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
} 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(
<a
key={`${key}-href`}
href={href}
target="_blank"
rel="noopener noreferrer"
title={linkTip}
className={linkVisual}
>
{children}
</a>
)
}
} else { } else {
const children = stripNestedAnchorsFromNodes(
renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${key}-link`),
`${key}-link-sanitized`
)
out.push( out.push(
<a <a
key={`${key}-href`} key={`${key}-href`}
@ -3422,7 +3522,25 @@ function parseMarkdownContentMarked(
) )
break break
} }
if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) { if (isBlossomBudBlobUrl(cleaned) && isSafeMediaUrl(cleaned)) {
const baseImeta = imetaInfoForStandaloneImageUrl(cleaned)
if (baseImeta.m?.startsWith('video/') || baseImeta.m?.startsWith('audio/')) {
const poster = videoPosterMap?.get(cleaned) ?? baseImeta.image
out.push(
<div key={`${key}-blossom-inline-media`} className="my-2 not-prose">
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned) ?? baseImeta.blurHash}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
break
}
}
if ((!isImage(cleaned) && !isBlossomBudBlobUrl(cleaned)) || !isSafeMediaUrl(cleaned)) {
out.push( out.push(
<span key={`${key}-img-fallback`} className="break-words"> <span key={`${key}-img-fallback`} className="break-words">
{label || src} {label || src}
@ -3513,7 +3631,7 @@ function parseMarkdownContentMarked(
const recoveredMdImage = tryRecoverMalformedMarkdownImageParagraph(paragraphText) const recoveredMdImage = tryRecoverMalformedMarkdownImageParagraph(paragraphText)
if (recoveredMdImage) { if (recoveredMdImage) {
const cleaned = cleanUrl(recoveredMdImage.href) const cleaned = cleanUrl(recoveredMdImage.href)
if (cleaned && isImage(cleaned) && isSafeMediaUrl(cleaned)) { if (cleaned && (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) && isSafeMediaUrl(cleaned)) {
const baseImeta = imetaInfoForStandaloneImageUrl(cleaned) const baseImeta = imetaInfoForStandaloneImageUrl(cleaned)
let imageIdx = imageIndexMap.get(cleaned) let imageIdx = imageIndexMap.get(cleaned)
if (imageIdx === undefined && getImageIdentifier) { if (imageIdx === undefined && getImageIdentifier) {
@ -3688,8 +3806,8 @@ function parseMarkdownContentMarked(
</div> </div>
) )
} }
if (isImage(cleaned) && isSafeMediaUrl(cleaned)) { if ((isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) && isSafeMediaUrl(cleaned)) {
return renderStandaloneHttpsImageBlock(cleaned, `${key}-line-img-${lineIdx}`) return renderStandaloneHttpsImageOrBlossomBlob(cleaned, `${key}-line-img-${lineIdx}`)
} }
if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) { if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) {
return ( return (
@ -3861,8 +3979,8 @@ function parseMarkdownContentMarked(
</div> </div>
) )
} }
if (isImage(cleaned) && isSafeMediaUrl(cleaned)) { if ((isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) && isSafeMediaUrl(cleaned)) {
return renderStandaloneHttpsImageBlock(cleaned, `${key}-para-img`) return renderStandaloneHttpsImageOrBlossomBlob(cleaned, `${key}-para-img`)
} }
if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) { if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) {
return ( return (
@ -4241,7 +4359,24 @@ function parseMarkdownContentMarked(
</div> </div>
) )
} }
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 (
<div key={`${key}-blossom-media-block`} className="my-2">
<MediaPlayer
src={cleaned}
poster={poster}
blurHash={mediaBlurHashMap?.get(cleaned) ?? im.blurHash}
className="max-w-[400px]"
mustLoad={!lazyMedia}
/>
</div>
)
}
}
if ((!isImage(cleaned) && !isBlossomBudBlobUrl(cleaned)) || !isSafeMediaUrl(cleaned)) {
return ( return (
<div key={`${key}-img-inline-fallback`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}> <div key={`${key}-img-inline-fallback`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)} {renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)}
@ -5217,12 +5352,18 @@ export default function MarkdownArticle({
imetaInfos.forEach((info) => { imetaInfos.forEach((info) => {
const cleaned = cleanUrl(info.url) const cleaned = cleanUrl(info.url)
if (!cleaned || seenUrls.has(cleaned)) return 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) seenUrls.add(cleaned)
if (info.m?.startsWith('image/') || isImage(cleaned)) { if (
media.push({ url: info.url, type: 'image' })
} else if (
info.m?.startsWith('video/') || info.m?.startsWith('video/') ||
isVideo(cleaned) || isVideo(cleaned) ||
isHlsPlaylistUrl(cleaned) || isHlsPlaylistUrl(cleaned) ||
@ -5241,6 +5382,8 @@ export default function MarkdownArticle({
poster: info.thumb, poster: info.thumb,
blurHash: info.blurHash 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 url = tag[1]
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return 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) seenUrls.add(cleaned)
if (isImage(cleaned)) { if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
media.push({ url, type: 'image' }) media.push({ url, type: 'image' })
} else if (isVideo(cleaned) || isHlsPlaylistUrl(cleaned)) { } else if (isVideo(cleaned) || isHlsPlaylistUrl(cleaned)) {
media.push({ url, type: 'video' }) media.push({ url, type: 'video' })
@ -5265,7 +5408,7 @@ export default function MarkdownArticle({
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) { if (imageTag?.[1]) {
const cleaned = cleanUrl(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) seenUrls.add(cleaned)
media.push({ url: imageTag[1], type: 'image' }) media.push({ url: imageTag[1], type: 'image' })
} }
@ -5348,7 +5491,7 @@ export default function MarkdownArticle({
const url = tag[1] const url = tag[1]
if (!url.startsWith('http://') && !url.startsWith('https://')) return if (!url.startsWith('http://') && !url.startsWith('https://')) return
if (isPseudoNostrHttpsUrl(url)) 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 (isYouTubeUrl(url)) return // Exclude YouTube URLs
if (isSpotifyUrl(url)) return if (isSpotifyUrl(url)) return
if (isZapStreamWatchUrl(url)) return if (isZapStreamWatchUrl(url)) return
@ -5380,7 +5523,7 @@ export default function MarkdownArticle({
// Add metadata image if it exists // Add metadata image if it exists
if (metadata.image) { if (metadata.image) {
const cleaned = cleanUrl(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) seenUrls.add(cleaned)
images.push({ url: metadata.image }) images.push({ url: metadata.image })
} }
@ -5422,6 +5565,9 @@ export default function MarkdownArticle({
const pathname = parsed.pathname const pathname = parsed.pathname
// Extract the filename (last segment of the path) // Extract the filename (last segment of the path)
const filename = pathname.split('/').pop() || '' 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 // If the filename looks like a hash (hex string), use it for comparison
// Also use the full pathname as a fallback // Also use the full pathname as a fallback
if (filename && /^[a-f0-9]{32,}\.(png|jpg|jpeg|gif|webp|svg)$/i.test(filename)) { 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) { while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0] const url = match[0]
const cleaned = cleanUrl(url) 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) urls.add(cleaned)
// Also add image identifier for filename-based matching // Also add image identifier for filename-based matching
const identifier = getImageIdentifier(cleaned) const identifier = getImageIdentifier(cleaned)

3
src/components/PostEditor/PostContent.tsx

@ -48,7 +48,7 @@ import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' 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 logger from '@/lib/logger'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
@ -1531,6 +1531,7 @@ export default function PostContent({
} }
const inferKindFromEditorMediaUrl = (url: string): number | null => { const inferKindFromEditorMediaUrl = (url: string): number | null => {
if (isBlossomBudBlobUrl(url)) return ExtendedKind.PICTURE
const path = url.split(/[?#]/)[0].toLowerCase() const path = url.split(/[?#]/)[0].toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|heic|avif|apng)$/i.test(path)) return ExtendedKind.PICTURE 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 if (/\.(mp3|m4a|mka|ogg|opus|wav|aac|flac)$/i.test(path)) return ExtendedKind.VOICE

24
src/constants.ts

@ -246,11 +246,25 @@ export const METADATA_BATCH_AUTHORS_CHUNK = 22
*/ */
export const PROFILE_FETCH_PROMISE_TIMEOUT_MS = 20000 export const PROFILE_FETCH_PROMISE_TIMEOUT_MS = 20000
export const RECOMMENDED_BLOSSOM_SERVERS = [ /**
'https://blossom.band', * Public Blossom (BUD) upload bases: presets in post settings and merged after the users
'https://blossom.primal.net', * kind-10063 URLs when resolving the default Blossom server list.
'https://nostr.media' * @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 = { export const StorageKey = {
VERSION: 'version', VERSION: 'version',

14
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.", "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", "Publish successful": "Veröffentlichung erfolgreich",
"Media upload service": "Medien-Upload-Service", "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", "Choose a relay": "Wähle ein Relay",
"no relays found": "Keine Relays gefunden", "no relays found": "Keine Relays gefunden",
video: "Video", video: "Video",

14
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.", "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", "Publish successful": "Publish successful",
"Media upload service": "Media upload service", "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", "Choose a relay": "Choose a relay",
"no relays found": "no relays found", "no relays found": "no relays found",
video: "video", video: "video",

4
src/lib/content-parser.ts

@ -12,7 +12,7 @@ import {
} from '@/lib/content-patterns' } from '@/lib/content-patterns'
import { PAYTO_URI_REGEX } from '@/lib/payto' import { PAYTO_URI_REGEX } from '@/lib/payto'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' 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 { isSpotifyOpenUrl } from './spotify-url'
import { isZapStreamWatchUrl } from './zap-stream-url' import { isZapStreamWatchUrl } from './zap-stream-url'
@ -111,6 +111,8 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
let type: TEmbeddedNodeType = 'url' let type: TEmbeddedNodeType = 'url'
if (isImage(url)) { if (isImage(url)) {
type = 'image' type = 'image'
} else if (isBlossomBudBlobUrl(url)) {
type = 'image'
} else if (isHlsPlaylistUrl(url)) { } else if (isHlsPlaylistUrl(url)) {
type = 'media' type = 'media'
} else if (isMedia(url)) { } else if (isMedia(url)) {

11
src/lib/draft-event.ts

@ -32,7 +32,7 @@ import {
NIP22_URL_SCOPE_KIND NIP22_URL_SCOPE_KIND
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns' 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 { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip'
import { randomString } from './random' import { randomString } from './random'
import { generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag' import { generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag'
@ -195,6 +195,7 @@ export function collectUploadImetaTagsForContentUrls(content: string): string[][
const keys = [raw] const keys = [raw]
const c = cleanUrl(raw) const c = cleanUrl(raw)
if (c && c !== raw) keys.push(c) if (c && c !== raw) keys.push(c)
let fromUpload = false
for (const key of keys) { for (const key of keys) {
const tag = mediaUpload.getImetaTagByUrl(key) const tag = mediaUpload.getImetaTagByUrl(key)
if (tag) { if (tag) {
@ -203,9 +204,17 @@ export function collectUploadImetaTagsForContentUrls(content: string): string[][
seen.add(u) seen.add(u)
out.push(tag) out.push(tag)
} }
fromUpload = true
break 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 return out
} }

3
src/lib/tag.ts

@ -1,5 +1,5 @@
import { TEmoji, TImetaInfo } from '@/types' import { TEmoji, TImetaInfo } from '@/types'
import { cleanUrl, isImage, isMedia } from './url' import { cleanUrl, isImage, isMedia, isBlossomBudBlobUrl } from './url'
import { isBlurhashValid } from 'blurhash' import { isBlurhashValid } from 'blurhash'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { isValidPubkey } from './pubkey' import { isValidPubkey } from './pubkey'
@ -137,6 +137,7 @@ export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImeta
if ( if (
isImage(t) || isImage(t) ||
isMedia(t) || isMedia(t) ||
isBlossomBudBlobUrl(t) ||
(mimeHint && (mimeHint &&
(mimeHint.startsWith('image/') || (mimeHint.startsWith('image/') ||
mimeHint.startsWith('video/') || mimeHint.startsWith('video/') ||

24
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) { export function isAudio(url: string) {
try { try {
const path = new URL(url).pathname.toLowerCase() const path = new URL(url).pathname.toLowerCase()

14
src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx

@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator' 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 { createBlossomServerListDraftEvent } from '@/lib/draft-event'
import { getServersFromServerTags } from '@/lib/tag' import { getServersFromServerTags } from '@/lib/tag'
import { normalizeHttpUrl } from '@/lib/url' import { normalizeHttpUrl } from '@/lib/url'
@ -185,6 +185,18 @@ export default function BlossomServerListSetting() {
{t('Add')} {t('Add')}
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground leading-snug pt-1">
<a
href={LOTUS_BLOSSOM_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:no-underline"
>
{t('Lotus on GitHub')}
</a>
{' — '}
{t('BlossomSelfHostLotusHint')}
</p>
</div> </div>
) )
} }

82
src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx

@ -6,8 +6,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import { DEFAULT_NIP_96_SERVICE, NIP_96_SERVICE } from '@/constants' import {
import { simplifyUrl } from '@/lib/url' 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 { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -15,6 +19,19 @@ import BlossomServerListSetting from './BlossomServerListSetting'
const BLOSSOM = 'blossom' 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() { export default function MediaUploadServiceSetting() {
const { t } = useTranslation() const { t } = useTranslation()
const { serviceConfig, updateServiceConfig } = useMediaUploadService() const { serviceConfig, updateServiceConfig } = useMediaUploadService()
@ -22,6 +39,9 @@ export default function MediaUploadServiceSetting() {
if (serviceConfig.type === 'blossom') { if (serviceConfig.type === 'blossom') {
return BLOSSOM return BLOSSOM
} }
if (serviceConfig.type === 'blossom-preset') {
return blossomPresetSelectValue(serviceConfig.url)
}
return serviceConfig.service return serviceConfig.service
}, [serviceConfig]) }, [serviceConfig])
@ -29,22 +49,48 @@ export default function MediaUploadServiceSetting() {
if (value === BLOSSOM) { if (value === BLOSSOM) {
return updateServiceConfig({ type: '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 }) 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="media-upload-service-select">{t('Media upload service')}</Label> <Label htmlFor="media-upload-service-select">{t('Media upload service')}</Label>
<Select <Select value={selectedValue} onValueChange={handleSelectedValueChange}>
defaultValue={DEFAULT_NIP_96_SERVICE} <SelectTrigger id="media-upload-service-select" className="w-full max-w-sm min-w-[12rem]">
value={selectedValue} <SelectValue placeholder={t('Media upload service')}>{selectTriggerLabel}</SelectValue>
onValueChange={handleSelectedValueChange}
>
<SelectTrigger id="media-upload-service-select" className="w-48">
<SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={BLOSSOM}>{t('Blossom')}</SelectItem> <SelectItem value={BLOSSOM}>{t('BlossomUploadYourListOption')}</SelectItem>
{STANDARD_BLOSSOM_UPLOAD_HOSTS.map(({ url, labelKey }) => (
<SelectItem key={url} value={blossomPresetSelectValue(url)}>
{t(labelKey)}
</SelectItem>
))}
{NIP_96_SERVICE.map((url) => ( {NIP_96_SERVICE.map((url) => (
<SelectItem key={url} value={url}> <SelectItem key={url} value={url}>
{simplifyUrl(url)} {simplifyUrl(url)}
@ -52,8 +98,22 @@ export default function MediaUploadServiceSetting() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{selectedValue === BLOSSOM ? (
<p className="text-xs text-muted-foreground leading-snug max-w-xl">{t('BlossomUploadServiceBlurb')}</p>
) : null}
{isBlossomPreset && serviceConfig.type === 'blossom-preset' ? (
<>
<p className="text-xs text-muted-foreground leading-snug max-w-xl">{t('BlossomPresetUploadServiceBlurb')}</p>
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-xs max-w-xl">
<div className="text-muted-foreground mb-0.5">{t('BlossomPresetSelectedHostLabel')}</div>
<div className="truncate font-mono text-foreground" title={serviceConfig.url}>
{serviceConfig.url}
</div>
</div>
</>
) : null}
{selectedValue === BLOSSOM && <BlossomServerListSetting />} {showBlossomServerList ? <BlossomServerListSetting /> : null}
</div> </div>
) )
} }

16
src/services/content-parser.service.ts

@ -9,7 +9,7 @@ import { getImetaInfosFromEvent } from '@/lib/event'
import { URL_REGEX, ExtendedKind } from '@/constants' import { URL_REGEX, ExtendedKind } from '@/constants'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isPseudoNostrHttpsUrl } from '@/lib/url' import { isPseudoNostrHttpsUrl, isBlossomBudBlobUrl } from '@/lib/url'
export interface ParsedContent { export interface ParsedContent {
html: string html: string
@ -803,13 +803,13 @@ class ContentParserService {
const rawUrls = content.match(URL_REGEX) || [] const rawUrls = content.match(URL_REGEX) || []
rawUrls.forEach(url => { rawUrls.forEach(url => {
if (!seenUrls.has(url)) { if (!seenUrls.has(url)) {
const isImage = /\.(jpeg|jpg|png|gif|webp|svg)$/i.test(url) const isImageExt = /\.(jpeg|jpg|png|gif|webp|svg)$/i.test(url)
const isVideo = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url) const isVideoExt = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url)
if (isImage || isVideo) { if (isImageExt || isVideoExt || isBlossomBudBlobUrl(url)) {
media.push({ media.push({
url, url,
pubkey: event?.pubkey || '', pubkey: event?.pubkey || '',
m: isVideo ? 'video/*' : 'image/*' m: isVideoExt ? 'video/*' : 'image/*'
}) })
seenUrls.add(url) seenUrls.add(url)
} }

13
src/services/local-storage.service.ts

@ -851,7 +851,18 @@ class LocalStorageService {
if (!pubkey) { if (!pubkey) {
return defaultConfig 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( setMediaUploadServiceConfig(

37
src/services/media-extraction.service.ts

@ -1,14 +1,23 @@
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event' 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). */ /** Any URL we may embed or extract from note bodies (incl. video-only extensions like .3gp, HLS manifests). */
function isEmbeddableMediaUrl(cleaned: string): boolean { 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 { export interface ExtractedMedia {
images: TImetaInfo[] images: TImetaInfo[]
@ -44,6 +53,8 @@ export function extractAllMediaFromEvent(
if (!mime) { if (!mime) {
if (isImage(cleaned)) { if (isImage(cleaned)) {
mime = 'image/*' mime = 'image/*'
} else if (isBlossomBudBlobUrl(cleaned)) {
mime = 'image/*'
} else if (isHlsPlaylistUrl(cleaned)) { } else if (isHlsPlaylistUrl(cleaned)) {
mime = 'video/*' mime = 'video/*'
} else if (isAudio(cleaned)) { } else if (isAudio(cleaned)) {
@ -157,6 +168,10 @@ export function extractAllMediaFromEvent(
try { try {
const u = cleanUrl(url) const u = cleanUrl(url)
if (!u) return null if (!u) return null
const blossom = blossomSha256FromBlobUrl(u)
if (blossom) {
return `blossom-sha256:${blossom}`
}
const pathname = new URL(u).pathname const pathname = new URL(u).pathname
const filename = pathname.split('/').pop() || '' const filename = pathname.split('/').pop() || ''
if (filename && /^[a-f0-9]{32,}\.(png|jpg|jpeg|gif|webp|svg|avif|apng)$/i.test(filename)) { 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) => { imetaInfos.forEach((imeta) => {
const imetaUrl = cleanUrl(imeta.url) const imetaUrl = cleanUrl(imeta.url)
const imetaKey = imetaUrl ? imageIdentityKey(imetaUrl) : null 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) => { allMedia.forEach((media, index) => {
if (imetaUrl && imetaUrl === media.url) { if (imetaUrl && imetaUrl === media.url) {
allMedia[index] = { ...media, ...imeta, url: media.url } allMedia[index] = { ...media, ...imeta, url: media.url }
} else if (imetaKey && imetaKey === imageIdentityKey(media.url)) { } else if (imetaKey && imetaKey === imageIdentityKey(media.url)) {
allMedia[index] = { ...media, ...imeta, url: media.url } allMedia[index] = { ...media, ...imeta, url: media.url }
} else if (imetaKeyFromX && imetaKeyFromX === imageIdentityKey(media.url)) {
allMedia[index] = { ...media, ...imeta, url: media.url }
} else { } else {
// Try to get imeta from media upload service // Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(media.url) const tag = mediaUpload.getImetaTagByUrl(media.url)
@ -201,6 +220,14 @@ export function extractAllMediaFromEvent(
videos.push(media) videos.push(media)
} else if (media.m?.startsWith('audio/') || isAudio(media.url)) { } else if (media.m?.startsWith('audio/') || isAudio(media.url)) {
audio.push(media) 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 { } else {
// Fallback: try to determine by URL extension // Fallback: try to determine by URL extension
if (isImage(media.url)) { if (isImage(media.url)) {

5
src/services/media-upload.service.ts

@ -163,7 +163,10 @@ class MediaUploadService {
} }
startPseudoProgress() 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) { if (servers.length === 0) {
throw new Error('No Blossom services available') throw new Error('No Blossom services available')
} }

5
src/types/index.d.ts vendored

@ -230,6 +230,11 @@ export type TMediaUploadServiceConfig =
| { | {
type: 'blossom' 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] export type TPollType = (typeof POLL_TYPE)[keyof typeof POLL_TYPE]

Loading…
Cancel
Save