feat: support change media upload service

This commit is contained in:
codytseng
2025-04-08 14:23:16 +08:00
parent 2e552c356c
commit bc0fa7f528
11 changed files with 198 additions and 41 deletions

View File

@@ -7,6 +7,7 @@ import { PageManager } from './PageManager'
import { FavoriteRelaysProvider } from './providers/FavoriteRelaysProvider'
import { FeedProvider } from './providers/FeedProvider'
import { FollowListProvider } from './providers/FollowListProvider'
import { MediaUploadServiceProvider } from './providers/MediaUploadServiceProvider'
import { MuteListProvider } from './providers/MuteListProvider'
import { NostrProvider } from './providers/NostrProvider'
import { NoteStatsProvider } from './providers/NoteStatsProvider'
@@ -24,8 +25,10 @@ export default function App(): JSX.Element {
<MuteListProvider>
<FeedProvider>
<NoteStatsProvider>
<PageManager />
<Toaster />
<MediaUploadServiceProvider>
<PageManager />
<Toaster />
</MediaUploadServiceProvider>
</NoteStatsProvider>
</FeedProvider>
</MuteListProvider>

View File

@@ -1,7 +1,6 @@
import { useToast } from '@/hooks/use-toast'
import { useNostr } from '@/providers/NostrProvider'
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
import { useRef } from 'react'
import { z } from 'zod'
export default function Uploader({
children,
@@ -16,41 +15,19 @@ export default function Uploader({
className?: string
accept?: string
}) {
const { signHttpAuth } = useNostr()
const { toast } = useToast()
const { upload } = useMediaUploadService()
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
try {
onUploadingChange?.(true)
const url = 'https://nostr.build/api/v2/nip96/upload'
const auth = await signHttpAuth(url, 'POST')
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
Authorization: auth
}
})
if (!response.ok) {
throw new Error(response.status.toString())
}
const data = await response.json()
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
const imageUrl = tags.find(([tagName]) => tagName === 'url')?.[1]
if (imageUrl) {
onUploadSuccess({ url: imageUrl, tags })
} else {
throw new Error('No image url found')
}
const result = await upload(file)
console.log('File uploaded successfully', result)
onUploadSuccess(result)
} catch (error) {
console.error('Error uploading file', error)
toast({

View File

@@ -12,6 +12,7 @@ export const StorageKey = {
QUICK_ZAP: 'quickZap',
LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap',
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService',
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated
@@ -54,3 +55,13 @@ export const DEFAULT_FAVORITE_RELAYS = [
'wss://news.utxo.one/',
'wss://algo.utxo.one'
]
export const NIP_96_SERVICE = [
'https://mockingyou.com',
'https://nostpic.com',
'https://nostr.build', // default
'https://nostrcheck.me',
'https://nostrmedia.com',
'https://files.sovbit.host'
]
export const DEFAULT_NIP_96_SERVICE = 'https://nostr.build'

View File

@@ -35,11 +35,12 @@ export const toOthersRelaySettings = (pubkey: string) => {
const npub = nip19.npubEncode(pubkey)
return `/users/${npub}/relays`
}
export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
return '/relay-settings' + (tag ? '#' + tag : '')
}
export const toSettings = () => '/settings'
export const toWallet = () => '/wallet'
export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
return '/settings/relays' + (tag ? '#' + tag : '')
}
export const toWallet = () => '/settings/wallet'
export const toPostSettings = () => '/settings/posts'
export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toMuteList = () => '/mutes'

View File

@@ -41,7 +41,12 @@ export function normalizeHttpUrl(url: string): string {
}
export function simplifyUrl(url: string): string {
return url.replace('wss://', '').replace('ws://', '').replace(/\/$/, '')
return url
.replace('wss://', '')
.replace('ws://', '')
.replace('https://', '')
.replace('http://', '')
.replace(/\/$/, '')
}
export function isLocalNetworkUrl(urlString: string): boolean {

View File

@@ -0,0 +1,35 @@
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { DEFAULT_NIP_96_SERVICE, NIP_96_SERVICE } from '@/constants'
import { simplifyUrl } from '@/lib/url'
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
import { useTranslation } from 'react-i18next'
export default function MediaUploadServiceSetting() {
const { t } = useTranslation()
const { service, updateService } = useMediaUploadService()
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}>
<SelectTrigger id="media-upload-service-select" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NIP_96_SERVICE.map((url) => (
<SelectItem key={url} value={url}>
{simplifyUrl(url)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import MediaUploadServiceSetting from './MediaUploadServiceSetting'
const PostSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Wallet')}>
<div className="px-4 pt-2 space-y-4">
<MediaUploadServiceSetting />
</div>
</SecondaryPageLayout>
)
})
PostSettingsPage.displayName = 'PostSettingsPage'
export default PostSettingsPage

View File

@@ -3,7 +3,7 @@ import Donation from '@/components/Donation'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { LocalizedLanguageNames, TLanguage } from '@/i18n'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toRelaySettings, toWallet } from '@/lib/link'
import { toPostSettings, toRelaySettings, toWallet } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
@@ -16,6 +16,7 @@ import {
Info,
KeyRound,
Languages,
PencilLine,
Server,
SunMoon,
Wallet
@@ -87,6 +88,13 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
</div>
<ChevronRight />
</SettingItem>
<SettingItem className="clickable" onClick={() => push(toPostSettings())}>
<div className="flex items-center gap-4">
<PencilLine />
<div>{t('Posts')}</div>
</div>
<ChevronRight />
</SettingItem>
{!!nsec && (
<SettingItem
className="clickable"

View File

@@ -0,0 +1,84 @@
import { simplifyUrl } from '@/lib/url'
import storage from '@/services/local-storage.service'
import { createContext, useContext, useState } from 'react'
import { z } from 'zod'
import { useNostr } from './NostrProvider'
type TMediaUploadServiceContext = {
service: string
updateService: (service: string) => void
upload: (file: File) => Promise<{ url: string; tags: string[][] }>
}
const MediaUploadServiceContext = createContext<TMediaUploadServiceContext | undefined>(undefined)
export const useMediaUploadService = () => {
const context = useContext(MediaUploadServiceContext)
if (!context) {
throw new Error('useMediaUploadService must be used within MediaUploadServiceProvider')
}
return context
}
const ServiceUploadUrlMap = new Map<string, string | undefined>()
export function MediaUploadServiceProvider({ children }: { children: React.ReactNode }) {
const { signHttpAuth } = useNostr()
const [service, setService] = useState(storage.getMediaUploadService())
const updateService = (newService: string) => {
setService(newService)
storage.setMediaUploadService(newService)
}
const upload = async (file: File) => {
let uploadUrl = ServiceUploadUrlMap.get(service)
if (!uploadUrl) {
const response = await fetch(`${service}/.well-known/nostr/nip96.json`)
if (!response.ok) {
throw new Error(
`${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(service)} does not work, please try another service in your settings`
)
}
ServiceUploadUrlMap.set(service, uploadUrl)
}
const formData = new FormData()
formData.append('file', file)
const auth = await signHttpAuth(uploadUrl, 'POST')
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData,
headers: {
Authorization: auth
}
})
if (!response.ok) {
throw new Error(response.status.toString())
}
const data = await response.json()
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
const imageUrl = tags.find(([tagName]) => tagName === 'url')?.[1]
if (imageUrl) {
return { url: imageUrl, tags }
} else {
throw new Error('No image url found')
}
}
return (
<MediaUploadServiceContext.Provider value={{ service, updateService, upload }}>
{children}
</MediaUploadServiceContext.Provider>
)
}

View File

@@ -5,6 +5,7 @@ import MuteListPage from './pages/secondary/MuteListPage'
import NoteListPage from './pages/secondary/NoteListPage'
import NotePage from './pages/secondary/NotePage'
import OthersRelaySettingsPage from './pages/secondary/OthersRelaySettingsPage'
import PostSettingsPage from './pages/secondary/PostSettingsPage'
import ProfileEditorPage from './pages/secondary/ProfileEditorPage'
import ProfileListPage from './pages/secondary/ProfileListPage'
import ProfilePage from './pages/secondary/ProfilePage'
@@ -20,11 +21,12 @@ const ROUTES = [
{ path: '/users/:id', element: <ProfilePage /> },
{ path: '/users/:id/following', element: <FollowingListPage /> },
{ path: '/users/:id/relays', element: <OthersRelaySettingsPage /> },
{ path: '/relay-settings', element: <RelaySettingsPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/wallet', element: <WalletPage /> },
{ path: '/profile-editor', element: <ProfileEditorPage /> },
{ path: '/relays/:url', element: <RelayPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/settings/relays', element: <RelaySettingsPage /> },
{ path: '/settings/wallet', element: <WalletPage /> },
{ path: '/settings/posts', element: <PostSettingsPage /> },
{ path: '/profile-editor', element: <ProfileEditorPage /> },
{ path: '/mutes', element: <MuteListPage /> }
]

View File

@@ -1,4 +1,4 @@
import { StorageKey } from '@/constants'
import { DEFAULT_NIP_96_SERVICE, StorageKey } from '@/constants'
import { isSameAccount } from '@/lib/account'
import { randomString } from '@/lib/random'
import {
@@ -23,6 +23,7 @@ class LocalStorageService {
private defaultZapComment: string = 'Zap!'
private quickZap: boolean = false
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
constructor() {
if (!LocalStorageService.instance) {
@@ -85,6 +86,9 @@ class LocalStorageService {
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
this.mediaUploadService =
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@@ -222,6 +226,15 @@ class LocalStorageService {
JSON.stringify(this.accountFeedInfoMap)
)
}
getMediaUploadService() {
return this.mediaUploadService
}
setMediaUploadService(service: string) {
this.mediaUploadService = service
window.localStorage.setItem(StorageKey.MEDIA_UPLOAD_SERVICE, service)
}
}
const instance = new LocalStorageService()