feat: 🌸
This commit is contained in:
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user