feat: 🌸

This commit is contained in:
codytseng
2025-07-18 23:25:47 +08:00
parent 74e04e1c7d
commit e91b2648cc
41 changed files with 756 additions and 92 deletions

135
package-lock.json generated
View File

@@ -33,6 +33,7 @@
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@webbtc/webln-types": "^3.0.0",
"blossom-client-sdk": "^4.1.0",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -1573,6 +1574,56 @@
"node": ">=6.9.0"
}
},
"node_modules/@cashu/cashu-ts": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.5.2.tgz",
"integrity": "sha512-AjfDOZKb3RWWhmpHABC4KJxwJs3wp6eOFg6U3S6d3QOqtSoNkceMTn6lLN4/bYQarLR19rysbrIJ8MHsSwNxeQ==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"@scure/bip32": "^1.5.0",
"buffer": "^6.0.3"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.2.tgz",
"integrity": "sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.9.0",
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz",
@@ -2381,9 +2432,10 @@
}
},
"node_modules/@noble/hashes": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz",
"integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -4842,6 +4894,26 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -4853,6 +4925,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/blossom-client-sdk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/blossom-client-sdk/-/blossom-client-sdk-4.1.0.tgz",
"integrity": "sha512-IEjX3/e6EYnEonlog8qbd1/7qYIatOKEAQMWGkPCPjTO/b9fsrSnoELwOam52a5U3M83XLvYFhf6qE9MmlmJuQ==",
"license": "MIT",
"dependencies": {
"@cashu/cashu-ts": "^2.4.3",
"@noble/hashes": "^1.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
@@ -4911,6 +4996,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -6771,6 +6880,26 @@
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",

View File

@@ -43,6 +43,7 @@
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@webbtc/webln-types": "^3.0.0",
"blossom-client-sdk": "^4.1.0",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -46,7 +46,7 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
])
const imageInfos = event.tags
.map((tag) => extractImageInfoFromTag(tag))
.map((tag) => extractImageInfoFromTag(tag, event.pubkey))
.filter(Boolean) as TImageInfo[]
const allImages = nodes
.map((node) => {
@@ -56,13 +56,15 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
return imageInfo
}
const tag = mediaUpload.getImetaTagByUrl(node.data)
return tag ? extractImageInfoFromTag(tag) : { url: node.data }
return tag
? extractImageInfoFromTag(tag, event.pubkey)
: { url: node.data, pubkey: event.pubkey }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imageInfos.find((image) => image.url === url)
return imageInfo ?? { url }
return imageInfo ?? { url, pubkey: event.pubkey }
})
}
return null

View File

