feat: 🌸
This commit is contained in:
parent
74e04e1c7d
commit
e91b2648cc
41 changed files with 756 additions and 92 deletions
135
package-lock.json
generated
135
package-lock.json
generated
|
|
@ -33,6 +33,7 @@
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"@tiptap/suggestion": "^2.12.0",
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
"@webbtc/webln-types": "^3.0.0",
|
"@webbtc/webln-types": "^3.0.0",
|
||||||
|
"blossom-client-sdk": "^4.1.0",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -1573,6 +1574,56 @@
|
||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.24.0",
|
"version": "0.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz",
|
||||||
|
|
@ -2381,9 +2432,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "1.6.1",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
"integrity": "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==",
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.21.3 || >=16"
|
"node": "^14.21.3 || >=16"
|
||||||
},
|
},
|
||||||
|
|
@ -4842,6 +4894,26 @@
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
"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": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
|
@ -4853,6 +4925,19 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/blurhash": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
|
"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": "^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": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
|
@ -6771,6 +6880,26 @@
|
||||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"@tiptap/suggestion": "^2.12.0",
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
"@webbtc/webln-types": "^3.0.0",
|
"@webbtc/webln-types": "^3.0.0",
|
||||||
|
"blossom-client-sdk": "^4.1.0",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
||||||
])
|
])
|
||||||
|
|
||||||
const imageInfos = event.tags
|
const imageInfos = event.tags
|
||||||
.map((tag) => extractImageInfoFromTag(tag))
|
.map((tag) => extractImageInfoFromTag(tag, event.pubkey))
|
||||||
.filter(Boolean) as TImageInfo[]
|
.filter(Boolean) as TImageInfo[]
|
||||||
const allImages = nodes
|
const allImages = nodes
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
|
|
@ -56,13 +56,15 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
||||||
return imageInfo
|
return imageInfo
|
||||||
}
|
}
|
||||||
const tag = mediaUpload.getImetaTagByUrl(node.data)
|
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') {
|
if (node.type === 'images') {
|
||||||
const urls = Array.isArray(node.data) ? node.data : [node.data]
|
const urls = Array.isArray(node.data) ? node.data : [node.data]
|
||||||
return urls.map((url) => {
|
return urls.map((url) => {
|
||||||
const imageInfo = imageInfos.find((image) => image.url === url)
|
const imageInfo = imageInfos.find((image) => image.url === url)
|
||||||
return imageInfo ?? { url }
|
return imageInfo ?? { url, pubkey: event.pubkey }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import client from '@/services/client.service'
|
||||||
import { TImageInfo } from '@/types'
|
import { TImageInfo } from '@/types'
|
||||||
|
import { getHashFromURL } from 'blossom-client-sdk'
|
||||||
import { decode } from 'blurhash'
|
import { decode } from 'blurhash'
|
||||||
import { ImageOff } from 'lucide-react'
|
import { ImageOff } from 'lucide-react'
|
||||||
import { HTMLAttributes, useEffect, useState } from 'react'
|
import { HTMLAttributes, useEffect, useState } from 'react'
|
||||||
|
|
||||||
export default function Image({
|
export default function Image({
|
||||||
image: { url, blurHash },
|
image: { url, blurHash, pubkey },
|
||||||
alt,
|
alt,
|
||||||
className = '',
|
className = '',
|
||||||
classNames = {},
|
classNames = {},
|
||||||
|
|
@ -27,6 +29,8 @@ export default function Image({
|
||||||
const [displayBlurHash, setDisplayBlurHash] = useState(true)
|
const [displayBlurHash, setDisplayBlurHash] = useState(true)
|
||||||
const [blurDataUrl, setBlurDataUrl] = useState<string | null>(null)
|
const [blurDataUrl, setBlurDataUrl] = useState<string | null>(null)
|
||||||
const [hasError, setHasError] = useState(false)
|
const [hasError, setHasError] = useState(false)
|
||||||
|
const [imageUrl, setImageUrl] = useState(url)
|
||||||
|
const [tried, setTried] = useState(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (blurHash) {
|
if (blurHash) {
|
||||||
|
|
@ -49,12 +53,52 @@ export default function Image({
|
||||||
|
|
||||||
if (hideIfError && hasError) return null
|
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 (
|
return (
|
||||||
<div className={cn('relative', classNames.wrapper)} {...props}>
|
<div className={cn('relative', classNames.wrapper)} {...props}>
|
||||||
{isLoading && <Skeleton className={cn('absolute inset-0 rounded-lg', className)} />}
|
{isLoading && <Skeleton className={cn('absolute inset-0 rounded-lg', className)} />}
|
||||||
{!hasError ? (
|
{!hasError ? (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={imageUrl}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className={cn(
|
className={cn(
|
||||||
'object-cover transition-opacity duration-300',
|
'object-cover transition-opacity duration-300',
|
||||||
|
|
@ -66,10 +110,7 @@ export default function Image({
|
||||||
setHasError(false)
|
setHasError(false)
|
||||||
setTimeout(() => setDisplayBlurHash(false), 500)
|
setTimeout(() => setDisplayBlurHash(false), 500)
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={handleImageError}
|
||||||
setIsLoading(false)
|
|
||||||
setHasError(true)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export default function CommunityDefinition({
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{metadata.image && (
|
{metadata.image && (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: metadata.image }}
|
image={{ url: metadata.image, pubkey: event.pubkey }}
|
||||||
className="rounded-lg aspect-square object-cover bg-foreground h-20"
|
className="rounded-lg aspect-square object-cover bg-foreground h-20"
|
||||||
hideIfError
|
hideIfError
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export default function GroupMetadata({
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{metadata.picture && (
|
{metadata.picture && (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: metadata.picture }}
|
image={{ url: metadata.picture, pubkey: event.pubkey }}
|
||||||
className="rounded-lg aspect-square object-cover bg-foreground h-20"
|
className="rounded-lg aspect-square object-cover bg-foreground h-20"
|
||||||
hideIfError
|
hideIfError
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{metadata.image && (
|
{metadata.image && (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: metadata.image }}
|
image={{ url: metadata.image, pubkey: event.pubkey }}
|
||||||
className="w-full aspect-video object-cover rounded-lg"
|
className="w-full aspect-video object-cover rounded-lg"
|
||||||
hideIfError
|
hideIfError
|
||||||
/>
|
/>
|
||||||
|
|
@ -62,7 +62,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{metadata.image && (
|
{metadata.image && (
|
||||||
<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"
|
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
|
||||||
hideIfError
|
hideIfError
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export default function LongFormArticle({
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{metadata.image && (
|
{metadata.image && (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: metadata.image }}
|
image={{ url: metadata.image, pubkey: event.pubkey }}
|
||||||
className="w-full aspect-video object-cover rounded-lg"
|
className="w-full aspect-video object-cover rounded-lg"
|
||||||
hideIfError
|
hideIfError
|
||||||
/>
|
/>
|
||||||
|
|
@ -57,7 +57,7 @@ export default function LongFormArticle({
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{metadata.image && (
|
{metadata.image && (
|
||||||
<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"
|
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
|
||||||
hideIfError
|
hideIfError
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function ReactionNotification({
|
||||||
if (emojiUrl) {
|
if (emojiUrl) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: emojiUrl }}
|
image={{ url: emojiUrl, pubkey: notification.pubkey }}
|
||||||
alt={emojiName}
|
alt={emojiName}
|
||||||
className="w-6 h-6"
|
className="w-6 h-6"
|
||||||
classNames={{ errorPlaceholder: 'bg-transparent' }}
|
classNames={{ errorPlaceholder: 'bg-transparent' }}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export default function ProfileBanner({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: bannerUrl }}
|
image={{ url: bannerUrl, pubkey }}
|
||||||
alt={`${pubkey} banner`}
|
alt={`${pubkey} banner`}
|
||||||
className={className}
|
className={className}
|
||||||
onError={() => setBannerUrl(defaultBanner)}
|
onError={() => setBannerUrl(defaultBanner)}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const buttonVariants = cva(
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-primary',
|
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-primary',
|
||||||
ghost: 'clickable hover:text-accent-foreground',
|
ghost: 'clickable hover:text-accent-foreground',
|
||||||
|
'ghost-destructive': 'cursor-pointer hover:bg-destructive/10 text-destructive',
|
||||||
link: 'text-primary underline-offset-4 hover:underline'
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ export const DEFAULT_FAVORITE_RELAYS = [
|
||||||
|
|
||||||
export const RECOMMENDED_RELAYS = DEFAULT_FAVORITE_RELAYS.concat(['wss://yabu.me/'])
|
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 = {
|
export const StorageKey = {
|
||||||
VERSION: 'version',
|
VERSION: 'version',
|
||||||
THEME_SETTING: 'themeSetting',
|
THEME_SETTING: 'themeSetting',
|
||||||
|
|
@ -26,12 +28,13 @@ export const StorageKey = {
|
||||||
QUICK_ZAP: 'quickZap',
|
QUICK_ZAP: 'quickZap',
|
||||||
LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap',
|
LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap',
|
||||||
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
|
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
|
||||||
MEDIA_UPLOAD_SERVICE: 'mediaUploadService',
|
|
||||||
AUTOPLAY: 'autoplay',
|
AUTOPLAY: 'autoplay',
|
||||||
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions',
|
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions',
|
||||||
HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications',
|
HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications',
|
||||||
TRANSLATION_SERVICE_CONFIG_MAP: 'translationServiceConfigMap',
|
TRANSLATION_SERVICE_CONFIG_MAP: 'translationServiceConfigMap',
|
||||||
|
MEDIA_UPLOAD_SERVICE_CONFIG_MAP: 'mediaUploadServiceConfigMap',
|
||||||
HIDE_UNTRUSTED_NOTES: 'hideUntrustedNotes',
|
HIDE_UNTRUSTED_NOTES: 'hideUntrustedNotes',
|
||||||
|
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
||||||
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
|
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
|
||||||
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
||||||
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
|
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
|
||||||
|
|
@ -59,8 +62,9 @@ export const GROUP_METADATA_EVENT_KIND = 39000
|
||||||
|
|
||||||
export const ExtendedKind = {
|
export const ExtendedKind = {
|
||||||
PICTURE: 20,
|
PICTURE: 20,
|
||||||
FAVORITE_RELAYS: 10012,
|
|
||||||
COMMENT: 1111,
|
COMMENT: 1111,
|
||||||
|
FAVORITE_RELAYS: 10012,
|
||||||
|
BLOSSOM_SERVER_LIST: 10063,
|
||||||
GROUP_METADATA: 39000
|
GROUP_METADATA: 39000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,12 @@ export default {
|
||||||
'Live event': 'حدث مباشر',
|
'Live event': 'حدث مباشر',
|
||||||
Article: 'مقالة',
|
Article: 'مقالة',
|
||||||
Unfavorite: 'إلغاء المفضلة',
|
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: 'المفضل'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,12 @@ export default {
|
||||||
'Live event': 'Live-Event',
|
'Live event': 'Live-Event',
|
||||||
Article: 'Artikel',
|
Article: 'Artikel',
|
||||||
Unfavorite: 'Nicht mehr favorisieren',
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,12 @@ export default {
|
||||||
'Live event': 'Live event',
|
'Live event': 'Live event',
|
||||||
Article: 'Article',
|
Article: 'Article',
|
||||||
Unfavorite: 'Unfavorite',
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,12 @@ export default {
|
||||||
'Live event': 'Evento en vivo',
|
'Live event': 'Evento en vivo',
|
||||||
Article: 'Artículo',
|
Article: 'Artículo',
|
||||||
Unfavorite: 'Desfavoritar',
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,12 @@ export default {
|
||||||
'Live event': 'Événement en direct',
|
'Live event': 'Événement en direct',
|
||||||
Article: 'Article',
|
Article: 'Article',
|
||||||
Unfavorite: 'Ne plus aimer',
|
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 l’URL du serveur Blossom',
|
||||||
|
Preferred: 'Préféré'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,12 @@ export default {
|
||||||
'Live event': 'Evento dal vivo',
|
'Live event': 'Evento dal vivo',
|
||||||
Article: 'Articolo',
|
Article: 'Articolo',
|
||||||
Unfavorite: 'Rimuovi dai preferiti',
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,12 @@ export default {
|
||||||
'Live event': 'ライブイベント',
|
'Live event': 'ライブイベント',
|
||||||
Article: '記事',
|
Article: '記事',
|
||||||
Unfavorite: 'お気に入り解除',
|
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: '優先'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,12 @@ export default {
|
||||||
'Live event': '라이브 이벤트',
|
'Live event': '라이브 이벤트',
|
||||||
Article: '기사',
|
Article: '기사',
|
||||||
Unfavorite: '즐겨찾기 취소',
|
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: '선호'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,12 @@ export default {
|
||||||
'Live event': 'Wydarzenie na żywo',
|
'Live event': 'Wydarzenie na żywo',
|
||||||
Article: 'Artykuł',
|
Article: 'Artykuł',
|
||||||
Unfavorite: 'Usuń z ulubionych',
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,12 @@ export default {
|
||||||
'Live event': 'Evento ao vivo',
|
'Live event': 'Evento ao vivo',
|
||||||
Article: 'Artigo',
|
Article: 'Artigo',
|
||||||
Unfavorite: 'Desfavoritar',
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,12 @@ export default {
|
||||||
'Live event': 'Evento ao vivo',
|
'Live event': 'Evento ao vivo',
|
||||||
Article: 'Artigo',
|
Article: 'Artigo',
|
||||||
Unfavorite: 'Desfavoritar',
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,12 @@ export default {
|
||||||
'Live event': 'Живое событие',
|
'Live event': 'Живое событие',
|
||||||
Article: 'Статья',
|
Article: 'Статья',
|
||||||
Unfavorite: 'Убрать из избранного',
|
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: 'Предпочтительный'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,12 @@ export default {
|
||||||
'Live event': 'เหตุการณ์สด',
|
'Live event': 'เหตุการณ์สด',
|
||||||
Article: 'บทความ',
|
Article: 'บทความ',
|
||||||
Unfavorite: 'เลิกชื่นชอบ',
|
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: 'ที่ชื่นชอบ'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,12 @@ export default {
|
||||||
'Live event': '直播',
|
'Live event': '直播',
|
||||||
Article: '文章',
|
Article: '文章',
|
||||||
Unfavorite: '取消收藏',
|
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: '首选'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
isProtectedEvent,
|
isProtectedEvent,
|
||||||
isReplaceable
|
isReplaceable
|
||||||
} from './event'
|
} from './event'
|
||||||
|
import { normalizeHttpUrl } from './url'
|
||||||
|
|
||||||
// https://github.com/nostr-protocol/nips/blob/master/25.md
|
// https://github.com/nostr-protocol/nips/blob/master/25.md
|
||||||
export function createReactionDraftEvent(event: Event, emoji: TEmoji | string = '+'): TDraftEvent {
|
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[]) {
|
function generateImetaTags(imageUrls: string[]) {
|
||||||
return imageUrls
|
return imageUrls
|
||||||
.map((imageUrl) => {
|
.map((imageUrl) => {
|
||||||
|
|
|
||||||
|
|
@ -451,7 +451,7 @@ export function extractHashtags(content: string) {
|
||||||
export function extractImageInfosFromEventTags(event: Event) {
|
export function extractImageInfosFromEventTags(event: Event) {
|
||||||
const images: TImageInfo[] = []
|
const images: TImageInfo[] = []
|
||||||
event.tags.forEach((tag) => {
|
event.tags.forEach((tag) => {
|
||||||
const imageInfo = extractImageInfoFromTag(tag)
|
const imageInfo = extractImageInfoFromTag(tag, event.pubkey)
|
||||||
if (imageInfo) {
|
if (imageInfo) {
|
||||||
images.push(imageInfo)
|
images.push(imageInfo)
|
||||||
}
|
}
|
||||||
|
|
@ -588,6 +588,13 @@ export function extractEmojiInfosFromTags(tags: string[][] = []) {
|
||||||
.filter(Boolean) as TEmoji[]
|
.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 {
|
export function createFakeEvent(event: Partial<Event>): Event {
|
||||||
return {
|
return {
|
||||||
id: '',
|
id: '',
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,13 @@ export function generateEventId(event: Pick<Event, 'id' | 'pubkey'>) {
|
||||||
return nip19.neventEncode({ id: event.id, author: event.pubkey, relays: [relay] })
|
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
|
if (tag[0] !== 'imeta') return null
|
||||||
const urlItem = tag.find((item) => item.startsWith('url '))
|
const urlItem = tag.find((item) => item.startsWith('url '))
|
||||||
const url = urlItem?.slice(4)
|
const url = urlItem?.slice(4)
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
|
|
||||||
const image: TImageInfo = { url }
|
const image: TImageInfo = { url, pubkey }
|
||||||
const blurHashItem = tag.find((item) => item.startsWith('blurhash '))
|
const blurHashItem = tag.find((item) => item.startsWith('blurhash '))
|
||||||
const blurHash = blurHashItem?.slice(9)
|
const blurHash = blurHashItem?.slice(9)
|
||||||
if (blurHash) {
|
if (blurHash) {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export function normalizeHttpUrl(url: string): string {
|
||||||
return p.toString()
|
return p.toString()
|
||||||
} catch {
|
} catch {
|
||||||
console.error('Invalid URL:', url)
|
console.error('Invalid URL:', url)
|
||||||
return url
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,20 +9,42 @@ import {
|
||||||
import { DEFAULT_NIP_96_SERVICE, NIP_96_SERVICE } from '@/constants'
|
import { DEFAULT_NIP_96_SERVICE, NIP_96_SERVICE } from '@/constants'
|
||||||
import { simplifyUrl } from '@/lib/url'
|
import { simplifyUrl } from '@/lib/url'
|
||||||
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
|
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
|
||||||
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import BlossomServerListSetting from './BlossomServerListSetting'
|
||||||
|
|
||||||
|
const BLOSSOM = 'blossom'
|
||||||
|
|
||||||
export default function MediaUploadServiceSetting() {
|
export default function MediaUploadServiceSetting() {
|
||||||
const { t } = useTranslation()
|
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 (
|
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 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">
|
<SelectTrigger id="media-upload-service-select" className="w-48">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value={BLOSSOM}>{t('Blossom')}</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)}
|
||||||
|
|
@ -30,6 +52,8 @@ export default function MediaUploadServiceSetting() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{selectedValue === BLOSSOM && <BlossomServerListSetting />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
|
const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { nsec, ncryptsec } = useNostr()
|
const { pubkey, nsec, ncryptsec } = useNostr()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const [copiedNsec, setCopiedNsec] = useState(false)
|
const [copiedNsec, setCopiedNsec] = useState(false)
|
||||||
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
||||||
|
|
@ -63,6 +63,7 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
{!!pubkey && (
|
||||||
<SettingItem className="clickable" onClick={() => push(toPostSettings())}>
|
<SettingItem className="clickable" onClick={() => push(toPostSettings())}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<PencilLine />
|
<PencilLine />
|
||||||
|
|
@ -70,6 +71,7 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
)}
|
||||||
{!!nsec && (
|
{!!nsec && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
className="clickable"
|
className="clickable"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
import storage from '@/services/local-storage.service'
|
||||||
import mediaUpload from '@/services/media-upload.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 = {
|
type TMediaUploadServiceContext = {
|
||||||
service: string
|
serviceConfig: TMediaUploadServiceConfig
|
||||||
updateService: (service: string) => void
|
updateServiceConfig: (service: TMediaUploadServiceConfig) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaUploadServiceContext = createContext<TMediaUploadServiceContext | undefined>(undefined)
|
const MediaUploadServiceContext = createContext<TMediaUploadServiceContext | undefined>(undefined)
|
||||||
|
|
@ -17,15 +20,27 @@ export const useMediaUploadService = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaUploadServiceProvider({ children }: { children: React.ReactNode }) {
|
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) => {
|
useEffect(() => {
|
||||||
setService(newService)
|
const serviceConfig = storage.getMediaUploadServiceConfig(pubkey)
|
||||||
mediaUpload.setService(newService)
|
setServiceConfig(serviceConfig)
|
||||||
|
mediaUpload.setServiceConfig(serviceConfig)
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
const updateServiceConfig = (newService: TMediaUploadServiceConfig) => {
|
||||||
|
if (!pubkey) {
|
||||||
|
startLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setServiceConfig(newService)
|
||||||
|
storage.setMediaUploadServiceConfig(pubkey, newService)
|
||||||
|
mediaUpload.setServiceConfig(newService)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaUploadServiceContext.Provider value={{ service, updateService }}>
|
<MediaUploadServiceContext.Provider value={{ serviceConfig, updateServiceConfig }}>
|
||||||
{children}
|
{children}
|
||||||
</MediaUploadServiceContext.Provider>
|
</MediaUploadServiceContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||||
kinds.Contacts,
|
kinds.Contacts,
|
||||||
kinds.Mutelist,
|
kinds.Mutelist,
|
||||||
kinds.BookmarkList,
|
kinds.BookmarkList,
|
||||||
ExtendedKind.FAVORITE_RELAYS
|
ExtendedKind.FAVORITE_RELAYS,
|
||||||
|
ExtendedKind.BLOSSOM_SERVER_LIST
|
||||||
],
|
],
|
||||||
authors: [account.pubkey]
|
authors: [account.pubkey]
|
||||||
},
|
},
|
||||||
|
|
@ -226,6 +227,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||||
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
|
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
|
||||||
const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList)
|
const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList)
|
||||||
const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS)
|
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(
|
const notificationsSeenAtEvent = sortedEvents.find(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.kind === kinds.Application &&
|
e.kind === kinds.Application &&
|
||||||
|
|
@ -258,6 +262,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||||
setFavoriteRelaysEvent(favoriteRelaysEvent)
|
setFavoriteRelaysEvent(favoriteRelaysEvent)
|
||||||
await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
|
await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
|
||||||
}
|
}
|
||||||
|
if (blossomServerListEvent) {
|
||||||
|
await client.updateBlossomServerListEventCache(blossomServerListEvent)
|
||||||
|
}
|
||||||
|
|
||||||
const notificationsSeenAt = Math.max(
|
const notificationsSeenAt = Math.max(
|
||||||
notificationsSeenAtEvent?.created_at ?? 0,
|
notificationsSeenAtEvent?.created_at ?? 0,
|
||||||
|
|
@ -308,6 +315,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [signer])
|
}, [signer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account) {
|
||||||
|
client.pubkey = account.pubkey
|
||||||
|
} else {
|
||||||
|
client.pubkey = undefined
|
||||||
|
}
|
||||||
|
}, [account])
|
||||||
|
|
||||||
const hasNostrLoginHash = () => {
|
const hasNostrLoginHash = () => {
|
||||||
return window.location.hash && window.location.hash.startsWith('#nostr-login')
|
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)
|
additionalRelayUrls.push(...BIG_RELAY_URLS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
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 { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
|
||||||
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
||||||
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||||
|
|
@ -27,6 +31,7 @@ class ClientService extends EventTarget {
|
||||||
static instance: ClientService
|
static instance: ClientService
|
||||||
|
|
||||||
signer?: ISigner
|
signer?: ISigner
|
||||||
|
pubkey?: string
|
||||||
private currentRelayUrls: string[] = []
|
private currentRelayUrls: string[] = []
|
||||||
private pool: SimplePool
|
private pool: SimplePool
|
||||||
|
|
||||||
|
|
@ -74,10 +79,14 @@ class ClientService extends EventTarget {
|
||||||
max: 2000,
|
max: 2000,
|
||||||
fetchMethod: this._fetchFollowListEvent.bind(this)
|
fetchMethod: this._fetchFollowListEvent.bind(this)
|
||||||
})
|
})
|
||||||
private fetchFollowingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({
|
private followingFavoriteRelaysCache = new LRUCache<string, Promise<[string, string[]][]>>({
|
||||||
max: 10,
|
max: 10,
|
||||||
fetchMethod: this._fetchFollowingFavoriteRelays.bind(this)
|
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({
|
private userIndex = new FlexSearch.Index({
|
||||||
tokenize: 'forward'
|
tokenize: 'forward'
|
||||||
|
|
@ -816,7 +825,7 @@ class ClientService extends EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchFollowingFavoriteRelays(pubkey: string) {
|
async fetchFollowingFavoriteRelays(pubkey: string) {
|
||||||
return this.fetchFollowingFavoriteRelaysCache.fetch(pubkey)
|
return this.followingFavoriteRelaysCache.fetch(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetchFollowingFavoriteRelays(pubkey: string) {
|
private async _fetchFollowingFavoriteRelays(pubkey: string) {
|
||||||
|
|
@ -870,6 +879,47 @@ class ClientService extends EventTarget {
|
||||||
return fetchNewData()
|
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) {
|
updateFollowListCache(event: NEvent) {
|
||||||
this.followListCache.set(event.pubkey, Promise.resolve(event))
|
this.followListCache.set(event.pubkey, Promise.resolve(event))
|
||||||
indexedDb.putReplaceableEvent(event)
|
indexedDb.putReplaceableEvent(event)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const StoreNames = {
|
||||||
FOLLOW_LIST_EVENTS: 'followListEvents',
|
FOLLOW_LIST_EVENTS: 'followListEvents',
|
||||||
MUTE_LIST_EVENTS: 'muteListEvents',
|
MUTE_LIST_EVENTS: 'muteListEvents',
|
||||||
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
|
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
|
||||||
|
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
|
||||||
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
|
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
|
||||||
RELAY_INFO_EVENTS: 'relayInfoEvents',
|
RELAY_INFO_EVENTS: 'relayInfoEvents',
|
||||||
FAVORITE_RELAYS: 'favoriteRelays',
|
FAVORITE_RELAYS: 'favoriteRelays',
|
||||||
|
|
@ -37,7 +38,7 @@ class IndexedDbService {
|
||||||
init(): Promise<void> {
|
init(): Promise<void> {
|
||||||
if (!this.initPromise) {
|
if (!this.initPromise) {
|
||||||
this.initPromise = new Promise((resolve, reject) => {
|
this.initPromise = new Promise((resolve, reject) => {
|
||||||
const request = window.indexedDB.open('jumble', 5)
|
const request = window.indexedDB.open('jumble', 6)
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
reject(event)
|
reject(event)
|
||||||
|
|
@ -80,6 +81,9 @@ class IndexedDbService {
|
||||||
if (!db.objectStoreNames.contains(StoreNames.FOLLOWING_FAVORITE_RELAYS)) {
|
if (!db.objectStoreNames.contains(StoreNames.FOLLOWING_FAVORITE_RELAYS)) {
|
||||||
db.createObjectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS, { keyPath: 'key' })
|
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
|
this.db = db
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -433,6 +437,8 @@ class IndexedDbService {
|
||||||
return StoreNames.FOLLOW_LIST_EVENTS
|
return StoreNames.FOLLOW_LIST_EVENTS
|
||||||
case kinds.Mutelist:
|
case kinds.Mutelist:
|
||||||
return StoreNames.MUTE_LIST_EVENTS
|
return StoreNames.MUTE_LIST_EVENTS
|
||||||
|
case ExtendedKind.BLOSSOM_SERVER_LIST:
|
||||||
|
return StoreNames.BLOSSOM_SERVER_LIST_EVENTS
|
||||||
case kinds.Relaysets:
|
case kinds.Relaysets:
|
||||||
return StoreNames.RELAY_SETS
|
return StoreNames.RELAY_SETS
|
||||||
case ExtendedKind.FAVORITE_RELAYS:
|
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.RELAY_LIST_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
|
||||||
{
|
{
|
||||||
name: StoreNames.FOLLOW_LIST_EVENTS,
|
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(
|
const transaction = this.db!.transaction(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
TAccount,
|
TAccount,
|
||||||
TAccountPointer,
|
TAccountPointer,
|
||||||
TFeedInfo,
|
TFeedInfo,
|
||||||
|
TMediaUploadServiceConfig,
|
||||||
TNoteListMode,
|
TNoteListMode,
|
||||||
TRelaySet,
|
TRelaySet,
|
||||||
TThemeSetting,
|
TThemeSetting,
|
||||||
|
|
@ -30,6 +31,7 @@ class LocalStorageService {
|
||||||
private hideUntrustedNotifications: boolean = false
|
private hideUntrustedNotifications: boolean = false
|
||||||
private hideUntrustedNotes: boolean = false
|
private hideUntrustedNotes: boolean = false
|
||||||
private translationServiceConfigMap: Record<string, TTranslationServiceConfig> = {}
|
private translationServiceConfigMap: Record<string, TTranslationServiceConfig> = {}
|
||||||
|
private mediaUploadServiceConfigMap: Record<string, TMediaUploadServiceConfig> = {}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!LocalStorageService.instance) {
|
if (!LocalStorageService.instance) {
|
||||||
|
|
@ -92,6 +94,7 @@ class LocalStorageService {
|
||||||
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
|
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
|
||||||
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
|
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
|
||||||
|
|
||||||
|
// deprecated
|
||||||
this.mediaUploadService =
|
this.mediaUploadService =
|
||||||
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
|
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
|
||||||
|
|
||||||
|
|
@ -123,6 +126,13 @@ class LocalStorageService {
|
||||||
this.translationServiceConfigMap = JSON.parse(translationServiceConfigMapStr)
|
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
|
// Clean up deprecated data
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_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() {
|
getAutoplay() {
|
||||||
return this.autoplay
|
return this.autoplay
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +327,26 @@ class LocalStorageService {
|
||||||
JSON.stringify(this.translationServiceConfigMap)
|
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()
|
const instance = new LocalStorageService()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { simplifyUrl } from '@/lib/url'
|
import { simplifyUrl } from '@/lib/url'
|
||||||
|
import { TDraftEvent, TMediaUploadServiceConfig } from '@/types'
|
||||||
|
import { BlossomClient } from 'blossom-client-sdk'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { kinds } from 'nostr-tools'
|
import { kinds } from 'nostr-tools'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
@ -8,8 +10,8 @@ import storage from './local-storage.service'
|
||||||
class MediaUploadService {
|
class MediaUploadService {
|
||||||
static instance: MediaUploadService
|
static instance: MediaUploadService
|
||||||
|
|
||||||
private service: string = storage.getMediaUploadService()
|
private serviceConfig: TMediaUploadServiceConfig = storage.getMediaUploadServiceConfig()
|
||||||
private serviceUploadUrlMap = new Map<string, string | undefined>()
|
private nip96ServiceUploadUrlMap = new Map<string, string | undefined>()
|
||||||
private imetaTagMap = new Map<string, string[]>()
|
private imetaTagMap = new Map<string, string[]>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -19,32 +21,81 @@ class MediaUploadService {
|
||||||
return MediaUploadService.instance
|
return MediaUploadService.instance
|
||||||
}
|
}
|
||||||
|
|
||||||
getService() {
|
setServiceConfig(config: TMediaUploadServiceConfig) {
|
||||||
return this.service
|
this.serviceConfig = config
|
||||||
}
|
|
||||||
|
|
||||||
setService(service: string) {
|
|
||||||
this.service = service
|
|
||||||
storage.setMediaUploadService(service)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(file: File) {
|
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) {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
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()
|
const data = await response.json()
|
||||||
uploadUrl = data?.api_url
|
uploadUrl = data?.api_url
|
||||||
if (!uploadUrl) {
|
if (!uploadUrl) {
|
||||||
throw new Error(
|
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()
|
const formData = new FormData()
|
||||||
|
|
@ -67,8 +118,7 @@ class MediaUploadService {
|
||||||
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
|
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
|
||||||
const url = tags.find(([tagName]) => tagName === 'url')?.[1]
|
const url = tags.find(([tagName]) => tagName === 'url')?.[1]
|
||||||
if (url) {
|
if (url) {
|
||||||
this.imetaTagMap.set(url, ['imeta', ...tags.map(([n, v]) => `${n} ${v}`)])
|
return { url, tags }
|
||||||
return { url: url, tags }
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No url found')
|
throw new Error('No url found')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
src/types.ts
16
src/types.ts
|
|
@ -100,7 +100,12 @@ export type TFeedInfo = { feedType: TFeedType; id?: string }
|
||||||
|
|
||||||
export type TLanguage = 'en' | 'zh' | 'pl'
|
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'
|
export type TNoteListMode = 'posts' | 'postsAndReplies' | 'pictures' | 'you'
|
||||||
|
|
||||||
|
|
@ -137,3 +142,12 @@ export type TTranslationServiceConfig =
|
||||||
server?: string
|
server?: string
|
||||||
api_key?: string
|
api_key?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TMediaUploadServiceConfig =
|
||||||
|
| {
|
||||||
|
type: 'nip96'
|
||||||
|
service: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'blossom'
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue