diff --git a/package-lock.json b/package-lock.json index 496b9457..5269a0d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e2276cad..ab5265da 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index b9b01ca0..3fd6cedb 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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 diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index fc71c04d..e8c9423b 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -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(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 (
{isLoading && } {!hasError ? ( {alt} setDisplayBlurHash(false), 500) }} - onError={() => { - setIsLoading(false) - setHasError(true) - }} + onError={handleImageError} /> ) : (
{metadata.image && ( diff --git a/src/components/Note/GroupMetadata.tsx b/src/components/Note/GroupMetadata.tsx index 4cdb1be1..f79d0bf6 100644 --- a/src/components/Note/GroupMetadata.tsx +++ b/src/components/Note/GroupMetadata.tsx @@ -28,7 +28,7 @@ export default function GroupMetadata({
{metadata.picture && ( diff --git a/src/components/Note/LiveEvent.tsx b/src/components/Note/LiveEvent.tsx index a1e686e4..18aea8f8 100644 --- a/src/components/Note/LiveEvent.tsx +++ b/src/components/Note/LiveEvent.tsx @@ -41,7 +41,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
{metadata.image && ( @@ -62,7 +62,7 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
{metadata.image && ( diff --git a/src/components/Note/LongFormArticle.tsx b/src/components/Note/LongFormArticle.tsx index 3d04bba4..aadcdd1c 100644 --- a/src/components/Note/LongFormArticle.tsx +++ b/src/components/Note/LongFormArticle.tsx @@ -37,7 +37,7 @@ export default function LongFormArticle({
{metadata.image && ( @@ -57,7 +57,7 @@ export default function LongFormArticle({
{metadata.image && ( diff --git a/src/components/NotificationList/NotificationItem/ReactionNotification.tsx b/src/components/NotificationList/NotificationItem/ReactionNotification.tsx index 40605d0e..90b11b2a 100644 --- a/src/components/NotificationList/NotificationItem/ReactionNotification.tsx +++ b/src/components/NotificationList/NotificationItem/ReactionNotification.tsx @@ -41,7 +41,7 @@ export function ReactionNotification({ if (emojiUrl) { return ( {emojiName} setBannerUrl(defaultBanner)} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index c06e9177..58e86ff3 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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: { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index b1406898..da0f0fef 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -8,7 +8,7 @@ const Input = React.forwardRef>( ['server', normalizeHttpUrl(server)]), + created_at: dayjs().unix() + } +} + function generateImetaTags(imageUrls: string[]) { return imageUrls .map((imageUrl) => { diff --git a/src/lib/event.ts b/src/lib/event.ts index d83596f1..ae8569de 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -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 { return { id: '', diff --git a/src/lib/tag.ts b/src/lib/tag.ts index a3155d7b..23edc75d 100644 --- a/src/lib/tag.ts +++ b/src/lib/tag.ts @@ -49,13 +49,13 @@ export function generateEventId(event: Pick) { 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) { diff --git a/src/lib/url.ts b/src/lib/url.ts index c8b301cb..c832bcad 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -48,7 +48,7 @@ export function normalizeHttpUrl(url: string): string { return p.toString() } catch { console.error('Invalid URL:', url) - return url + return '' } } diff --git a/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx b/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx new file mode 100644 index 00000000..b80bbf5c --- /dev/null +++ b/src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx @@ -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(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) => { + 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 ( +
+
{t('Blossom server URLs')}
+ {serverUrls.length === 0 && ( +
+
+ + {t('You need to add at least one media server in order to upload media files.')} +
+ +
{t('Recommended blossom servers')}:
+
+ {RECOMMENDED_BLOSSOM_SERVERS.map((recommendedUrl) => ( + + ))} +
+
+ )} + {serverUrls.map((url, idx) => ( +
+ + {url} + +
+ {idx > 0 ? ( + + ) : ( + {t('Preferred')} + )} + +
+
+ ))} +
+ setUrl(e.target.value)} + placeholder={t('Enter Blossom server URL')} + onKeyDown={handleUrlInputKeyDown} + /> + +
+
+ ) +} diff --git a/src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx b/src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx index 683fcdf8..676422bd 100644 --- a/src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx +++ b/src/pages/secondary/PostSettingsPage/MediaUploadServiceSetting.tsx @@ -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 (
- + {t('Blossom')} {NIP_96_SERVICE.map((url) => ( {simplifyUrl(url)} @@ -30,6 +52,8 @@ export default function MediaUploadServiceSetting() { ))} + + {selectedValue === BLOSSOM && }
) } diff --git a/src/pages/secondary/SettingsPage/index.tsx b/src/pages/secondary/SettingsPage/index.tsx index 958670d1..b572f708 100644 --- a/src/pages/secondary/SettingsPage/index.tsx +++ b/src/pages/secondary/SettingsPage/index.tsx @@ -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,13 +63,15 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
- push(toPostSettings())}> -
- -
{t('Post settings')}
-
- -
+ {!!pubkey && ( + push(toPostSettings())}> +
+ +
{t('Post settings')}
+
+ +
+ )} {!!nsec && ( void + serviceConfig: TMediaUploadServiceConfig + updateServiceConfig: (service: TMediaUploadServiceConfig) => void } const MediaUploadServiceContext = createContext(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 ( - + {children} ) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 88376927..69f64057 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index e72517ef..cbad6e06 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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>({ + private followingFavoriteRelaysCache = new LRUCache>({ max: 10, fetchMethod: this._fetchFollowingFavoriteRelays.bind(this) }) + private blossomServerListEventCache = new LRUCache>({ + 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) diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 085cd2a7..ae170580 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -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 { 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( diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 0bdf9277..5b72ba1d 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -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 = {} + private mediaUploadServiceConfigMap: Record = {} 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() diff --git a/src/services/media-upload.service.ts b/src/services/media-upload.service.ts index 26b2620d..153e84cf 100644 --- a/src/services/media-upload.service.ts +++ b/src/services/media-upload.service.ts @@ -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() + private serviceConfig: TMediaUploadServiceConfig = storage.getMediaUploadServiceConfig() + private nip96ServiceUploadUrlMap = new Map() private imetaTagMap = new Map() 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') } diff --git a/src/types.ts b/src/types.ts index b9cec094..a35672c5 100644 --- a/src/types.ts +++ b/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' + }