@@ -1,12 +1,14 @@
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { TImageInfo } from '@/types'
import { getHashFromURL } from 'blossom-client-sdk'
import { decode } from 'blurhash'
import { ImageOff } from 'lucide-react'
import { HTMLAttributes, useEffect, useState } from 'react'
export default function Image({
image: { url, blurHash },
image: { url, blurHash, pubkey },
alt,
className = '',
classNames = {},
@@ -27,6 +29,8 @@ export default function Image({
const [displayBlurHash, setDisplayBlurHash] = useState(true)
const [blurDataUrl, setBlurDataUrl] = useState<string | null>(null)
const [hasError, setHasError] = useState(false)
const [imageUrl, setImageUrl] = useState(url)
const [tried, setTried] = useState(new Set())
useEffect(() => {
if (blurHash) {
@@ -49,12 +53,52 @@ export default function Image({
if (hideIfError && hasError) return null
const handleImageError = async () => {
let oldImageUrl: URL | undefined
let hash: string | null = null
try {
oldImageUrl = new URL(imageUrl)
hash = getHashFromURL(oldImageUrl)
} catch (error) {
console.error('Invalid image URL:', error)
}
if (!pubkey || !hash || !oldImageUrl) {
setIsLoading(false)
setHasError(true)
return
}
const ext = oldImageUrl.pathname.match(/\.\w+$/i)
setTried((prev) => new Set(prev.add(oldImageUrl.hostname)))
const blossomServerList = await client.fetchBlossomServerList(pubkey)
const urls = blossomServerList
.map((server) => {
try {
return new URL(server)
} catch (error) {
console.error('Invalid Blossom server URL:', server, error)
return undefined
}
})
.filter((url) => !!url && !tried.has(url.hostname))
const nextUrl = urls[0]
if (!nextUrl) {
setIsLoading(false)
setHasError(true)
return
}
nextUrl.pathname = '/' + hash + ext
setImageUrl(nextUrl.toString())
}
return (
<div className={cn('relative', classNames.wrapper)} {...props}>
{isLoading && <Skeleton className={cn('absolute inset-0 rounded-lg', className)} />}
{!hasError ? (
<img
src={url}
src={imageUrl}
alt={alt}
className={cn(
'object-cover transition-opacity duration-300',
@@ -66,10 +110,7 @@ export default function Image({
setHasError(false)
setTimeout(() => setDisplayBlurHash(false), 500)
}}
onError={() => {
setIsLoading(false)
setHasError(true)
}}
onError={handleImageError}
/>
) : (
<div

View File

@@ -26,7 +26,7 @@ export default function CommunityDefinition({
<div className="flex gap-4">
{metadata.image && (
<Image
image={{ url: metadata.image }}
image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-square object-cover bg-foreground h-20"
hideIfError
/>

View File

@@ -28,7 +28,7 @@ export default function GroupMetadata({
<div className="flex gap-4">
{metadata.picture && (
<Image
image={{ url: metadata.picture }}
image={{ url: metadata.picture, pubkey: event.pubkey }}
className="rounded-lg aspect-square object-cover bg-foreground h-20"
hideIfError
/>

View File

@@ -41,7 +41,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
<div className={className}>
{metadata.image && (
<Image
image={{ url: metadata.image }}
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full aspect-video object-cover rounded-lg"
hideIfError
/>
@@ -62,7 +62,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
<div className="flex gap-4">
{metadata.image && (
<Image
image={{ url: metadata.image }}
image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
hideIfError
/>

View File

@@ -37,7 +37,7 @@ export default function LongFormArticle({
<div className={className}>
{metadata.image && (
<Image
image={{ url: metadata.image }}
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full aspect-video object-cover rounded-lg"
hideIfError
/>
@@ -57,7 +57,7 @@ export default function LongFormArticle({
<div className="flex gap-4">
{metadata.image && (
<Image
image={{ url: metadata.image }}
image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
hideIfError
/>

View File

@@ -41,7 +41,7 @@ export function ReactionNotification({
if (emojiUrl) {
return (
<Image
image={{ url: emojiUrl }}
image={{ url: emojiUrl, pubkey: notification.pubkey }}
alt={emojiName}
className="w-6 h-6"
classNames={{ errorPlaceholder: 'bg-transparent' }}

View File

@@ -24,7 +24,7 @@ export default function ProfileBanner({
return (
<Image
image={{ url: bannerUrl }}
image={{ url: bannerUrl, pubkey }}
alt={`${pubkey} banner`}
className={className}
onError={() => setBannerUrl(defaultBanner)}

View File

@@ -16,6 +16,7 @@ const buttonVariants = cva(
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-primary',
ghost: 'clickable hover:text-accent-foreground',
'ghost-destructive': 'cursor-pointer hover:bg-destructive/10 text-destructive',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {

View File

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'flex h-9 w-full rounded-lg border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}

View File

@@ -12,6 +12,8 @@ export const DEFAULT_FAVORITE_RELAYS = [
export const RECOMMENDED_RELAYS = DEFAULT_FAVORITE_RELAYS.concat(['wss://yabu.me/'])
export const RECOMMENDED_BLOSSOM_SERVERS = ['https://blossom.band/', 'https://nostr.download/']
export const StorageKey = {
VERSION: 'version',
THEME_SETTING: 'themeSetting',
@@ -26,12 +28,13 @@ export const StorageKey = {
QUICK_ZAP: 'quickZap',
LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap',
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService',
AUTOPLAY: 'autoplay',
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions',
HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications',
TRANSLATION_SERVICE_CONFIG_MAP: 'translationServiceConfigMap',
MEDIA_UPLOAD_SERVICE_CONFIG_MAP: 'mediaUploadServiceConfigMap',
HIDE_UNTRUSTED_NOTES: 'hideUntrustedNotes',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
@@ -59,8 +62,9 @@ export const GROUP_METADATA_EVENT_KIND = 39000
export const ExtendedKind = {
PICTURE: 20,
FAVORITE_RELAYS: 10012,
COMMENT: 1111,
FAVORITE_RELAYS: 10012,
BLOSSOM_SERVER_LIST: 10063,
GROUP_METADATA: 39000
}

View File

@@ -287,6 +287,12 @@ export default {
'Live event': 'حدث مباشر',
Article: 'مقالة',
Unfavorite: 'إلغاء المفضلة',
'Recommended relays': 'الريلايات الموصى بها'
'Recommended relays': 'الريلايات الموصى بها',
'Blossom server URLs': 'عناوين خوادم Blossom',
'You need to add at least one blossom server in order to upload media files.':
'تحتاج إلى إضافة خادم Blossom واحد على الأقل لتحميل ملفات الوسائط.',
'Recommended blossom servers': 'خوادم Blossom الموصى بها',
'Enter Blossom server URL': 'أدخل عنوان خادم Blossom URL',
Preferred: 'المفضل'
}
}

View File

@@ -294,6 +294,12 @@ export default {
'Live event': 'Live-Event',
Article: 'Artikel',
Unfavorite: 'Nicht mehr favorisieren',
'Recommended relays': 'Empfohlene Relays'
'Recommended relays': 'Empfohlene Relays',
'Blossom server URLs': 'Blossom-Server-URLs',
'You need to add at least one blossom server in order to upload media files.':
'Du musst mindestens einen Blossom-Server hinzufügen, um Mediendateien hochladen zu können.',
'Recommended blossom servers': 'Empfohlene Blossom-Server',
'Enter Blossom server URL': 'Blossom-Server-URL eingeben',
Preferred: 'Bevorzugt'
}
}

View File

@@ -287,6 +287,12 @@ export default {
'Live event': 'Live event',
Article: 'Article',
Unfavorite: 'Unfavorite',
'Recommended relays': 'Recommended relays'
'Recommended relays': 'Recommended relays',
'Blossom server URLs': 'Blossom server URLs',
'You need to add at least one blossom server in order to upload media files.':
'You need to add at least one blossom server in order to upload media files.',
'Recommended blossom servers': 'Recommended blossom servers',
'Enter Blossom server URL': 'Enter Blossom server URL',
Preferred: 'Preferred'
}
}

View File

@@ -292,6 +292,12 @@ export default {
'Live event': 'Evento en vivo',
Article: 'Artículo',
Unfavorite: 'Desfavoritar',
'Recommended relays': 'Relés recomendados'
'Recommended relays': 'Relés recomendados',
'Blossom server URLs': 'URLs del servidor Blossom',
'You need to add at least one blossom server in order to upload media files.':
'Necesitas agregar al menos un servidor Blossom para poder cargar archivos multimedia.',
'Recommended blossom servers': 'Servidores Blossom recomendados',
'Enter Blossom server URL': 'Ingresar URL del servidor Blossom',
Preferred: 'Preferido'
}
}

View File

@@ -292,6 +292,12 @@ export default {
'Live event': 'Événement en direct',
Article: 'Article',
Unfavorite: 'Ne plus aimer',
'Recommended relays': 'Relais recommandés'
'Recommended relays': 'Relais recommandés',
'Blossom server URLs': 'URLs du serveur Blossom',
'You need to add at least one blossom server in order to upload media files.':
'Vous devez ajouter au moins un serveur Blossom pour pouvoir télécharger des fichiers multimédias.',
'Recommended blossom servers': 'Serveurs Blossom recommandés',
'Enter Blossom server URL': 'Entrer lURL du serveur Blossom',
Preferred: 'Préféré'
}
}

View File

@@ -291,6 +291,12 @@ export default {
'Live event': 'Evento dal vivo',
Article: 'Articolo',
Unfavorite: 'Rimuovi dai preferiti',
'Recommended relays': 'Relay consigliati'
'Recommended relays': 'Relay consigliati',
'Blossom server URLs': 'URL del server Blossom',
'You need to add at least one blossom server in order to upload media files.':
'È necessario aggiungere almeno un server Blossom per caricare file multimediali.',
'Recommended blossom servers': 'Server Blossom consigliati',
'Enter Blossom server URL': 'Inserisci URL del server Blossom',
Preferred: 'Preferito'
}
}

View File

@@ -289,6 +289,12 @@ export default {
'Live event': 'ライブイベント',
Article: '記事',
Unfavorite: 'お気に入り解除',
'Recommended relays': 'おすすめのリレイ'
'Recommended relays': 'おすすめのリレイ',
'Blossom server URLs': 'BlossomサーバーURL',
'You need to add at least one blossom server in order to upload media files.':
'メディアファイルをアップロードするには、少なくとも1つのBlossomサーバーを追加する必要があります。',
'Recommended blossom servers': 'おすすめのBlossomサーバー',
'Enter Blossom server URL': 'BlossomサーバーURLを入力',
Preferred: '優先'
}
}

View File

@@ -289,6 +289,12 @@ export default {
'Live event': '라이브 이벤트',
Article: '기사',
Unfavorite: '즐겨찾기 취소',
'Recommended relays': '추천 릴레이'
'Recommended relays': '추천 릴레이',
'Blossom server URLs': 'Blossom 서버 주소',
'You need to add at least one blossom server in order to upload media files.':
'미디어 파일을 업로드하려면 최소한 하나의 Blossom 서버를 추가해야 합니다.',
'Recommended blossom servers': '추천 Blossom 서버',
'Enter Blossom server URL': 'Blossom 서버 URL 입력',
Preferred: '선호'
}
}

View File

@@ -290,6 +290,12 @@ export default {
'Live event': 'Wydarzenie na żywo',
Article: 'Artykuł',
Unfavorite: 'Usuń z ulubionych',
'Recommended relays': 'Rekomendowane transmitery'
'Recommended relays': 'Rekomendowane transmitery',
'Blossom server URLs': 'Adresy serwerów Blossom',
'You need to add at least one blossom server in order to upload media files.':
'Musisz dodać przynajmniej jeden serwer Blossom, aby móc przesyłać pliki multimedialne.',
'Recommended blossom servers': 'Zalecane serwery Blossom',
'Enter Blossom server URL': 'Wprowadź adres URL serwera Blossom',
Preferred: 'Preferowany'
}
}

View File

@@ -290,6 +290,12 @@ export default {
'Live event': 'Evento ao vivo',
Article: 'Artigo',
Unfavorite: 'Desfavoritar',
'Recommended relays': 'Relés recomendados'
'Recommended relays': 'Relés recomendados',
'Blossom server URLs': 'URLs do servidor Blossom',
'You need to add at least one blossom server in order to upload media files.':
'Você precisa adicionar pelo menos um servidor Blossom para poder carregar arquivos de mídia.',
'Recommended blossom servers': 'Servidores Blossom recomendados',
'Enter Blossom server URL': 'Inserir URL do servidor Blossom',
Preferred: 'Preferido'
}
}

View File

@@ -291,6 +291,12 @@ export default {
'Live event': 'Evento ao vivo',
Article: 'Artigo',
Unfavorite: 'Desfavoritar',
'Recommended relays': 'Relés recomendados'
'Recommended relays': 'Relés recomendados',
'Blossom server URLs': 'URLs do servidor Blossom',
'You need to add at least one blossom server in order to upload media files.':
'Você precisa adicionar pelo menos um servidor Blossom para poder carregar arquivos de mídia.',
'Recommended blossom servers': 'Servidores Blossom recomendados',
'Enter Blossom server URL': 'Inserir URL do servidor Blossom',
Preferred: 'Preferido'
}
}

View File

@@ -292,6 +292,12 @@ export default {
'Live event': 'Живое событие',
Article: 'Статья',
Unfavorite: 'Убрать из избранного',
'Recommended relays': 'Рекомендуемые ретрансляторы'
'Recommended relays': 'Рекомендуемые ретрансляторы',
'Blossom server URLs': 'URLs сервера Blossom',
'You need to add at least one blossom server in order to upload media files.':
'Вам нужно добавить хотя бы один сервер Blossom, чтобы загружать медиафайлы.',
'Recommended blossom servers': 'Рекомендуемые серверы Blossom',
'Enter Blossom server URL': 'Введите URL сервера Blossom',
Preferred: 'Предпочтительный'
}
}

View File

@@ -286,6 +286,12 @@ export default {
'Live event': 'เหตุการณ์สด',
Article: 'บทความ',
Unfavorite: 'เลิกชื่นชอบ',
'Recommended relays': 'รีเลย์ที่แนะนำ'
'Recommended relays': 'รีเลย์ที่แนะนำ',
'Blossom server URLs': 'URL ของเซิร์ฟเวอร์ Blossom',
'You need to add at least one blossom server in order to upload media files.':
'คุณต้องเพิ่มเซิร์ฟเวอร์ Blossom อย่างน้อยหนึ่งตัวเพื่ออัปโหลดไฟล์สื่อ',
'Recommended blossom servers': 'เซิร์ฟเวอร์ Blossom ที่แนะนำ',
'Enter Blossom server URL': 'ป้อน URL ของเซิร์ฟเวอร์ Blossom',
Preferred: 'ที่ชื่นชอบ'
}
}

View File

@@ -287,6 +287,12 @@ export default {
'Live event': '直播',
Article: '文章',
Unfavorite: '取消收藏',
'Recommended relays': '推荐服务器'
'Recommended relays': '推荐服务器',
'Blossom server URLs': 'Blossom 服务器地址',
'You need to add at least one blossom server in order to upload media files.':
'您需要添加至少一个 Blossom 服务器才能上传媒体文件。',
'Recommended blossom servers': '推荐的 Blossom 服务器',
'Enter Blossom server URL': '输入 Blossom 服务器 URL',
Preferred: '首选'
}
}

View File

@@ -13,6 +13,7 @@ import {
isProtectedEvent,
isReplaceable
} from './event'
import { normalizeHttpUrl } from './url'
// https://github.com/nostr-protocol/nips/blob/master/25.md
export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent {
@@ -346,6 +347,15 @@ export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraft
}
}
export function createBlossomServerListDraftEvent(servers: string[]): TDraftEvent {
return {
kind: ExtendedKind.BLOSSOM_SERVER_LIST,
content: '',
tags: servers.map((server) => ['server', normalizeHttpUrl(server)]),
created_at: dayjs().unix()
}
}
function generateImetaTags(imageUrls: string[]) {
return imageUrls
.map((imageUrl) => {

View File

@@ -451,7 +451,7 @@ export function extractHashtags(content: string) {
export function extractImageInfosFromEventTags(event: Event) {
const images: TImageInfo[] = []
event.tags.forEach((tag) => {
const imageInfo = extractImageInfoFromTag(tag)
const imageInfo = extractImageInfoFromTag(tag, event.pubkey)
if (imageInfo) {
images.push(imageInfo)
}
@@ -588,6 +588,13 @@ export function extractEmojiInfosFromTags(tags: string[][] = []) {
.filter(Boolean) as TEmoji[]
}
export function extractServersFromTags(tags: string[][] = []) {
return tags
.filter(tagNameEquals('server'))
.map(([, url]) => (url ? normalizeHttpUrl(url) : ''))
.filter(Boolean)
}
export function createFakeEvent(event: Partial<Event>): Event {
return {
id: '',

View File

@@ -49,13 +49,13 @@ export function generateEventId(event: Pick<Event, 'id' | 'pubkey'>) {
return nip19.neventEncode({ id: event.id, author: event.pubkey, relays: [relay] })
}
export function extractImageInfoFromTag(tag: string[]): TImageInfo | null {
export function extractImageInfoFromTag(tag: string[], pubkey?: string): TImageInfo | null {
if (tag[0] !== 'imeta') return null
const urlItem = tag.find((item) => item.startsWith('url '))
const url = urlItem?.slice(4)
if (!url) return null
const image: TImageInfo = { url }
const image: TImageInfo = { url, pubkey }
const blurHashItem = tag.find((item) => item.startsWith('blurhash '))
const blurHash = blurHashItem?.slice(9)
if (blurHash) {

View File

@@ -48,7 +48,7 @@ export function normalizeHttpUrl(url: string): string {
return p.toString()
} catch {
console.error('Invalid URL:', url)
return url
return ''
}
}

View File

@@ -0,0 +1,177 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { RECOMMENDED_BLOSSOM_SERVERS } from '@/constants'
import { createBlossomServerListDraftEvent } from '@/lib/draft-event'
import { extractServersFromTags } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { AlertCircle, ArrowUpToLine, Loader, X } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function BlossomServerListSetting() {
const { t } = useTranslation()
const { pubkey, publish } = useNostr()
const [blossomServerListEvent, setBlossomServerListEvent] = useState<Event | null>(null)
const serverUrls = useMemo(() => {
return extractServersFromTags(blossomServerListEvent ? blossomServerListEvent.tags : [])
}, [blossomServerListEvent])
const [url, setUrl] = useState('')
const [removingIndex, setRemovingIndex] = useState(-1)
const [movingIndex, setMovingIndex] = useState(-1)
const [adding, setAdding] = useState(false)
useEffect(() => {
const init = async () => {
if (!pubkey) {
setBlossomServerListEvent(null)
return
}
const event = await client.fetchBlossomServerListEvent(pubkey)
setBlossomServerListEvent(event)
}
init()
}, [pubkey])
const addBlossomUrl = async (url: string) => {
if (!url || adding || removingIndex >= 0 || movingIndex >= 0) return
setAdding(true)
try {
const draftEvent = createBlossomServerListDraftEvent([...serverUrls, url])
const newEvent = await publish(draftEvent)
await client.updateBlossomServerListEventCache(newEvent)
setBlossomServerListEvent(newEvent)
setUrl('')
} catch (error) {
console.error('Failed to add Blossom URL:', error)
} finally {
setAdding(false)
}
}
const handleUrlInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
addBlossomUrl(url)
}
}
const removeBlossomUrl = async (idx: number) => {
if (removingIndex >= 0 || adding || movingIndex >= 0) return
setRemovingIndex(idx)
try {
const draftEvent = createBlossomServerListDraftEvent(serverUrls.filter((_, i) => i !== idx))
const newEvent = await publish(draftEvent)
await client.updateBlossomServerListEventCache(newEvent)
setBlossomServerListEvent(newEvent)
} catch (error) {
console.error('Failed to remove Blossom URL:', error)
} finally {
setRemovingIndex(-1)
}
}
const moveToTop = async (idx: number) => {
if (removingIndex >= 0 || adding || movingIndex >= 0 || idx === 0) return
setMovingIndex(idx)
try {
const newUrls = [serverUrls[idx], ...serverUrls.filter((_, i) => i !== idx)]
const draftEvent = createBlossomServerListDraftEvent(newUrls)
const newEvent = await publish(draftEvent)
await client.updateBlossomServerListEventCache(newEvent)
setBlossomServerListEvent(newEvent)
} catch (error) {
console.error('Failed to move Blossom URL to top:', error)
} finally {
setMovingIndex(-1)
}
}
return (
<div className="space-y-2">
<div className="text-sm font-medium">{t('Blossom server URLs')}</div>
{serverUrls.length === 0 && (
<div className="flex flex-col gap-1 text-sm border rounded-lg p-2 bg-muted text-muted-foreground">
<div className="font-medium flex gap-2 items-center">
<AlertCircle className="size-4" />
{t('You need to add at least one media server in order to upload media files.')}
</div>
<Separator className="bg-muted-foreground my-2" />
<div className="font-medium">{t('Recommended blossom servers')}:</div>
<div className="flex flex-col">
{RECOMMENDED_BLOSSOM_SERVERS.map((recommendedUrl) => (
<Button
variant="link"
key={recommendedUrl}
onClick={() => addBlossomUrl(recommendedUrl)}
disabled={removingIndex >= 0 || adding || movingIndex >= 0}
className="w-fit p-0 text-muted-foreground hover:text-foreground h-fit"
>
{recommendedUrl}
</Button>
))}
</div>
</div>
)}
{serverUrls.map((url, idx) => (
<div
key={url}
className={cn(
'flex items-center justify-between gap-2 pl-3 pr-1 py-1 border rounded-lg',
idx === 0 && 'border-primary'
)}
>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:underline"
>
{url}
</a>
<div className="flex items-center gap-2">
{idx > 0 ? (
<Button
variant="ghost"
size="icon"
onClick={() => moveToTop(idx)}
title={t('Move to top')}
disabled={removingIndex >= 0 || adding || movingIndex >= 0}
className="text-muted-foreground"
>
{movingIndex === idx ? <Loader className="animate-spin" /> : <ArrowUpToLine />}
</Button>
) : (
<Badge>{t('Preferred')}</Badge>
)}
<Button
variant="ghost-destructive"
size="icon"
onClick={() => removeBlossomUrl(idx)}
title={t('Remove')}
disabled={removingIndex >= 0 || adding || movingIndex >= 0}
>
{removingIndex === idx ? <Loader className="animate-spin" /> : <X />}
</Button>
</div>
</div>
))}
<div className="flex items-center gap-2">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t('Enter Blossom server URL')}
onKeyDown={handleUrlInputKeyDown}
/>
<Button type="button" onClick={() => addBlossomUrl(url)} title={t('Add')}>
{adding && <Loader className="animate-spin" />}
{t('Add')}
</Button>
</div>
</div>
)
}

View File

@@ -9,20 +9,42 @@ import {
import { DEFAULT_NIP_96_SERVICE, NIP_96_SERVICE } from '@/constants'
import { simplifyUrl } from '@/lib/url'
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import BlossomServerListSetting from './BlossomServerListSetting'
const BLOSSOM = 'blossom'
export default function MediaUploadServiceSetting() {
const { t } = useTranslation()
const { service, updateService } = useMediaUploadService()
const { serviceConfig, updateServiceConfig } = useMediaUploadService()
const selectedValue = useMemo(() => {
if (serviceConfig.type === 'blossom') {
return BLOSSOM
}
return serviceConfig.service
}, [serviceConfig])
const handleSelectedValueChange = (value: string) => {
if (value === BLOSSOM) {
return updateServiceConfig({ type: 'blossom' })
}
return updateServiceConfig({ type: 'nip96', service: value })
}
return (
<div className="space-y-2">
<Label htmlFor="media-upload-service-select">{t('Media upload service')}</Label>
<Select defaultValue={DEFAULT_NIP_96_SERVICE} value={service} onValueChange={updateService}>
<Select
defaultValue={DEFAULT_NIP_96_SERVICE}
value={selectedValue}
onValueChange={handleSelectedValueChange}
>
<SelectTrigger id="media-upload-service-select" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={BLOSSOM}>{t('Blossom')}</SelectItem>
{NIP_96_SERVICE.map((url) => (
<SelectItem key={url} value={url}>
{simplifyUrl(url)}
@@ -30,6 +52,8 @@ export default function MediaUploadServiceSetting() {
))}
</SelectContent>
</Select>
{selectedValue === BLOSSOM && <BlossomServerListSetting />}
</div>
)
}

View File

@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next'
const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { nsec, ncryptsec } = useNostr()
const { pubkey, nsec, ncryptsec } = useNostr()
const { push } = useSecondaryPage()
const [copiedNsec, setCopiedNsec] = useState(false)
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
@@ -63,6 +63,7 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</div>
<ChevronRight />
</SettingItem>
{!!pubkey && (
<SettingItem className="clickable" onClick={() => push(toPostSettings())}>
<div className="flex items-center gap-4">
<PencilLine />
@@ -70,6 +71,7 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</div>
<ChevronRight />
</SettingItem>
)}
{!!nsec && (
<SettingItem
className="clickable"

View File

@@ -1,9 +1,12 @@
import storage from '@/services/local-storage.service'
import mediaUpload from '@/services/media-upload.service'
import { createContext, useContext, useState } from 'react'
import { TMediaUploadServiceConfig } from '@/types'
import { createContext, useContext, useEffect, useState } from 'react'
import { useNostr } from './NostrProvider'
type TMediaUploadServiceContext = {
service: string
updateService: (service: string) => void
serviceConfig: TMediaUploadServiceConfig
updateServiceConfig: (service: TMediaUploadServiceConfig) => void
}
const MediaUploadServiceContext = createContext<TMediaUploadServiceContext | undefined>(undefined)
@@ -17,15 +20,27 @@ export const useMediaUploadService = () => {
}
export function MediaUploadServiceProvider({ children }: { children: React.ReactNode }) {
const [service, setService] = useState(mediaUpload.getService())
const { pubkey, startLogin } = useNostr()
const [serviceConfig, setServiceConfig] = useState(storage.getMediaUploadServiceConfig())
const updateService = (newService: string) => {
setService(newService)
mediaUpload.setService(newService)
useEffect(() => {
const serviceConfig = storage.getMediaUploadServiceConfig(pubkey)
setServiceConfig(serviceConfig)
mediaUpload.setServiceConfig(serviceConfig)
}, [pubkey])
const updateServiceConfig = (newService: TMediaUploadServiceConfig) => {
if (!pubkey) {
startLogin()
return
}
setServiceConfig(newService)
storage.setMediaUploadServiceConfig(pubkey, newService)
mediaUpload.setServiceConfig(newService)
}
return (
<MediaUploadServiceContext.Provider value={{ service, updateService }}>
<MediaUploadServiceContext.Provider value={{ serviceConfig, updateServiceConfig }}>
{children}
</MediaUploadServiceContext.Provider>
)

View File

@@ -210,7 +210,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.Contacts,
kinds.Mutelist,
kinds.BookmarkList,
ExtendedKind.FAVORITE_RELAYS
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST
],
authors: [account.pubkey]
},
@@ -226,6 +227,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList)
const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS)
const blossomServerListEvent = sortedEvents.find(
(e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
)
const notificationsSeenAtEvent = sortedEvents.find(
(e) =>
e.kind === kinds.Application &&
@@ -258,6 +262,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFavoriteRelaysEvent(favoriteRelaysEvent)
await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
}
if (blossomServerListEvent) {
await client.updateBlossomServerListEventCache(blossomServerListEvent)
}
const notificationsSeenAt = Math.max(
notificationsSeenAtEvent?.created_at ?? 0,
@@ -308,6 +315,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
}, [signer])
useEffect(() => {
if (account) {
client.pubkey = account.pubkey
} else {
client.pubkey = undefined
}
}, [account])
const hasNostrLoginHash = () => {
return window.location.hash && window.location.hash.startsWith('#nostr-login')
}
@@ -565,7 +580,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
})
}
}
if ([kinds.RelayList, kinds.Contacts, ExtendedKind.FAVORITE_RELAYS].includes(draftEvent.kind)) {
if (
[
kinds.RelayList,
kinds.Contacts,
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST
].includes(draftEvent.kind)
) {
additionalRelayUrls.push(...BIG_RELAY_URLS)
}

View File

@@ -1,5 +1,9 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/lib/event'
import {
extractServersFromTags,
getProfileFromProfileEvent,
getRelayListFromRelayListEvent
} from '@/lib/event'
import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { extractPubkeysFromEventTags } from '@/lib/tag'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
@@ -27,6 +31,7 @@ class ClientService extends EventTarget {
static instance: ClientService
signer?: ISigner
pubkey?: string
private currentRelayUrls: string[] = []
private pool: SimplePool
@@ -74,10 +79,14 @@ class ClientService extends EventTarget {
max: 2000,
fetchMethod: this._fetchFollowListEvent.bind(this)
})
private fetchFollowingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({
private followingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({
max: 10,
fetchMethod: this._fetchFollowingFavoriteRelays.bind(this)
})
private blossomServerListEventCache = new LRUCache<string, Promise<NEvent | null>>({
max: 1000,
fetchMethod: this._fetchBlossomServerListEvent.bind(this)
})
private userIndex = new FlexSearch.Index({
tokenize: 'forward'
@@ -816,7 +825,7 @@ class ClientService extends EventTarget {
}
async fetchFollowingFavoriteRelays(pubkey: string) {
return this.fetchFollowingFavoriteRelaysCache.fetch(pubkey)
return this.followingFavoriteRelaysCache.fetch(pubkey)
}
private async _fetchFollowingFavoriteRelays(pubkey: string) {
@@ -870,6 +879,47 @@ class ClientService extends EventTarget {
return fetchNewData()
}
async fetchBlossomServerList(pubkey: string) {
const evt = await this.blossomServerListEventCache.fetch(pubkey)
return evt ? extractServersFromTags(evt.tags) : []
}
async fetchBlossomServerListEvent(pubkey: string) {
return (await this.blossomServerListEventCache.fetch(pubkey)) ?? null
}
async updateBlossomServerListEventCache(evt: NEvent) {
this.blossomServerListEventCache.set(evt.pubkey, Promise.resolve(evt))
await indexedDb.putReplaceableEvent(evt)
}
private async _fetchBlossomServerListEvent(pubkey: string) {
const fetchNew = async () => {
const relayList = await this.fetchRelayList(pubkey)
const events = await this.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 5), {
authors: [pubkey],
kinds: [ExtendedKind.BLOSSOM_SERVER_LIST]
})
const blossomServerListEvent = events.sort((a, b) => b.created_at - a.created_at)[0]
if (!blossomServerListEvent) {
indexedDb.putNullReplaceableEvent(pubkey, ExtendedKind.BLOSSOM_SERVER_LIST)
return null
}
indexedDb.putReplaceableEvent(blossomServerListEvent)
return blossomServerListEvent
}
const storedBlossomServerListEvent = await indexedDb.getReplaceableEvent(
pubkey,
ExtendedKind.BLOSSOM_SERVER_LIST
)
if (storedBlossomServerListEvent) {
fetchNew()
return storedBlossomServerListEvent
}
return fetchNew()
}
updateFollowListCache(event: NEvent) {
this.followListCache.set(event.pubkey, Promise.resolve(event))
indexedDb.putReplaceableEvent(event)

View File

@@ -14,6 +14,7 @@ const StoreNames = {
FOLLOW_LIST_EVENTS: 'followListEvents',
MUTE_LIST_EVENTS: 'muteListEvents',
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
RELAY_INFO_EVENTS: 'relayInfoEvents',
FAVORITE_RELAYS: 'favoriteRelays',
@@ -37,7 +38,7 @@ class IndexedDbService {
init(): Promise<void> {
if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 5)
const request = window.indexedDB.open('jumble', 6)
request.onerror = (event) => {
reject(event)
@@ -80,6 +81,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.FOLLOWING_FAVORITE_RELAYS)) {
db.createObjectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.BLOSSOM_SERVER_LIST_EVENTS)) {
db.createObjectStore(StoreNames.BLOSSOM_SERVER_LIST_EVENTS, { keyPath: 'key' })
}
this.db = db
}
})
@@ -433,6 +437,8 @@ class IndexedDbService {
return StoreNames.FOLLOW_LIST_EVENTS
case kinds.Mutelist:
return StoreNames.MUTE_LIST_EVENTS
case ExtendedKind.BLOSSOM_SERVER_LIST:
return StoreNames.BLOSSOM_SERVER_LIST_EVENTS
case kinds.Relaysets:
return StoreNames.RELAY_SETS
case ExtendedKind.FAVORITE_RELAYS:
@@ -463,7 +469,11 @@ class IndexedDbService {
{ name: StoreNames.RELAY_LIST_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
{
name: StoreNames.FOLLOW_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 day
},
{
name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 7 // 7 days
}
]
const transaction = this.db!.transaction(

View File

@@ -5,6 +5,7 @@ import {
TAccount,
TAccountPointer,
TFeedInfo,
TMediaUploadServiceConfig,
TNoteListMode,
TRelaySet,
TThemeSetting,
@@ -30,6 +31,7 @@ class LocalStorageService {
private hideUntrustedNotifications: boolean = false
private hideUntrustedNotes: boolean = false
private translationServiceConfigMap: Record<string, TTranslationServiceConfig> = {}
private mediaUploadServiceConfigMap: Record<string, TMediaUploadServiceConfig> = {}
constructor() {
if (!LocalStorageService.instance) {
@@ -92,6 +94,7 @@ class LocalStorageService {
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
// deprecated
this.mediaUploadService =
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
@@ -123,6 +126,13 @@ class LocalStorageService {
this.translationServiceConfigMap = JSON.parse(translationServiceConfigMapStr)
}
const mediaUploadServiceConfigMapStr = window.localStorage.getItem(
StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP
)
if (mediaUploadServiceConfigMapStr) {
this.mediaUploadServiceConfigMap = JSON.parse(mediaUploadServiceConfigMapStr)
}
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@@ -264,15 +274,6 @@ class LocalStorageService {
)
}
getMediaUploadService() {
return this.mediaUploadService
}
setMediaUploadService(service: string) {
this.mediaUploadService = service
window.localStorage.setItem(StorageKey.MEDIA_UPLOAD_SERVICE, service)
}
getAutoplay() {
return this.autoplay
}
@@ -326,6 +327,26 @@ class LocalStorageService {
JSON.stringify(this.translationServiceConfigMap)
)
}
getMediaUploadServiceConfig(pubkey?: string | null): TMediaUploadServiceConfig {
const defaultConfig = { type: 'nip96', service: this.mediaUploadService } as const
if (!pubkey) {
return defaultConfig
}
return this.mediaUploadServiceConfigMap[pubkey] ?? defaultConfig
}
setMediaUploadServiceConfig(
pubkey: string,
config: TMediaUploadServiceConfig
): TMediaUploadServiceConfig {
this.mediaUploadServiceConfigMap[pubkey] = config
window.localStorage.setItem(
StorageKey.MEDIA_UPLOAD_SERVICE_CONFIG_MAP,
JSON.stringify(this.mediaUploadServiceConfigMap)
)
return config
}
}
const instance = new LocalStorageService()

View File

@@ -1,4 +1,6 @@
import { simplifyUrl } from '@/lib/url'
import { TDraftEvent, TMediaUploadServiceConfig } from '@/types'
import { BlossomClient } from 'blossom-client-sdk'
import dayjs from 'dayjs'
import { kinds } from 'nostr-tools'
import { z } from 'zod'
@@ -8,8 +10,8 @@ import storage from './local-storage.service'
class MediaUploadService {
static instance: MediaUploadService
private service: string = storage.getMediaUploadService()
private serviceUploadUrlMap = new Map<string, string | undefined>()
private serviceConfig: TMediaUploadServiceConfig = storage.getMediaUploadServiceConfig()
private nip96ServiceUploadUrlMap = new Map<string, string | undefined>()
private imetaTagMap = new Map<string, string[]>()
constructor() {
@@ -19,32 +21,81 @@ class MediaUploadService {
return MediaUploadService.instance
}
getService() {
return this.service
}
setService(service: string) {
this.service = service
storage.setMediaUploadService(service)
setServiceConfig(config: TMediaUploadServiceConfig) {
this.serviceConfig = config
}
async upload(file: File) {
let uploadUrl = this.serviceUploadUrlMap.get(this.service)
let result: { url: string; tags: string[][] }
if (this.serviceConfig.type === 'nip96') {
result = await this.uploadByNip96(this.serviceConfig.service, file)
} else {
result = await this.uploadByBlossom(file)
}
if (result.tags.length > 0) {
this.imetaTagMap.set(result.url, ['imeta', ...result.tags.map(([n, v]) => `${n} ${v}`)])
}
return result
}
private async uploadByBlossom(file: File) {
const pubkey = client.pubkey
const signer = async (draft: TDraftEvent) => {
if (!client.signer) {
throw new Error('You need to be logged in to upload media')
}
return client.signer.signEvent(draft)
}
if (!pubkey) {
throw new Error('You need to be logged in to upload media')
}
const servers = await client.fetchBlossomServerList(pubkey)
if (servers.length === 0) {
throw new Error('No Blossom services available')
}
const [mainServer, ...mirrorServers] = servers
const auth = await BlossomClient.createUploadAuth(signer, file, {
message: `Uploading ${file.name}`
})
// first upload blob to main server
const blob = await BlossomClient.uploadBlob(mainServer, file, { auth })
if (mirrorServers.length > 0) {
await Promise.allSettled(
mirrorServers.map((server) => BlossomClient.mirrorBlob(server, blob, { auth }))
)
}
let tags: string[][] = []
const parseResult = z.array(z.array(z.string())).safeParse((blob as any).nip94 ?? [])
if (parseResult.success) {
tags = parseResult.data
}
return { url: blob.url, tags }
}
private async uploadByNip96(service: string, file: File) {
let uploadUrl = this.nip96ServiceUploadUrlMap.get(service)
if (!uploadUrl) {
const response = await fetch(`${this.service}/.well-known/nostr/nip96.json`)
const response = await fetch(`${service}/.well-known/nostr/nip96.json`)
if (!response.ok) {
throw new Error(
`${simplifyUrl(this.service)} does not work, please try another service in your settings`
`${simplifyUrl(service)} does not work, please try another service in your settings`
)
}
const data = await response.json()
uploadUrl = data?.api_url
if (!uploadUrl) {
throw new Error(
`${simplifyUrl(this.service)} does not work, please try another service in your settings`
`${simplifyUrl(service)} does not work, please try another service in your settings`
)
}
this.serviceUploadUrlMap.set(this.service, uploadUrl)
this.nip96ServiceUploadUrlMap.set(service, uploadUrl)
}
const formData = new FormData()
@@ -67,8 +118,7 @@ class MediaUploadService {
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
const url = tags.find(([tagName]) => tagName === 'url')?.[1]
if (url) {
this.imetaTagMap.set(url, ['imeta', ...tags.map(([n, v]) => `${n} ${v}`)])
return { url: url, tags }
return { url, tags }
} else {
throw new Error('No url found')
}

View File

@@ -100,7 +100,12 @@ export type TFeedInfo = { feedType: TFeedType; id?: string }
export type TLanguage = 'en' | 'zh' | 'pl'
export type TImageInfo = { url: string; blurHash?: string; dim?: { width: number; height: number } }
export type TImageInfo = {
url: string
blurHash?: string
dim?: { width: number; height: number }
pubkey?: string
}
export type TNoteListMode = 'posts' | 'postsAndReplies' | 'pictures' | 'you'
@@ -137,3 +142,12 @@ export type TTranslationServiceConfig =
server?: string
api_key?: string
}
export type TMediaUploadServiceConfig =
| {
type: 'nip96'
service: string
}
| {
type: 'blossom'
}