feat: support change media upload service
This commit is contained in:
@@ -7,6 +7,7 @@ import { PageManager } from './PageManager'
|
|||||||
import { FavoriteRelaysProvider } from './providers/FavoriteRelaysProvider'
|
import { FavoriteRelaysProvider } from './providers/FavoriteRelaysProvider'
|
||||||
import { FeedProvider } from './providers/FeedProvider'
|
import { FeedProvider } from './providers/FeedProvider'
|
||||||
import { FollowListProvider } from './providers/FollowListProvider'
|
import { FollowListProvider } from './providers/FollowListProvider'
|
||||||
|
import { MediaUploadServiceProvider } from './providers/MediaUploadServiceProvider'
|
||||||
import { MuteListProvider } from './providers/MuteListProvider'
|
import { MuteListProvider } from './providers/MuteListProvider'
|
||||||
import { NostrProvider } from './providers/NostrProvider'
|
import { NostrProvider } from './providers/NostrProvider'
|
||||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||||
@@ -24,8 +25,10 @@ export default function App(): JSX.Element {
|
|||||||
<MuteListProvider>
|
<MuteListProvider>
|
||||||
<FeedProvider>
|
<FeedProvider>
|
||||||
<NoteStatsProvider>
|
<NoteStatsProvider>
|
||||||
<PageManager />
|
<MediaUploadServiceProvider>
|
||||||
<Toaster />
|
<PageManager />
|
||||||
|
<Toaster />
|
||||||
|
</MediaUploadServiceProvider>
|
||||||
</NoteStatsProvider>
|
</NoteStatsProvider>
|
||||||
</FeedProvider>
|
</FeedProvider>
|
||||||
</MuteListProvider>
|
</MuteListProvider>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
export default function Uploader({
|
export default function Uploader({
|
||||||
children,
|
children,
|
||||||
@@ -16,41 +15,19 @@ export default function Uploader({
|
|||||||
className?: string
|
className?: string
|
||||||
accept?: string
|
accept?: string
|
||||||
}) {
|
}) {
|
||||||
const { signHttpAuth } = useNostr()
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const { upload } = useMediaUploadService()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
onUploadingChange?.(true)
|
onUploadingChange?.(true)
|
||||||
const url = 'https://nostr.build/api/v2/nip96/upload'
|
const result = await upload(file)
|
||||||
const auth = await signHttpAuth(url, 'POST')
|
console.log('File uploaded successfully', result)
|
||||||
const response = await fetch(url, {
|
onUploadSuccess(result)
|
||||||
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')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading file', error)
|
console.error('Error uploading file', error)
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const StorageKey = {
|
|||||||
QUICK_ZAP: 'quickZap',
|
QUICK_ZAP: 'quickZap',
|
||||||
LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap',
|
LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap',
|
||||||
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
|
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
|
||||||
|
MEDIA_UPLOAD_SERVICE: 'mediaUploadService',
|
||||||
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
||||||
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
|
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
|
||||||
ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated
|
ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated
|
||||||
@@ -54,3 +55,13 @@ export const DEFAULT_FAVORITE_RELAYS = [
|
|||||||
'wss://news.utxo.one/',
|
'wss://news.utxo.one/',
|
||||||
'wss://algo.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'
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ export const toOthersRelaySettings = (pubkey: string) => {
|
|||||||
const npub = nip19.npubEncode(pubkey)
|
const npub = nip19.npubEncode(pubkey)
|
||||||
return `/users/${npub}/relays`
|
return `/users/${npub}/relays`
|
||||||
}
|
}
|
||||||
export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
|
|
||||||
return '/relay-settings' + (tag ? '#' + tag : '')
|
|
||||||
}
|
|
||||||
export const toSettings = () => '/settings'
|
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 toProfileEditor = () => '/profile-editor'
|
||||||
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
|
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
|
||||||
export const toMuteList = () => '/mutes'
|
export const toMuteList = () => '/mutes'
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ export function normalizeHttpUrl(url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function simplifyUrl(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 {
|
export function isLocalNetworkUrl(urlString: string): boolean {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/pages/secondary/PostSettingsPage/index.tsx
Normal file
18
src/pages/secondary/PostSettingsPage/index.tsx
Normal 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
|
||||||
@@ -3,7 +3,7 @@ import Donation from '@/components/Donation'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
|
||||||
import { LocalizedLanguageNames, TLanguage } from '@/i18n'
|
import { LocalizedLanguageNames, TLanguage } from '@/i18n'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { toRelaySettings, toWallet } from '@/lib/link'
|
import { toPostSettings, toRelaySettings, toWallet } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Languages,
|
Languages,
|
||||||
|
PencilLine,
|
||||||
Server,
|
Server,
|
||||||
SunMoon,
|
SunMoon,
|
||||||
Wallet
|
Wallet
|
||||||
@@ -87,6 +88,13 @@ const SettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
</div>
|
</div>
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
<SettingItem className="clickable" onClick={() => push(toPostSettings())}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<PencilLine />
|
||||||
|
<div>{t('Posts')}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight />
|
||||||
|
</SettingItem>
|
||||||
{!!nsec && (
|
{!!nsec && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
className="clickable"
|
className="clickable"
|
||||||
|
|||||||
84
src/providers/MediaUploadServiceProvider.tsx
Normal file
84
src/providers/MediaUploadServiceProvider.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import MuteListPage from './pages/secondary/MuteListPage'
|
|||||||
import NoteListPage from './pages/secondary/NoteListPage'
|
import NoteListPage from './pages/secondary/NoteListPage'
|
||||||
import NotePage from './pages/secondary/NotePage'
|
import NotePage from './pages/secondary/NotePage'
|
||||||
import OthersRelaySettingsPage from './pages/secondary/OthersRelaySettingsPage'
|
import OthersRelaySettingsPage from './pages/secondary/OthersRelaySettingsPage'
|
||||||
|
import PostSettingsPage from './pages/secondary/PostSettingsPage'
|
||||||
import ProfileEditorPage from './pages/secondary/ProfileEditorPage'
|
import ProfileEditorPage from './pages/secondary/ProfileEditorPage'
|
||||||
import ProfileListPage from './pages/secondary/ProfileListPage'
|
import ProfileListPage from './pages/secondary/ProfileListPage'
|
||||||
import ProfilePage from './pages/secondary/ProfilePage'
|
import ProfilePage from './pages/secondary/ProfilePage'
|
||||||
@@ -20,11 +21,12 @@ const ROUTES = [
|
|||||||
{ path: '/users/:id', element: <ProfilePage /> },
|
{ path: '/users/:id', element: <ProfilePage /> },
|
||||||
{ path: '/users/:id/following', element: <FollowingListPage /> },
|
{ path: '/users/:id/following', element: <FollowingListPage /> },
|
||||||
{ path: '/users/:id/relays', element: <OthersRelaySettingsPage /> },
|
{ 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: '/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 /> }
|
{ path: '/mutes', element: <MuteListPage /> }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { StorageKey } from '@/constants'
|
import { DEFAULT_NIP_96_SERVICE, StorageKey } from '@/constants'
|
||||||
import { isSameAccount } from '@/lib/account'
|
import { isSameAccount } from '@/lib/account'
|
||||||
import { randomString } from '@/lib/random'
|
import { randomString } from '@/lib/random'
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +23,7 @@ class LocalStorageService {
|
|||||||
private defaultZapComment: string = 'Zap!'
|
private defaultZapComment: string = 'Zap!'
|
||||||
private quickZap: boolean = false
|
private quickZap: boolean = false
|
||||||
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
|
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
|
||||||
|
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!LocalStorageService.instance) {
|
if (!LocalStorageService.instance) {
|
||||||
@@ -85,6 +86,9 @@ class LocalStorageService {
|
|||||||
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
|
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
|
||||||
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
|
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
|
||||||
|
|
||||||
|
this.mediaUploadService =
|
||||||
|
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
|
||||||
|
|
||||||
// Clean up deprecated data
|
// Clean up deprecated data
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
||||||
@@ -222,6 +226,15 @@ class LocalStorageService {
|
|||||||
JSON.stringify(this.accountFeedInfoMap)
|
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()
|
const instance = new LocalStorageService()
|
||||||
|
|||||||
Reference in New Issue
Block a user