feat: 🌸
This commit is contained in:
135
package-lock.json
generated
135
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 'المفضل'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 l’URL du serveur Blossom',
|
||||
Preferred: 'Préféré'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '優先'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '선호'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 'Предпочтительный'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 'ที่ชื่นชอบ'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '首选'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function normalizeHttpUrl(url: string): string {
|
||||
return p.toString()
|
||||
} catch {
|
||||
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 { 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
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 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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user