feat: 🌸

This commit is contained in:
codytseng
2025-07-18 23:25:47 +08:00
parent 74e04e1c7d
commit e91b2648cc
41 changed files with 756 additions and 92 deletions

View File

@@ -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)

View File

@@ -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(

View File

@@ -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()

View File

@@ -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')
}