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 { @@ -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({ @@ -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({ @@ -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' })
}

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

@ -20,7 +20,8 @@ import { @@ -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( @@ -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( @@ -2092,7 +2093,7 @@ function parseMarkdownContentLegacy(
}
}
if (isImage(cleaned)) {
if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
parts.push(
<div key={`img-${patternIdx}`} className="my-2 block max-w-[400px]">
<Image
@ -2135,7 +2136,7 @@ function parseMarkdownContentLegacy( @@ -2135,7 +2136,7 @@ function parseMarkdownContentLegacy(
const imageUrl = imageMatch[2]
const cleaned = cleanUrl(imageUrl)
if (isImage(cleaned)) {
if (isImage(cleaned) || isBlossomBudBlobUrl(cleaned)) {
// Check if there's a thumbnail available for this image
let thumbnailUrl: string | undefined
if (imageThumbnailMap) {
@ -2774,7 +2775,7 @@ function parseMarkdownContentLegacy( @@ -2774,7 +2775,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)
@ -3262,6 +3263,38 @@ function parseMarkdownContentMarked( @@ -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 footnotes = new Map<string, string>()
const citations: Array<{ id: string; type: string; citationId: string }> = []
@ -3305,6 +3338,22 @@ function parseMarkdownContentMarked( @@ -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( @@ -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(
<PaytoLink
key={`${key}-payto`}
@ -3382,7 +3432,57 @@ function parseMarkdownContentMarked( @@ -3382,7 +3432,57 @@ function parseMarkdownContentMarked(
{children}
</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 {
const children = stripNestedAnchorsFromNodes(
renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${key}-link`),
`${key}-link-sanitized`
)
out.push(
<a
key={`${key}-href`}
@ -3422,7 +3522,25 @@ function parseMarkdownContentMarked( @@ -3422,7 +3522,25 @@ function parseMarkdownContentMarked(
)
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(
<span key={`${key}-img-fallback`} className="break-words">
{label || src}
@ -3513,7 +3631,7 @@ function parseMarkdownContentMarked( @@ -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( @@ -3688,8 +3806,8 @@ function parseMarkdownContentMarked(
</div>
)
}
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( @@ -3861,8 +3979,8 @@ function parseMarkdownContentMarked(
</div>
)
}
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( @@ -4241,7 +4359,24 @@ function parseMarkdownContentMarked(
</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 (
<div key={`${key}-img-inline-fallback`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}>
{renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)}
@ -5217,12 +5352,18 @@ export default function MarkdownArticle({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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)

3
src/components/PostEditor/PostContent.tsx

@ -48,7 +48,7 @@ import { cn } from '@/lib/utils' @@ -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({ @@ -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

24
src/constants.ts

@ -246,11 +246,25 @@ export const METADATA_BATCH_AUTHORS_CHUNK = 22 @@ -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 users
* 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',

14
src/i18n/locales/de.ts

@ -545,6 +545,20 @@ export default { @@ -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",

14
src/i18n/locales/en.ts

@ -552,6 +552,20 @@ export default { @@ -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",

4
src/lib/content-parser.ts

@ -12,7 +12,7 @@ import { @@ -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) => { @@ -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)) {

11
src/lib/draft-event.ts

@ -32,7 +32,7 @@ import { @@ -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[][ @@ -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[][ @@ -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
}

3
src/lib/tag.ts

@ -1,5 +1,5 @@ @@ -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 @@ -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/') ||

24
src/lib/url.ts

@ -354,6 +354,30 @@ export function isMedia(url: string) { @@ -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()

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

@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button' @@ -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() { @@ -185,6 +185,18 @@ export default function BlossomServerListSetting() {
{t('Add')}
</Button>
</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>
)
}

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

@ -6,8 +6,12 @@ import { @@ -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' @@ -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() { @@ -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() { @@ -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 (
<div className="space-y-2">
<Label htmlFor="media-upload-service-select">{t('Media upload service')}</Label>
<Select
defaultValue={DEFAULT_NIP_96_SERVICE}
value={selectedValue}
onValueChange={handleSelectedValueChange}
>
<SelectTrigger id="media-upload-service-select" className="w-48">
<SelectValue />
<Select value={selectedValue} onValueChange={handleSelectedValueChange}>
<SelectTrigger id="media-upload-service-select" className="w-full max-w-sm min-w-[12rem]">
<SelectValue placeholder={t('Media upload service')}>{selectTriggerLabel}</SelectValue>
</SelectTrigger>
<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) => (
<SelectItem key={url} value={url}>
{simplifyUrl(url)}
@ -52,8 +98,22 @@ export default function MediaUploadServiceSetting() { @@ -52,8 +98,22 @@ export default function MediaUploadServiceSetting() {
))}
</SelectContent>
</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>
)
}

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

@ -9,7 +9,7 @@ import { getImetaInfosFromEvent } from '@/lib/event' @@ -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 { @@ -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)
}

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

@ -851,7 +851,18 @@ class LocalStorageService { @@ -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(

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

@ -1,14 +1,23 @@ @@ -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( @@ -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( @@ -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( @@ -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( @@ -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)) {

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

@ -163,7 +163,10 @@ class MediaUploadService { @@ -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')
}

5
src/types/index.d.ts vendored

@ -230,6 +230,11 @@ export type TMediaUploadServiceConfig = @@ -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]

Loading…
Cancel
Save