feat: adapt for algo relay

This commit is contained in:
codytseng
2024-11-17 23:08:01 +08:00
parent feb04cce87
commit d1150b947a
4 changed files with 163 additions and 131 deletions

View File

@@ -1,4 +1,5 @@
import { Image } from '@nextui-org/image' import { Image } from '@nextui-org/image'
import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'
import { cn } from '@renderer/lib/utils' import { cn } from '@renderer/lib/utils'
import { useState } from 'react' import { useState } from 'react'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
@@ -23,21 +24,25 @@ export default function ImageGallery({
setIndex(current) setIndex(current)
} }
const maxHight = size === 'small' ? 'h-[15vh]' : images.length < 3 ? 'h-[30vh]' : 'h-[20vh]'
return ( return (
<div className={className} onClick={(e) => e.stopPropagation()}> <div className={cn('relative', className)} onClick={(e) => e.stopPropagation()}>
<div className="flex flex-wrap gap-2"> <ScrollArea className="w-full">
<div className="flex space-x-2">
{images.map((src, index) => ( {images.map((src, index) => (
<ImageWithNsfwOverlay <Image
key={index} key={index}
className={cn(
'rounded-lg cursor-pointer z-0',
size === 'small' ? 'h-[15vh]' : 'h-[30vh]'
)}
src={src} src={src}
isNsfw={isNsfw}
className={maxHight}
onClick={(e) => handlePhotoClick(e, index)} onClick={(e) => handlePhotoClick(e, index)}
removeWrapper
/> />
))} ))}
</div> </div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<Lightbox <Lightbox
index={index} index={index}
slides={images.map((src) => ({ src }))} slides={images.map((src) => ({ src }))}
@@ -51,28 +56,6 @@ export default function ImageGallery({
}} }}
styles={{ toolbar: { paddingTop: '2.25rem' } }} styles={{ toolbar: { paddingTop: '2.25rem' } }}
/> />
</div>
)
}
function ImageWithNsfwOverlay({
src,
isNsfw = false,
className,
onClick
}: {
src: string
isNsfw: boolean
className?: string
onClick?: (e: React.MouseEvent) => void
}) {
return (
<div className="relative" onClick={onClick}>
<Image
className={cn('rounded-lg object-cover aspect-square', className)}
src={src}
removeWrapper
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />} {isNsfw && <NsfwOverlay className="rounded-lg" />}
</div> </div>
) )

View File

@@ -1,6 +1,7 @@
import { Button } from '@renderer/components/ui/button' import { Button } from '@renderer/components/ui/button'
import { isReplyNoteEvent } from '@renderer/lib/event' import { isReplyNoteEvent } from '@renderer/lib/event'
import { cn } from '@renderer/lib/utils' import { cn } from '@renderer/lib/utils'
import { useNostr } from '@renderer/providers/NostrProvider'
import client from '@renderer/services/client.service' import client from '@renderer/services/client.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools' import { Event, Filter, kinds } from 'nostr-tools'
@@ -16,6 +17,7 @@ export default function NoteList({
filter?: Filter filter?: Filter
className?: string className?: string
}) { }) {
const { isReady, singEvent } = useNostr()
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [until, setUntil] = useState<number>(() => dayjs().unix()) const [until, setUntil] = useState<number>(() => dayjs().unix())
@@ -26,18 +28,23 @@ export default function NoteList({
const noteFilter = useMemo(() => { const noteFilter = useMemo(() => {
return { return {
kinds: [kinds.ShortTextNote, kinds.Repost], kinds: [kinds.ShortTextNote, kinds.Repost],
limit: 100, limit: 200,
...filter ...filter
} }
}, [JSON.stringify(filter)]) }, [JSON.stringify(filter)])
useEffect(() => { useEffect(() => {
if (!isReady) return
setInitialized(false) setInitialized(false)
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])
setHasMore(true) setHasMore(true)
const sub = client.subscribeEvents(relayUrls, noteFilter, { const subCloser = client.subscribeEventsWithAuth(
relayUrls,
noteFilter,
{
onEose: (events) => { onEose: (events) => {
const processedEvents = events.filter((e) => !isReplyNoteEvent(e)) const processedEvents = events.filter((e) => !isReplyNoteEvent(e))
if (processedEvents.length > 0) { if (processedEvents.length > 0) {
@@ -56,12 +63,14 @@ export default function NoteList({
setNewEvents((oldEvents) => [event, ...oldEvents]) setNewEvents((oldEvents) => [event, ...oldEvents])
} }
} }
}) },
singEvent
)
return () => { return () => {
sub.close() subCloser()
} }
}, [JSON.stringify(relayUrls), JSON.stringify(noteFilter)]) }, [JSON.stringify(relayUrls), JSON.stringify(noteFilter), isReady])
useEffect(() => { useEffect(() => {
if (!initialized) return if (!initialized) return

View File

@@ -9,6 +9,7 @@ import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
type TNostrContext = { type TNostrContext = {
isReady: boolean
pubkey: string | null pubkey: string | null
canLogin: boolean canLogin: boolean
login: (nsec: string) => Promise<string> login: (nsec: string) => Promise<string>
@@ -19,6 +20,7 @@ type TNostrContext = {
*/ */
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event> publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
signHttpAuth: (url: string, method: string) => Promise<string> signHttpAuth: (url: string, method: string) => Promise<string>
singEvent: (draftEvent: TDraftEvent) => Promise<Event>
checkLogin: (cb?: () => void | Promise<void>) => void checkLogin: (cb?: () => void | Promise<void>) => void
} }
@@ -34,17 +36,23 @@ export const useNostr = () => {
export function NostrProvider({ children }: { children: React.ReactNode }) { export function NostrProvider({ children }: { children: React.ReactNode }) {
const { toast } = useToast() const { toast } = useToast()
const [isReady, setIsReady] = useState(false)
const [pubkey, setPubkey] = useState<string | null>(null) const [pubkey, setPubkey] = useState<string | null>(null)
const [canLogin, setCanLogin] = useState(false) const [canLogin, setCanLogin] = useState(false)
const [openLoginDialog, setOpenLoginDialog] = useState(false) const [openLoginDialog, setOpenLoginDialog] = useState(false)
const relayList = useFetchRelayList(pubkey) const relayList = useFetchRelayList(pubkey)
useEffect(() => { useEffect(() => {
window.nostr?.getPublicKey().then((pubkey) => { if (window.nostr) {
window.nostr.getPublicKey().then((pubkey) => {
if (pubkey) { if (pubkey) {
setPubkey(pubkey) setPubkey(pubkey)
} }
setIsReady(true)
}) })
} else {
setIsReady(true)
}
if (isElectron(window)) { if (isElectron(window)) {
window.api?.system.isEncryptionAvailable().then((isEncryptionAvailable) => { window.api?.system.isEncryptionAvailable().then((isEncryptionAvailable) => {
setCanLogin(isEncryptionAvailable) setCanLogin(isEncryptionAvailable)
@@ -104,6 +112,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return event return event
} }
const singEvent = async (draftEvent: TDraftEvent) => {
const event = await window.nostr?.signEvent(draftEvent)
if (!event) {
throw new Error('sign event failed')
}
return event
}
const signHttpAuth = async (url: string, method: string) => { const signHttpAuth = async (url: string, method: string) => {
const event = await window.nostr?.signEvent({ const event = await window.nostr?.signEvent({
content: '', content: '',
@@ -142,7 +158,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return ( return (
<NostrContext.Provider <NostrContext.Provider
value={{ pubkey, canLogin, login, nip07Login, logout, publish, signHttpAuth, checkLogin }} value={{
isReady,
pubkey,
canLogin,
login,
nip07Login,
logout,
publish,
signHttpAuth,
checkLogin,
singEvent
}}
> >
{children} {children}
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} /> <LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />

View File

@@ -1,13 +1,20 @@
import { TRelayGroup } from '@common/types' import { TDraftEvent, TRelayGroup } from '@common/types'
import { formatPubkey } from '@renderer/lib/pubkey' import { formatPubkey } from '@renderer/lib/pubkey'
import { tagNameEquals } from '@renderer/lib/tag' import { tagNameEquals } from '@renderer/lib/tag'
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
import { TProfile, TRelayList } from '@renderer/types' import { TProfile, TRelayList } from '@renderer/types'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools' import {
EventTemplate,
Filter,
kinds,
Event as NEvent,
SimplePool,
VerifiedEvent
} from 'nostr-tools'
import { EVENT_TYPES, eventBus } from './event-bus.service' import { EVENT_TYPES, eventBus } from './event-bus.service'
import storage from './storage.service' import storage from './storage.service'
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
const BIG_RELAY_URLS = [ const BIG_RELAY_URLS = [
'wss://relay.damus.io/', 'wss://relay.damus.io/',
@@ -26,10 +33,7 @@ class ClientService {
private eventByFilterCache = new LRUCache<string, Promise<NEvent | undefined>>({ private eventByFilterCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000, max: 10000,
fetchMethod: async (filterStr) => { fetchMethod: async (filterStr) => {
const events = await this.fetchEvents( const events = await this.fetchEvents(BIG_RELAY_URLS, JSON.parse(filterStr))
BIG_RELAY_URLS.concat(this.relayUrls),
JSON.parse(filterStr)
)
events.forEach((event) => this.addEventToCache(event)) events.forEach((event) => this.addEventToCache(event))
return events.sort((a, b) => b.created_at - a.created_at)[0] return events.sort((a, b) => b.created_at - a.created_at)[0]
} }
@@ -85,44 +89,86 @@ class ClientService {
return await Promise.any(this.pool.publish(this.relayUrls.concat(relayUrls), event)) return await Promise.any(this.pool.publish(this.relayUrls.concat(relayUrls), event))
} }
subscribeEvents( subscribeEventsWithAuth(
urls: string[], urls: string[],
filter: Filter, filter: Filter,
opts: { {
onEose,
onNew
}: {
onEose: (events: NEvent[]) => void onEose: (events: NEvent[]) => void
onNew: (evt: NEvent) => void onNew: (evt: NEvent) => void
} },
signer?: (evt: TDraftEvent) => Promise<NEvent>
) { ) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this
const _knownIds = new Set<string>()
const events: NEvent[] = [] const events: NEvent[] = []
let eose = false let started = 0
return this.pool.subscribeMany( let eosed = 0
urls.length > 0 ? urls : this.relayUrls.concat(BIG_RELAY_URLS), const subPromises = urls.map(async (url) => {
[filter], const relay = await this.pool.ensureRelay(url)
{ let hasAuthed = false
onevent: (evt) => {
if (eose) { return startSub()
opts.onNew(evt)
function startSub() {
started++
return relay.subscribe([filter], {
alreadyHaveEvent: (id: string) => {
const have = _knownIds.has(id)
_knownIds.add(id)
return have
},
onevent(evt: NEvent) {
if (eosed === started) {
onNew(evt)
} else { } else {
events.push(evt) events.push(evt)
} }
that.eventByIdCache.set(evt.id, Promise.resolve(evt))
}, },
oneose: () => { onclose(reason: string) {
eose = true if (reason.startsWith('auth-required:')) {
opts.onEose(events.sort((a, b) => b.created_at - a.created_at)) if (!hasAuthed && signer) {
relay
.auth((authEvt: EventTemplate) => {
return signer(authEvt) as Promise<VerifiedEvent>
})
.then(() => {
hasAuthed = true
startSub()
})
}
}
}, },
onclose: () => { oneose() {
if (!eose) { eosed++
opts.onEose(events.sort((a, b) => b.created_at - a.created_at)) if (eosed === started) {
events.sort((a, b) => b.created_at - a.created_at)
onEose(events)
} }
} }
})
}
})
return () => {
onEose = () => {}
onNew = () => {}
subPromises.forEach((subPromise) => {
subPromise.then((sub) => {
sub.close()
})
})
} }
)
} }
async fetchEvents(relayUrls: string[], filter: Filter) { async fetchEvents(relayUrls: string[], filter: Filter) {
await this.initPromise await this.initPromise
// If relayUrls is empty, use this.relayUrls // If relayUrls is empty, use this.relayUrls
return await this.pool.querySync(relayUrls.length > 0 ? relayUrls : this.relayUrls, filter) return await this.pool.querySync(relayUrls.length > 0 ? relayUrls : BIG_RELAY_URLS, filter)
} }
async fetchEventByFilter(filter: Filter) { async fetchEventByFilter(filter: Filter) {
@@ -154,7 +200,7 @@ class ClientService {
} }
private async eventBatchLoadFn(ids: readonly string[]) { private async eventBatchLoadFn(ids: readonly string[]) {
const events = await this.fetchEvents(this.relayUrls, { const events = await this.fetchEvents(BIG_RELAY_URLS, {
ids: ids as string[], ids: ids as string[],
limit: ids.length limit: ids.length
}) })
@@ -163,25 +209,11 @@ class ClientService {
eventsMap.set(event.id, event) eventsMap.set(event.id, event)
} }
const missingIds = ids.filter((id) => !eventsMap.has(id))
if (missingIds.length > 0) {
const missingEvents = await this.fetchEvents(
BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url)),
{
ids: missingIds,
limit: missingIds.length
}
)
for (const event of missingEvents) {
eventsMap.set(event.id, event)
}
}
return ids.map((id) => eventsMap.get(id)) return ids.map((id) => eventsMap.get(id))
} }
private async profileBatchLoadFn(pubkeys: readonly string[]) { private async profileBatchLoadFn(pubkeys: readonly string[]) {
const events = await this.fetchEvents(this.relayUrls, { const events = await this.fetchEvents(BIG_RELAY_URLS, {
authors: pubkeys as string[], authors: pubkeys as string[],
kinds: [kinds.Metadata], kinds: [kinds.Metadata],
limit: pubkeys.length limit: pubkeys.length
@@ -195,25 +227,6 @@ class ClientService {
} }
} }
const missingPubkeys = pubkeys.filter((pubkey) => !eventsMap.has(pubkey))
if (missingPubkeys.length > 0) {
const missingEvents = await this.fetchEvents(
BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url)),
{
authors: missingPubkeys,
kinds: [kinds.Metadata],
limit: missingPubkeys.length
}
)
for (const event of missingEvents) {
const pubkey = event.pubkey
const existing = eventsMap.get(pubkey)
if (!existing || existing.created_at < event.created_at) {
eventsMap.set(pubkey, event)
}
}
}
return pubkeys.map((pubkey) => { return pubkeys.map((pubkey) => {
const event = eventsMap.get(pubkey) const event = eventsMap.get(pubkey)
return event ? this.parseProfileFromEvent(event) : undefined return event ? this.parseProfileFromEvent(event) : undefined
@@ -221,7 +234,7 @@ class ClientService {
} }
private async relayListBatchLoadFn(pubkeys: readonly string[]) { private async relayListBatchLoadFn(pubkeys: readonly string[]) {
const events = await this.fetchEvents(BIG_RELAY_URLS.concat(this.relayUrls), { const events = await this.fetchEvents(BIG_RELAY_URLS, {
authors: pubkeys as string[], authors: pubkeys as string[],
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
limit: pubkeys.length limit: pubkeys.length